use crate::{Error, Metadata, RenderedMetadata, ValidityCheck};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct Recipient {
#[serde(rename = "type")]
recipient_type: &'static str,
salt: String,
hashed: bool,
identity: String,
}
impl ValidityCheck for Recipient {
fn check_validity(&self) -> Result<(), Error> {
if self.recipient_type != "email" {
return Err(Error::InvalidPayload(
"Recipient type must be 'email'".to_string(),
));
}
if !self.hashed {
return Err(Error::InvalidPayload(
"Recipient email must be hashed".to_string(),
));
}
if self.salt.is_empty() {
return Err(Error::InvalidPayload(
"Recipient email hash must have be salted".to_string(),
));
}
if self.identity.is_empty() {
return Err(Error::InvalidPayload(
"Recipient identity must be set".to_string(),
));
}
Ok(())
}
}
impl ValidityCheck for Option<Recipient> {
fn check_validity(&self) -> Result<(), Error> {
if let Some(r) = self {
r.check_validity()
} else {
Err(Error::InvalidPayload("Missing recipient".to_string()))
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct Evidence {
#[serde(rename = "type")]
evidence_type: &'static str,
id: Option<String>,
narrative: Option<String>,
name: Option<String>,
description: Option<String>,
genre: Option<String>,
audience: Option<String>,
}
impl Default for Evidence {
fn default() -> Self {
Evidence {
evidence_type: "Evidence",
id: None,
narrative: None,
name: None,
description: None,
genre: None,
audience: None,
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct Verification {
#[serde(rename = "type")]
verification_type: &'static str,
#[serde(rename = "verificationProperty")]
verification_property: Option<String>,
#[serde(rename = "startsWith")]
starts_with: Option<String>,
#[serde(rename = "allowedOrigins")]
allowed_origins: Option<Vec<String>>,
url: Option<String>,
creator: Option<String>,
}
impl Default for Verification {
fn default() -> Self {
Verification {
verification_type: "HostedBadge",
verification_property: None,
starts_with: None,
allowed_origins: None,
url: None,
creator: None,
}
}
}
impl ValidityCheck for Verification {
fn check_validity(&self) -> Result<(), Error> {
if self.verification_type == "HostedBadge" {
Ok(())
} else if self.verification_type == "SignedBadge" {
Ok(())
} else {
Err(Error::InvalidPayload(
"Invalid verificatin type".to_string(),
))
}
}
}
impl ValidityCheck for Option<Verification> {
fn check_validity(&self) -> Result<(), Error> {
if let Some(v) = self {
v.check_validity()
} else {
Err(Error::InvalidPayload(
"Missing verification info".to_string(),
))
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct Criteria {
#[serde(rename = "type")]
criteria_type: &'static str,
id: Option<String>,
narrative: Option<String>,
}
impl Default for Criteria {
fn default() -> Self {
Criteria {
criteria_type: "Criteria",
id: None,
narrative: None,
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Issuer {
#[serde(rename = "@context")]
context: &'static str,
#[serde(rename = "type")]
issuer_type: &'static str,
id: Option<String>,
name: Option<String>,
url: Option<String>,
telephone: Option<String>,
description: Option<String>,
image: Option<String>,
email: Option<String>,
public_key: Option<String>,
verification: Option<Verification>,
#[serde(rename = "revocationList")]
revocation_list: Option<String>,
}
impl Issuer {
/// Set the issuer ID
///
/// The issuer ID should be a unique URL pointing to the same
/// issuer data that is contained in this entry.
pub fn with_id(mut self, id: impl Into<String>) -> Self {
self.id = Some(id.into());
self
}
/// Set the issuer name
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
/// Set the issuer home page URL
pub fn with_url(mut self, url: impl Into<String>) -> Self {
self.url = Some(url.into());
self
}
/// Set the issuer email
pub fn with_email(mut self, email: impl Into<String>) -> Self {
self.email = Some(email.into());
self
}
/// Set the issuer telephone number. Optional.
pub fn with_telephone(mut self, telephone: impl Into<String>) -> Self {
self.telephone = Some(telephone.into());
self
}
/// Set the issuer description. Optional.
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
/// Set the issuer image url. Optional.
pub fn with_image(mut self, url: impl Into<String>) -> Self {
self.image = Some(url.into());
self
}
// /// Set the issuer public key. Optional.
// pub fn with_public_key(mut self, public_key: impl Into<String>) -> Self {
// todo!("Sorry, signed badges are not yet implemented.");
// }
/// Set revocation list URL. Optional.
pub fn with_revocation_list(mut self, url: impl Into<String>) -> Self {
self.revocation_list = Some(url.into());
self
}
}
impl ValidityCheck for Issuer {
fn check_validity(&self) -> Result<(), Error> {
if self.issuer_type != "Issuer" {
return Err(Error::InvalidPayload("Invalid issuer type".to_string()));
}
if self.id.is_none() {
return Err(Error::InvalidPayload("Missing issuer id".to_string()));
}
if self.name.is_none() {
return Err(Error::InvalidPayload("Missing issuer name".to_string()));
}
if self.url.is_none() {
return Err(Error::InvalidPayload("Missing issuer url".to_string()));
}
if self.email.is_none() {
return Err(Error::InvalidPayload("Missing issuer email".to_string()));
}
Ok(())
}
}
impl ValidityCheck for Option<Issuer> {
fn check_validity(&self) -> Result<(), Error> {
if let Some(i) = self {
i.check_validity()
} else {
Err(Error::InvalidPayload("Missing issuer".to_string()))
}
}
}
impl Default for Issuer {
fn default() -> Self {
Issuer {
context: "https://w3id.org/openbadges/v2",
issuer_type: "Issuer",
id: None, // required
name: None, // required
url: None, // required
telephone: None,
description: None,
image: None,
email: None, // required
public_key: None,
verification: None,
revocation_list: None,
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Alignment {
#[serde(rename = "type")]
alignment_type: &'static str,
target_name: String,
target_url: String,
target_description: Option<String>,
target_framework: Option<String>,
target_code: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct BadgeClass {
#[serde(rename = "@context")]
context: &'static str,
#[serde(rename = "type")]
badgeclass_type: &'static str,
id: Option<String>,
name: Option<String>,
description: Option<String>,
image: Option<String>,
criteria: Option<Criteria>,
issuer: Option<Issuer>,
alignment: Vec<Alignment>,
tags: Vec<String>,
}
impl ValidityCheck for BadgeClass {
fn check_validity(&self) -> Result<(), Error> {
if self.badgeclass_type != "BadgeClass" {
return Err(Error::InvalidPayload(
"Invalid badge class type".to_string(),
));
}
if self.id.is_none() {
return Err(Error::InvalidPayload("Missing badge class id".to_string()));
}
if self.name.is_none() {
return Err(Error::InvalidPayload(
"Missing badge class name".to_string(),
));
}
if self.description.is_none() {
return Err(Error::InvalidPayload(
"Missing badge class description".to_string(),
));
}
if self.image.is_none() {
return Err(Error::InvalidPayload(
"Missing badge class image".to_string(),
));
}
if self.criteria.is_none() {
return Err(Error::InvalidPayload(
"Missing badge class criteria".to_string(),
));
}
self.issuer.check_validity()?;
Ok(())
}
}
impl ValidityCheck for Option<BadgeClass> {
fn check_validity(&self) -> Result<(), Error> {
if let Some(c) = self {
c.check_validity()
} else {
Err(Error::InvalidPayload(
"Missing badge class information".to_string(),
))
}
}
}
impl Default for BadgeClass {
fn default() -> Self {
BadgeClass {
context: "https://w3id.org/openbadges/v2",
badgeclass_type: "BadgeClass",
id: None,
name: None,
description: None,
image: None,
criteria: None,
issuer: None,
alignment: Vec::new(),
tags: Vec::new(),
}
}
}
// https://www.imsglobal.org/sites/default/files/Badges/OBv2p0/index.html
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Payload {
#[serde(rename = "@context")]
context: &'static str,
#[serde(rename = "type")]
payload_type: &'static str,
id: Option<String>,
recipient: Option<Recipient>,
badge: Option<BadgeClass>,
verification: Option<Verification>,
#[serde(rename = "issuedOn")]
issued_on: Option<jiff::Timestamp>,
image: Option<String>,
evidence: Vec<Evidence>,
narrative: Option<String>,
expires: Option<jiff::Timestamp>,
revoked: bool,
#[serde(rename = "revocationReason")]
revocation_reason: Option<String>,
}
impl Payload {
/// Set the ID of the assertion.
///
/// For hosted verification use, the assertion ID should be a
/// unique URL where this assertion can also be found.
///
/// For signature verification, the assertion ID can be a unique
/// URL as for hosted verification, or a globally unique
/// identifier such as a "urn:uuid:" URI.
pub fn with_id(mut self, id: impl Into<String>) -> Self {
self.id = Some(id.into());
self
}
/// Set the recipient of the assertion as a salted and hashed email.
///
/// This is the only format that is widely supported for the recipient.
pub fn with_recipient_email(mut self, email: impl AsRef<str>) -> Self {
use rand::prelude::*;
use sha2::{Digest, Sha256};
let mut rng = rand::rng();
let salt: String = (0..16)
.map(|_| rng.sample(rand::distr::Alphanumeric) as char)
.collect();
let mut hasher = Sha256::new();
hasher.update(email.as_ref());
hasher.update(&salt);
let hashed = hasher.finalize();
self.recipient = Some(Recipient {
recipient_type: "email",
hashed: true,
identity: format!("sha256${:x}", hashed),
salt,
});
self
}
/// Set the ID of the BadgeClass.
///
/// The ID should be a globally unique identifier for the
/// BadgeClass. Usually, it should be an HTTP or HTTPS URL
/// pointing to a location where the badge class data can also be
/// loaded, but in the case of a signed assertion it is allowed to
/// be any globally unique identifier.
pub fn with_badge_class_id(mut self, id: impl Into<String>) -> Self {
let mut badge = self.badge.unwrap_or_default();
badge.id = Some(id.into());
self.badge = Some(badge);
self
}
/// Set the name of the BadgeClass.
///
/// The name should be a human-readable name for the BadgeClass.
pub fn with_badge_class_name(mut self, name: impl Into<String>) -> Self {
let mut badge = self.badge.unwrap_or_default();
badge.name = Some(name.into());
self.badge = Some(badge);
self
}
/// Set the description of the BadgeClass.
///
/// The description should be a human-readable description of the BadgeClass.
pub fn with_badge_class_description(mut self, desc: impl Into<String>) -> Self {
let mut badge = self.badge.unwrap_or_default();
badge.description = Some(desc.into());
self.badge = Some(badge);
self
}
/// Set the image URL for the BadgeClass.
///
/// The URL should be an absolute URL that points to a non-badge
/// version of the badge image.
pub fn with_badge_class_image_url(mut self, url: impl Into<String>) -> Self {
let mut badge = self.badge.unwrap_or_default();
badge.image = Some(url.into());
self.badge = Some(badge);
self
}
/// Describe the criteria for earning this badge.
///
/// **id** should contain the URI of a webpage that describes in a human-readable format the criteria for the BadgeClass.
///
/// **narrative** should contain a text or Markdown description of what is needed to earn the badge.
pub fn with_badge_class_criteria(
mut self,
id: impl Into<String>,
narrative: impl Into<String>,
) -> Self {
let mut badge = self.badge.unwrap_or_default();
badge.criteria = Some(Criteria {
id: Some(id.into()),
narrative: Some(narrative.into()),
..Criteria::default()
});
self.badge = Some(badge);
self
}
/// Add an an alignment between a learning resource and a node in
/// an educational framework. Optional.
///
/// **name** is the name of the alignment.
///
/// **url** is a link to the official description of the alignment within the framework.
///
/// **description** is a short, human-readable description of the alignment.
///
/// **framework** is the name of the framework.
///
/// **code** is a locally unique identifier for the alignment within the framework.
pub fn add_badge_class_alignment<'d, 'f, 'c>(
mut self,
name: impl Into<String>,
url: impl Into<String>,
description: impl Into<Option<&'d str>>,
framework: impl Into<Option<&'f str>>,
code: impl Into<Option<&'c str>>,
) -> Self {
let mut badge = self.badge.unwrap_or_default();
badge.alignment.push(Alignment {
alignment_type: "AlignmentObject",
target_name: name.into(),
target_url: url.into(),
target_description: description.into().map(|x| x.to_owned()),
target_framework: framework.into().map(|x| x.to_owned()),
target_code: code.into().map(|x| x.to_owned()),
});
self.badge = Some(badge);
self
}
/// Add a tag. Optional.
pub fn add_badge_class_tag(mut self, tag: impl Into<String>) -> Self {
let mut badge = self.badge.unwrap_or_default();
badge.tags.push(tag.into());
self.badge = Some(badge);
self
}
/// Set the issuer for this badge.
pub fn with_issuer(mut self, issuer: Issuer) -> Self {
let mut badge = self.badge.unwrap_or_default();
badge.issuer = Some(issuer);
self.badge = Some(badge);
self
}
/// Set the issuance timestamp for the badge.
pub fn with_issued_timestamp(mut self, issued: jiff::Timestamp) -> Self {
self.issued_on = Some(issued);
self
}
// /// Set the badge signing key.
// ///
// /// If a signing key is set, the generated badge will be a signed verification badge rather than the default hosted verification.
// pub fn with_signing_key(mut self, key: ()) -> Self {
// todo!("Sorry, signed badges are not yet implemented.");
// }
/// Set the badge image.
pub fn with_image(mut self, url: impl Into<String>) -> Self {
self.image = Some(url.into());
self
}
/// Add an evidence entry to the badge. Optional.
///
/// **id** is the URL of a page presenting evidence of achievement.
///
/// **name** is a descriptive title for the evidence.
///
/// **description** is a longer description of the evidence.
///
/// **narrative** is a text or Markdown description of the evidence entry.
///
/// **genre** is a string that describes the type of evidence, e.g. Prose, Code, Image, etc.
///
/// **audience** is the a description of the intended audience for the evidence.
pub fn add_evidence<'i, 'nm, 'd, 'nr, 'g, 'a>(
mut self,
id: impl Into<Option<&'i str>>,
name: impl Into<Option<&'nm str>>,
description: impl Into<Option<&'d str>>,
narrative: impl Into<Option<&'nr str>>,
genre: impl Into<Option<&'g str>>,
audience: impl Into<Option<&'a str>>,
) -> Self {
self.evidence.push(Evidence {
id: id.into().map(|x| x.to_owned()),
name: name.into().map(|x| x.to_owned()),
description: description.into().map(|x| x.to_owned()),
narrative: narrative.into().map(|x| x.to_owned()),
genre: genre.into().map(|x| x.to_owned()),
audience: audience.into().map(|x| x.to_owned()),
..Evidence::default()
});
self
}
/// Set the narrative for the badge. Optional.
///
/// The narrative can be used to link multiple pieces of evidence
/// together in context with each other. Markdown is supported.
pub fn with_narrative(mut self, narrative: impl Into<String>) -> Self {
self.narrative = Some(narrative.into());
self
}
/// Sets the expiration timestamp for the badge. Optional.
pub fn with_expiration(mut self, expiration: jiff::Timestamp) -> Self {
self.expires = Some(expiration);
self
}
/// Marks the badge as revoked. Optional.
pub fn with_revocation(mut self, reason: impl Into<String>) -> Self {
self.revoked = true;
self.revocation_reason = Some(reason.into());
self
}
pub fn get_badge_class(&self) -> Option<BadgeClass> {
self.badge.clone()
}
}
impl Default for Payload {
fn default() -> Self {
Payload {
context: "https://w3id.org/openbadges/v2",
payload_type: "Assertion",
id: None,
recipient: None,
badge: None,
verification: Some(Verification::default()),
issued_on: None,
image: None,
evidence: Vec::new(),
narrative: None,
expires: None,
revoked: false,
revocation_reason: None,
}
}
}
impl ValidityCheck for Payload {
fn check_validity(&self) -> Result<(), Error> {
if self.id.is_none() {
return Err(Error::InvalidPayload("Missing ID".to_string()));
}
if self.image.is_none() {
return Err(Error::InvalidPayload("Missing image".to_string()));
}
self.recipient.check_validity()?;
self.badge.check_validity()?;
self.verification.check_validity()?;
Ok(())
}
}
impl crate::private::Sealed for Payload {}
impl Metadata for Payload {
fn render(mut self) -> Result<RenderedMetadata, Error> {
self.check_validity()?;
if self.issued_on.is_none() {
self.issued_on = Some(jiff::Timestamp::now());
}
let Some(verify) = self.id.clone() else {
return Err(Error::InvalidPayload(
"Assertion id is missing after being checked.".to_string(),
));
};
let metadata = serde_json::to_string(&self)?;
Ok(RenderedMetadata {
verify,
metadata,
signature: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn issuer_empty() {
let issuer = Issuer::default();
assert!(issuer.check_validity().is_err());
}
#[test]
fn issuer_missing_id() {
let issuer = Issuer::default()
.with_name("Example Issuer")
.with_url("https://example.com/")
.with_email("HkKzT@example.com")
.with_description("An example issuer")
.with_image("https://example.com/logo.png")
.with_telephone("555-555-1234")
.with_revocation_list("https://example.com/revocation.json");
assert!(issuer.check_validity().is_err());
}
#[test]
fn issuer_missing_name() {
let issuer = Issuer::default()
.with_id("https://example.com/issuer")
.with_url("https://example.com/")
.with_email("HkKzT@example.com")
.with_description("An example issuer")
.with_image("https://example.com/logo.png")
.with_telephone("555-555-1234")
.with_revocation_list("https://example.com/revocation.json");
assert!(issuer.check_validity().is_err());
}
#[test]
fn issuer_missing_url() {
let issuer = Issuer::default()
.with_id("https://example.com/issuer")
.with_name("Example Issuer")
.with_email("HkKzT@example.com")
.with_description("An example issuer")
.with_image("https://example.com/logo.png")
.with_telephone("555-555-1234")
.with_revocation_list("https://example.com/revocation.json");
assert!(issuer.check_validity().is_err());
}
#[test]
fn issuer_missing_email() {
let issuer = Issuer::default()
.with_id("https://example.com/issuer")
.with_name("Example Issuer")
.with_url("https://example.com/")
.with_description("An example issuer")
.with_image("https://example.com/logo.png")
.with_telephone("555-555-1234")
.with_revocation_list("https://example.com/revocation.json");
assert!(issuer.check_validity().is_err());
}
#[test]
fn issuer_minimal() {
let issuer = Issuer::default()
.with_id("https://example.com/issuer")
.with_name("Example Issuer")
.with_url("https://example.com/")
.with_email("HkKzT@example.com");
issuer.check_validity().unwrap();
assert_eq!(
serde_json::to_string_pretty(&issuer).unwrap(),
r#"{
"@context": "https://w3id.org/openbadges/v2",
"type": "Issuer",
"id": "https://example.com/issuer",
"name": "Example Issuer",
"url": "https://example.com/",
"telephone": null,
"description": null,
"image": null,
"email": "HkKzT@example.com",
"public_key": null,
"verification": null,
"revocationList": null
}"#
);
}
#[test]
fn issuer_maximal() {
let issuer = Issuer::default()
.with_id("https://example.com/issuer")
.with_name("Example Issuer")
.with_url("https://example.com/")
.with_email("HkKzT@example.com")
.with_description("An example issuer")
.with_image("https://example.com/logo.png")
.with_telephone("555-555-1234")
.with_revocation_list("https://example.com/revocation.json");
issuer.check_validity().unwrap();
assert_eq!(
serde_json::to_string_pretty(&issuer).unwrap(),
r#"{
"@context": "https://w3id.org/openbadges/v2",
"type": "Issuer",
"id": "https://example.com/issuer",
"name": "Example Issuer",
"url": "https://example.com/",
"telephone": "555-555-1234",
"description": "An example issuer",
"image": "https://example.com/logo.png",
"email": "HkKzT@example.com",
"public_key": null,
"verification": null,
"revocationList": "https://example.com/revocation.json"
}"#
);
}
}