Skip to main content

cufflink_types/host/
storage.rs

1//! `s3_upload` and `s3_head`.
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(deny_unknown_fields)]
7pub struct UploadRequest {
8    pub bucket: String,
9    pub key: String,
10    pub content_type: String,
11    #[serde(rename = "body_b64", with = "crate::serde_util::base64_bytes")]
12    pub body: Vec<u8>,
13    #[serde(default, skip_serializing_if = "Option::is_none")]
14    pub content_disposition: Option<String>,
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub cache_control: Option<String>,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(tag = "result", rename_all = "snake_case")]
21pub enum UploadResponse {
22    Ok { etag: Option<String>, size: u64 },
23    Error(StorageErrorEnvelope),
24}
25
26#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct HeadRequest {
29    pub bucket: String,
30    pub key: String,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(tag = "result", rename_all = "snake_case")]
35pub enum HeadResponse {
36    Found {
37        size: u64,
38        content_type: Option<String>,
39        etag: Option<String>,
40        /// RFC 3339.
41        last_modified: Option<String>,
42    },
43    NotFound,
44    Error(StorageErrorEnvelope),
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct StorageErrorEnvelope {
50    pub kind: StorageErrorKind,
51    pub message: String,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum StorageErrorKind {
57    Denied,
58    TooLarge,
59    NotConfigured,
60    Timeout,
61    Backend,
62}
63
64impl StorageErrorKind {
65    pub fn as_str(self) -> &'static str {
66        match self {
67            StorageErrorKind::Denied => "denied",
68            StorageErrorKind::TooLarge => "too_large",
69            StorageErrorKind::NotConfigured => "not_configured",
70            StorageErrorKind::Timeout => "timeout",
71            StorageErrorKind::Backend => "backend",
72        }
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79    use crate::host::tests::{refused, round_trip};
80    use serde_json::json;
81
82    const KINDS: [StorageErrorKind; 5] = [
83        StorageErrorKind::Denied,
84        StorageErrorKind::TooLarge,
85        StorageErrorKind::NotConfigured,
86        StorageErrorKind::Timeout,
87        StorageErrorKind::Backend,
88    ];
89
90    #[test]
91    fn upload_round_trips() {
92        round_trip(
93            &UploadRequest {
94                bucket: "receipts".into(),
95                key: "book-1/receipt-2.pdf".into(),
96                content_type: "application/pdf".into(),
97                body: b"%PDF".to_vec(),
98                ..Default::default()
99            },
100            json!({
101                "bucket": "receipts",
102                "key": "book-1/receipt-2.pdf",
103                "content_type": "application/pdf",
104                "body_b64": "JVBERg=="
105            }),
106        );
107        round_trip(
108            &UploadRequest {
109                bucket: "receipts".into(),
110                key: "k".into(),
111                content_type: "text/plain".into(),
112                body: Vec::new(),
113                content_disposition: Some("attachment; filename=\"q.pdf\"".into()),
114                cache_control: Some("no-store".into()),
115            },
116            json!({
117                "bucket": "receipts",
118                "key": "k",
119                "content_type": "text/plain",
120                "body_b64": "",
121                "content_disposition": "attachment; filename=\"q.pdf\"",
122                "cache_control": "no-store"
123            }),
124        );
125        round_trip(
126            &UploadResponse::Ok {
127                etag: Some("\"abc\"".into()),
128                size: 4,
129            },
130            json!({"result": "ok", "etag": "\"abc\"", "size": 4}),
131        );
132        round_trip(
133            &UploadResponse::Ok {
134                etag: None,
135                size: 0,
136            },
137            json!({"result": "ok", "etag": null, "size": 0}),
138        );
139    }
140
141    #[test]
142    fn head_round_trips() {
143        round_trip(
144            &HeadRequest {
145                bucket: "receipts".into(),
146                key: "k".into(),
147            },
148            json!({"bucket": "receipts", "key": "k"}),
149        );
150        round_trip(
151            &HeadResponse::Found {
152                size: 9,
153                content_type: Some("application/pdf".into()),
154                etag: None,
155                last_modified: Some("2026-09-28T10:00:00Z".into()),
156            },
157            json!({
158                "result": "found",
159                "size": 9,
160                "content_type": "application/pdf",
161                "etag": null,
162                "last_modified": "2026-09-28T10:00:00Z"
163            }),
164        );
165        round_trip(&HeadResponse::NotFound, json!({"result": "not_found"}));
166    }
167
168    #[test]
169    fn errors_name_their_kind() {
170        for kind in KINDS {
171            let envelope = StorageErrorEnvelope {
172                kind,
173                message: "why".into(),
174            };
175            let wire = json!({"result": "error", "kind": kind.as_str(), "message": "why"});
176            round_trip(&UploadResponse::Error(envelope.clone()), wire.clone());
177            round_trip(&HeadResponse::Error(envelope), wire);
178        }
179    }
180
181    #[test]
182    fn unknown_tags_kinds_and_bodies_are_refused() {
183        refused::<UploadResponse>(json!({"result": "stored"}));
184        refused::<HeadResponse>(json!({"result": "gone"}));
185        refused::<HeadResponse>(json!({"result": "error", "kind": "quota", "message": ""}));
186        refused::<UploadRequest>(
187            json!({"bucket": "b", "key": "k", "content_type": "t", "body_b64": "*"}),
188        );
189        refused::<UploadRequest>(json!({"bucket": "b", "key": "k", "content_type": "t"}));
190        refused::<HeadRequest>(json!({"bucket": "b", "key": "k", "version": "1"}));
191    }
192}