mhome-artifact-api 0.4.0

Storage-independent artifact references shared by mHome runtimes
Documentation
use std::fmt;

use serde::{Deserialize, Serialize};

use crate::{ArtifactKind, ArtifactReference};

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ResolveArtifactRequest {
    pub uri: String,
}

impl ResolveArtifactRequest {
    pub fn reference(&self) -> Result<ArtifactReference, crate::ArtifactReferenceError> {
        ArtifactReference::parse(&self.uri)
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "SCREAMING_SNAKE_CASE", deny_unknown_fields)]
pub enum ArtifactDelivery {
    DataUrl {
        #[serde(rename = "dataUrl")]
        data_url: String,
    },
    SignedUrl {
        url: String,
        #[serde(rename = "expiresAtUnixMs")]
        expires_at_unix_ms: u64,
    },
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ResolveArtifactResponse {
    pub uri: String,
    pub kind: ArtifactKind,
    pub mime_type: String,
    pub size_bytes: u64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub width: Option<u32>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub height: Option<u32>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub duration_millis: Option<u64>,
    pub delivery: ArtifactDelivery,
}

impl ResolveArtifactResponse {
    pub fn validate(&self) -> Result<ArtifactReference, ResolveArtifactResponseError> {
        let reference = ArtifactReference::parse(&self.uri)
            .map_err(|_| invalid("artifact resolve URI is invalid"))?;
        let metadata = reference.metadata();
        if self.kind != metadata.kind()
            || self.mime_type != metadata.mime_type()
            || self.size_bytes != metadata.size_bytes()
            || self.width != metadata.width()
            || self.height != metadata.height()
            || self.duration_millis != metadata.duration_millis()
        {
            return Err(invalid(
                "artifact resolve metadata does not match its canonical reference",
            ));
        }
        match &self.delivery {
            ArtifactDelivery::DataUrl { data_url } => {
                let prefix = format!("data:{};base64,", self.mime_type);
                if !data_url.starts_with(&prefix) || data_url.len() == prefix.len() {
                    return Err(invalid("artifact resolve data URL is invalid"));
                }
            }
            ArtifactDelivery::SignedUrl {
                url,
                expires_at_unix_ms,
            } => {
                if !(url.starts_with("https://") || url.starts_with("http://"))
                    || *expires_at_unix_ms == 0
                {
                    return Err(invalid("artifact resolve signed URL is invalid"));
                }
            }
        }
        Ok(reference)
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolveArtifactResponseError(&'static str);

impl fmt::Display for ResolveArtifactResponseError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.0)
    }
}

impl std::error::Error for ResolveArtifactResponseError {}

fn invalid(message: &'static str) -> ResolveArtifactResponseError {
    ResolveArtifactResponseError(message)
}

#[cfg(test)]
mod tests {
    use super::*;

    const FILE_URI: &str = "meow-artifact://v1/tenant/scope/cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc/eyJrIjoiZiIsIm0iOiJhcHBsaWNhdGlvbi9wZGYiLCJzIjozMH0";

    #[test]
    fn validates_response_against_reference() {
        let response = ResolveArtifactResponse {
            uri: FILE_URI.to_string(),
            kind: ArtifactKind::File,
            mime_type: "application/pdf".to_string(),
            size_bytes: 30,
            width: None,
            height: None,
            duration_millis: None,
            delivery: ArtifactDelivery::SignedUrl {
                url: "https://example.test/file".to_string(),
                expires_at_unix_ms: 1,
            },
        };

        assert_eq!(response.validate().unwrap().uri().unwrap(), FILE_URI);
    }

    #[test]
    fn rejects_response_metadata_drift() {
        let response = ResolveArtifactResponse {
            uri: FILE_URI.to_string(),
            kind: ArtifactKind::File,
            mime_type: "text/plain".to_string(),
            size_bytes: 30,
            width: None,
            height: None,
            duration_millis: None,
            delivery: ArtifactDelivery::DataUrl {
                data_url: "data:text/plain;base64,eA==".to_string(),
            },
        };

        assert!(response.validate().is_err());
    }
}