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}