1use 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 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}