v2.rs raw

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"
}"#
        );
    }
}