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 { 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, narrative: Option, name: Option, description: Option, genre: Option, audience: Option, } 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, #[serde(rename = "startsWith")] starts_with: Option, #[serde(rename = "allowedOrigins")] allowed_origins: Option>, url: Option, creator: Option, } 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 { 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, narrative: Option, } 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, name: Option, url: Option, telephone: Option, description: Option, image: Option, email: Option, public_key: Option, verification: Option, #[serde(rename = "revocationList")] revocation_list: Option, } 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) -> Self { self.id = Some(id.into()); self } /// Set the issuer name pub fn with_name(mut self, name: impl Into) -> Self { self.name = Some(name.into()); self } /// Set the issuer home page URL pub fn with_url(mut self, url: impl Into) -> Self { self.url = Some(url.into()); self } /// Set the issuer email pub fn with_email(mut self, email: impl Into) -> Self { self.email = Some(email.into()); self } /// Set the issuer telephone number. Optional. pub fn with_telephone(mut self, telephone: impl Into) -> Self { self.telephone = Some(telephone.into()); self } /// Set the issuer description. Optional. pub fn with_description(mut self, description: impl Into) -> Self { self.description = Some(description.into()); self } /// Set the issuer image url. Optional. pub fn with_image(mut self, url: impl Into) -> Self { self.image = Some(url.into()); self } // /// Set the issuer public key. Optional. // pub fn with_public_key(mut self, public_key: impl Into) -> Self { // todo!("Sorry, signed badges are not yet implemented."); // } /// Set revocation list URL. Optional. pub fn with_revocation_list(mut self, url: impl Into) -> 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 { 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, target_framework: Option, target_code: Option, } #[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, name: Option, description: Option, image: Option, criteria: Option, issuer: Option, alignment: Vec, tags: Vec, } 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 { 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, recipient: Option, badge: Option, verification: Option, #[serde(rename = "issuedOn")] issued_on: Option, image: Option, evidence: Vec, narrative: Option, expires: Option, revoked: bool, #[serde(rename = "revocationReason")] revocation_reason: Option, } 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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, narrative: impl Into, ) -> 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, url: impl Into, description: impl Into>, framework: impl Into>, code: impl Into>, ) -> 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) -> 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) -> 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>, name: impl Into>, description: impl Into>, narrative: impl Into>, genre: impl Into>, audience: impl Into>, ) -> 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) -> 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) -> Self { self.revoked = true; self.revocation_reason = Some(reason.into()); self } pub fn get_badge_class(&self) -> Option { 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 { 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" }"# ); } }