mhome-artifact-api 1.2.0

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

use base64::{engine::general_purpose::STANDARD, Engine as _};
use serde::{Deserialize, Serialize};

use crate::{ArtifactKind, ArtifactReference};

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PutArtifactRequest {
    pub kind: ArtifactKind,
    pub mime_type: String,
    pub data_base64: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub duration_millis: Option<u64>,
}

impl PutArtifactRequest {
    pub fn decode(&self) -> Result<Vec<u8>, PutArtifactValidationError> {
        if self.mime_type.trim() != self.mime_type || self.mime_type.is_empty() {
            return Err(invalid("artifact put MIME type is invalid"));
        }
        match self.kind {
            ArtifactKind::Image if !self.mime_type.starts_with("image/") => {
                return Err(invalid("artifact put image MIME type is invalid"));
            }
            ArtifactKind::Audio if !self.mime_type.starts_with("audio/") => {
                return Err(invalid("artifact put audio MIME type is invalid"));
            }
            ArtifactKind::Video if !self.mime_type.starts_with("video/") => {
                return Err(invalid("artifact put video MIME type is invalid"));
            }
            ArtifactKind::File if self.mime_type.starts_with("video/") => {
                return Err(invalid("video artifacts are not supported"));
            }
            _ => {}
        }
        if !matches!(self.kind, ArtifactKind::Audio | ArtifactKind::Video)
            && self.duration_millis.is_some()
        {
            return Err(invalid(
                "artifact put duration is only valid for audio or video",
            ));
        }
        if self.duration_millis == Some(0) {
            return Err(invalid("artifact put duration is invalid"));
        }
        let bytes = STANDARD
            .decode(&self.data_base64)
            .map_err(|_| invalid("artifact put content is invalid base64"))?;
        if bytes.is_empty() {
            return Err(invalid("artifact put content is empty"));
        }
        Ok(bytes)
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PutArtifactResponse {
    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>,
}

impl PutArtifactResponse {
    pub fn validate(&self) -> Result<ArtifactReference, PutArtifactValidationError> {
        let reference = ArtifactReference::parse(&self.uri)
            .map_err(|_| invalid("artifact put 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 put metadata does not match its canonical reference",
            ));
        }
        Ok(reference)
    }
}

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

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

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

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

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

    #[test]
    fn request_rejects_kind_mime_mismatch() {
        let request = PutArtifactRequest {
            kind: ArtifactKind::Audio,
            mime_type: "image/png".to_string(),
            data_base64: "eA==".to_string(),
            duration_millis: None,
        };
        assert!(request.decode().is_err());
    }
}