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