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 identity: crate::ArtifactIdentity,
13    #[serde(default)]
14    pub retention: crate::ArtifactRetention,
15    #[serde(
16        default,
17        skip_serializing_if = "Option::is_none",
18        deserialize_with = "crate::deserialize_optional_non_null"
19    )]
20    pub creation_id: Option<String>,
21    pub kind: ArtifactKind,
22    pub mime_type: String,
23    pub data_base64: String,
24    #[serde(
25        default,
26        skip_serializing_if = "Option::is_none",
27        deserialize_with = "crate::deserialize_optional_non_null"
28    )]
29    pub duration_millis: Option<u64>,
30}
31
32impl PutArtifactRequest {
33    pub fn decode(&self) -> Result<Vec<u8>, PutArtifactValidationError> {
34        self.policy()
35            .validate_creation_id(self.creation_id.as_deref())
36            .map_err(|_| invalid("invalid artifact creation ID"))?;
37        crate::validate_mime_type(&self.mime_type)
38            .map_err(|_| invalid("artifact put MIME type is invalid"))?;
39        match self.kind {
40            ArtifactKind::Image if !self.mime_type.starts_with("image/") => {
41                return Err(invalid("artifact put image MIME type is invalid"));
42            }
43            ArtifactKind::Audio if !self.mime_type.starts_with("audio/") => {
44                return Err(invalid("artifact put audio MIME type is invalid"));
45            }
46            ArtifactKind::Video if !self.mime_type.starts_with("video/") => {
47                return Err(invalid("artifact put video MIME type is invalid"));
48            }
49            ArtifactKind::File if self.mime_type.starts_with("video/") => {
50                return Err(invalid("video artifacts are not supported"));
51            }
52            _ => {}
53        }
54        crate::validate_duration_millis(self.kind, self.duration_millis).map_err(invalid)?;
55        let bytes = STANDARD
56            .decode(&self.data_base64)
57            .map_err(|_| invalid("artifact put content is invalid base64"))?;
58        crate::validate_size_bytes(bytes.len() as u64).map_err(invalid)?;
59        Ok(bytes)
60    }
61}
62
63object_message! {
64pub struct PutArtifactResponse {
65    #[serde(default)]
66    pub identity: crate::ArtifactIdentity,
67    #[serde(default)]
68    pub retention: crate::ArtifactRetention,
69    #[serde(
70        default,
71        skip_serializing_if = "Option::is_none",
72        deserialize_with = "crate::deserialize_optional_non_null"
73    )]
74    pub expires_at_unix_ms: Option<u64>,
75    pub uri: String,
76    pub kind: ArtifactKind,
77    pub mime_type: String,
78    pub size_bytes: u64,
79    #[serde(
80        default,
81        skip_serializing_if = "Option::is_none",
82        deserialize_with = "crate::deserialize_optional_non_null"
83    )]
84    pub width: Option<u32>,
85    #[serde(
86        default,
87        skip_serializing_if = "Option::is_none",
88        deserialize_with = "crate::deserialize_optional_non_null"
89    )]
90    pub height: Option<u32>,
91    #[serde(
92        default,
93        skip_serializing_if = "Option::is_none",
94        deserialize_with = "crate::deserialize_optional_non_null"
95    )]
96    pub duration_millis: Option<u64>,
97}
98}
99
100impl PutArtifactResponse {
101    pub fn validate(&self) -> Result<ArtifactReference, PutArtifactValidationError> {
102        if self.policy().is_temporary() != self.expires_at_unix_ms.is_some()
103            || self.expires_at_unix_ms == Some(0)
104        {
105            return Err(invalid("artifact lifetime and expiration are inconsistent"));
106        }
107        let reference = ArtifactReference::parse(&self.uri)
108            .map_err(|_| invalid("artifact put URI is invalid"))?;
109        let metadata = reference.metadata();
110        if self.policy() != reference.policy()
111            || self.kind != metadata.kind()
112            || self.mime_type != metadata.mime_type()
113            || self.size_bytes != metadata.size_bytes()
114            || self.width != metadata.width()
115            || self.height != metadata.height()
116            || self.duration_millis != metadata.duration_millis()
117        {
118            return Err(invalid(
119                "artifact put metadata does not match its canonical reference",
120            ));
121        }
122        Ok(reference)
123    }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct PutArtifactValidationError(&'static str);
128
129impl fmt::Display for PutArtifactValidationError {
130    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
131        formatter.write_str(self.0)
132    }
133}
134
135impl std::error::Error for PutArtifactValidationError {}
136
137fn invalid(message: &'static str) -> PutArtifactValidationError {
138    PutArtifactValidationError(message)
139}
140
141impl PutArtifactRequest {
142    pub const fn policy(&self) -> crate::ArtifactPolicy {
143        crate::ArtifactPolicy::new(self.identity, self.retention)
144    }
145}
146
147impl PutArtifactResponse {
148    pub const fn policy(&self) -> crate::ArtifactPolicy {
149        crate::ArtifactPolicy::new(self.identity, self.retention)
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn request_rejects_kind_mime_mismatch() {
159        let request = PutArtifactRequest {
160            identity: crate::ArtifactPolicy::AUTO_STD.identity,
161
162            retention: crate::ArtifactPolicy::AUTO_STD.retention,
163            creation_id: None,
164            kind: ArtifactKind::Audio,
165            mime_type: "image/png".to_string(),
166            data_base64: "eA==".to_string(),
167            duration_millis: None,
168        };
169        assert!(request.decode().is_err());
170    }
171}
172
173#[cfg(test)]
174mod lifecycle_tests {
175    use super::*;
176    #[test]
177    fn default_ingress_is_durable_and_temporary_is_explicit() {
178        let mut input: serde_json::Value =
179            serde_json::json!({"kind":"FILE","mimeType":"text/plain","dataBase64":"eA=="});
180        let durable: PutArtifactRequest = serde_json::from_value(input.clone()).unwrap();
181        assert!(!durable.policy().is_temporary());
182        assert_eq!(serde_json::to_value(&durable).unwrap()["retention"], "std");
183        input["retention"] = "tmp".into();
184        let temporary: PutArtifactRequest = serde_json::from_value(input).unwrap();
185        assert!(temporary.policy().is_temporary());
186        assert_eq!(temporary.decode().unwrap(), b"x");
187    }
188}