Skip to main content

artifact_api/
put.rs

1use std::fmt;
2
3use base64::{engine::general_purpose::STANDARD, Engine as _};
4use serde::{Deserialize, Serialize};
5
6use crate::{ArtifactKind, ArtifactReference};
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "camelCase", deny_unknown_fields)]
10pub struct PutArtifactRequest {
11    #[serde(default)]
12    pub lifecycle: crate::ArtifactLifecycle,
13    #[serde(default, skip_serializing_if = "Option::is_none")]
14    pub creation_id: Option<String>,
15    pub kind: ArtifactKind,
16    pub mime_type: String,
17    pub data_base64: String,
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub duration_millis: Option<u64>,
20}
21
22impl PutArtifactRequest {
23    pub fn decode(&self) -> Result<Vec<u8>, PutArtifactValidationError> {
24        self.lifecycle
25            .validate_creation_id(self.creation_id.as_deref())
26            .map_err(|_| invalid("invalid artifact creation ID"))?;
27        if self.mime_type.trim() != self.mime_type || self.mime_type.is_empty() {
28            return Err(invalid("artifact put MIME type is invalid"));
29        }
30        match self.kind {
31            ArtifactKind::Image if !self.mime_type.starts_with("image/") => {
32                return Err(invalid("artifact put image MIME type is invalid"));
33            }
34            ArtifactKind::Audio if !self.mime_type.starts_with("audio/") => {
35                return Err(invalid("artifact put audio MIME type is invalid"));
36            }
37            ArtifactKind::Video if !self.mime_type.starts_with("video/") => {
38                return Err(invalid("artifact put video MIME type is invalid"));
39            }
40            ArtifactKind::File if self.mime_type.starts_with("video/") => {
41                return Err(invalid("video artifacts are not supported"));
42            }
43            _ => {}
44        }
45        if !matches!(self.kind, ArtifactKind::Audio | ArtifactKind::Video)
46            && self.duration_millis.is_some()
47        {
48            return Err(invalid(
49                "artifact put duration is only valid for audio or video",
50            ));
51        }
52        if self.duration_millis == Some(0) {
53            return Err(invalid("artifact put duration is invalid"));
54        }
55        let bytes = STANDARD
56            .decode(&self.data_base64)
57            .map_err(|_| invalid("artifact put content is invalid base64"))?;
58        if bytes.is_empty() {
59            return Err(invalid("artifact put content is empty"));
60        }
61        Ok(bytes)
62    }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase", deny_unknown_fields)]
67pub struct PutArtifactResponse {
68    #[serde(default)]
69    pub lifecycle: crate::ArtifactLifecycle,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub expires_at_unix_ms: Option<u64>,
72    pub uri: String,
73    pub kind: ArtifactKind,
74    pub mime_type: String,
75    pub size_bytes: u64,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub width: Option<u32>,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub height: Option<u32>,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub duration_millis: Option<u64>,
82}
83
84impl PutArtifactResponse {
85    pub fn validate(&self) -> Result<ArtifactReference, PutArtifactValidationError> {
86        if self.lifecycle.is_temporary() != self.expires_at_unix_ms.is_some()
87            || self.expires_at_unix_ms == Some(0)
88        {
89            return Err(invalid("artifact lifetime and expiration are inconsistent"));
90        }
91        let reference = ArtifactReference::parse(&self.uri)
92            .map_err(|_| invalid("artifact put URI is invalid"))?;
93        let metadata = reference.metadata();
94        if self.lifecycle != reference.lifecycle()
95            || self.kind != metadata.kind()
96            || self.mime_type != metadata.mime_type()
97            || self.size_bytes != metadata.size_bytes()
98            || self.width != metadata.width()
99            || self.height != metadata.height()
100            || self.duration_millis != metadata.duration_millis()
101        {
102            return Err(invalid(
103                "artifact put metadata does not match its canonical reference",
104            ));
105        }
106        Ok(reference)
107    }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct PutArtifactValidationError(&'static str);
112
113impl fmt::Display for PutArtifactValidationError {
114    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
115        formatter.write_str(self.0)
116    }
117}
118
119impl std::error::Error for PutArtifactValidationError {}
120
121fn invalid(message: &'static str) -> PutArtifactValidationError {
122    PutArtifactValidationError(message)
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn request_rejects_kind_mime_mismatch() {
131        let request = PutArtifactRequest {
132            lifecycle: crate::ArtifactLifecycle::Std,
133            creation_id: None,
134            kind: ArtifactKind::Audio,
135            mime_type: "image/png".to_string(),
136            data_base64: "eA==".to_string(),
137            duration_millis: None,
138        };
139        assert!(request.decode().is_err());
140    }
141}
142
143#[cfg(test)]
144mod lifecycle_tests {
145    use super::*;
146    #[test]
147    fn default_ingress_is_durable_and_temporary_is_explicit() {
148        let mut input: serde_json::Value =
149            serde_json::json!({"kind":"FILE","mimeType":"text/plain","dataBase64":"eA=="});
150        let durable: PutArtifactRequest = serde_json::from_value(input.clone()).unwrap();
151        assert!(!durable.lifecycle.is_temporary());
152        assert_eq!(serde_json::to_value(&durable).unwrap()["lifecycle"], "std");
153        input["lifecycle"] = "tmp".into();
154        let temporary: PutArtifactRequest = serde_json::from_value(input).unwrap();
155        assert!(temporary.lifecycle.is_temporary());
156        assert_eq!(temporary.decode().unwrap(), b"x");
157    }
158}