1use core::fmt;
54
55use alloc::{string::String, vec::Vec};
56
57use io_http::{
58 coroutine::*,
59 rfc9110::{request::HttpRequest, send::HttpSendOutput},
60 rfc9112::send::{Http11Send, Http11SendError},
61};
62use log::trace;
63use secrecy::{ExposeSecret, SecretString};
64use serde::Deserialize;
65use thiserror::Error;
66use url::Url;
67
68use crate::{coroutine::*, rfc8620::coroutine::JmapRedirectYield};
69
70#[derive(Debug, Error)]
72pub enum JmapBlobUploadError {
73 #[error("JMAP blob-upload failed: HTTP {0}")]
74 HttpStatus(u16),
75 #[error("JMAP blob-upload failed: {0}")]
76 Send(#[from] Http11SendError),
77 #[error("JMAP blob-upload failed: parse response: {0}")]
78 ParseResponse(#[source] serde_json::Error),
79}
80
81#[derive(Clone, Debug)]
83pub struct JmapBlobUploadOutput {
84 pub blob_id: String,
85 pub blob_type: String,
86 pub size: u64,
87 pub keep_alive: bool,
88}
89
90#[derive(Deserialize)]
91#[serde(rename_all = "camelCase")]
92struct BlobUploadResponse {
93 blob_id: String,
94 r#type: String,
95 size: u64,
96}
97
98pub struct JmapBlobUpload {
100 state: State,
101}
102
103impl JmapBlobUpload {
104 pub fn new(
108 http_auth: &SecretString,
109 upload_url: &Url,
110 content_type: &str,
111 data: Vec<u8>,
112 ) -> Self {
113 let host = upload_url.host_str().unwrap_or("localhost");
114
115 let mut http_request = HttpRequest::get(upload_url.clone())
116 .header("Host", host)
117 .header("Content-Type", content_type)
118 .header("Authorization", http_auth.expose_secret())
119 .body(data);
120 http_request.method = "POST".into();
121
122 trace!("upload JMAP blob to {upload_url}");
123
124 Self {
125 state: State::Send(Http11Send::new(http_request)),
126 }
127 }
128}
129
130impl JmapCoroutine for JmapBlobUpload {
131 type Yield = JmapRedirectYield;
132 type Return = Result<JmapBlobUploadOutput, JmapBlobUploadError>;
133
134 fn resume(&mut self, arg: Option<&[u8]>) -> JmapCoroutineState<Self::Yield, Self::Return> {
135 trace!("blob-upload: {}", self.state);
136 match &mut self.state {
137 State::Send(send) => match send.resume(arg) {
138 HttpCoroutineState::Yielded(y) => JmapCoroutineState::Yielded(y.into()),
139 HttpCoroutineState::Complete(Err(err)) => {
140 JmapCoroutineState::Complete(Err(err.into()))
141 }
142 HttpCoroutineState::Complete(Ok(HttpSendOutput {
143 response,
144 keep_alive,
145 ..
146 })) => {
147 if !response.status.is_success() {
148 let err = JmapBlobUploadError::HttpStatus(*response.status);
149 return JmapCoroutineState::Complete(Err(err));
150 }
151
152 match serde_json::from_slice::<BlobUploadResponse>(&response.body) {
153 Ok(r) => JmapCoroutineState::Complete(Ok(JmapBlobUploadOutput {
154 blob_id: r.blob_id,
155 blob_type: r.r#type,
156 size: r.size,
157 keep_alive,
158 })),
159 Err(err) => JmapCoroutineState::Complete(Err(
160 JmapBlobUploadError::ParseResponse(err),
161 )),
162 }
163 }
164 },
165 }
166 }
167}
168
169enum State {
170 Send(Http11Send),
171}
172
173impl fmt::Display for State {
174 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175 match self {
176 Self::Send(_) => f.write_str("send"),
177 }
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use alloc::format;
184
185 use super::*;
186
187 fn make_auth() -> SecretString {
188 SecretString::from("Bearer test")
189 }
190
191 fn make_url() -> Url {
192 "https://api.example.com/jmap/upload/a1/".parse().unwrap()
193 }
194
195 fn build_http_reply(status: u16, body: &[u8]) -> Vec<u8> {
196 let head = format!(
197 "HTTP/1.1 {} OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n",
198 status,
199 body.len()
200 );
201 let mut bytes = head.into_bytes();
202 bytes.extend_from_slice(body);
203 bytes
204 }
205
206 #[test]
207 fn success_returns_ok() {
208 let mut cor = JmapBlobUpload::new(&make_auth(), &make_url(), "text/plain", b"hi".to_vec());
209
210 expect_wants_write(&mut cor, None);
211 expect_wants_read(&mut cor);
212
213 let body = br#"{"accountId":"a1","blobId":"b1","type":"text/plain","size":2}"#;
214 let reply = build_http_reply(200, body);
215 let out = expect_complete_ok(&mut cor, &reply);
216 assert_eq!(out.blob_id, "b1");
217 assert_eq!(out.blob_type, "text/plain");
218 assert_eq!(out.size, 2);
219 }
220
221 #[test]
222 fn http_error_returns_status() {
223 let mut cor = JmapBlobUpload::new(&make_auth(), &make_url(), "text/plain", b"hi".to_vec());
224
225 expect_wants_write(&mut cor, None);
226 expect_wants_read(&mut cor);
227
228 let reply = b"HTTP/1.1 413 Payload Too Large\r\nContent-Length: 0\r\n\r\n";
229 let err = expect_complete_err(&mut cor, reply);
230 assert!(matches!(err, JmapBlobUploadError::HttpStatus(413)));
231 }
232
233 #[test]
234 fn redirect_yields_redirect() {
235 let mut cor = JmapBlobUpload::new(&make_auth(), &make_url(), "text/plain", b"hi".to_vec());
236
237 expect_wants_write(&mut cor, None);
238 expect_wants_read(&mut cor);
239
240 let reply = b"HTTP/1.1 307 Temporary Redirect\r\nLocation: https://upload.example.com/jmap/upload/a1/\r\nContent-Length: 0\r\n\r\n";
241 match cor.resume(Some(reply)) {
242 JmapCoroutineState::Yielded(JmapRedirectYield::WantsRedirect { url, .. }) => {
243 assert_eq!(url.host_str(), Some("upload.example.com"));
244 }
245 state => panic!("expected WantsRedirect, got {state:?}"),
246 }
247 }
248
249 #[test]
250 fn invalid_json_returns_parse_error() {
251 let mut cor = JmapBlobUpload::new(&make_auth(), &make_url(), "text/plain", b"hi".to_vec());
252
253 expect_wants_write(&mut cor, None);
254 expect_wants_read(&mut cor);
255
256 let reply = build_http_reply(200, b"{nope");
257 let err = expect_complete_err(&mut cor, &reply);
258 assert!(matches!(err, JmapBlobUploadError::ParseResponse(_)));
259 }
260
261 #[test]
262 fn request_uses_post_method() {
263 let mut cor = JmapBlobUpload::new(&make_auth(), &make_url(), "text/plain", b"hi".to_vec());
264 let bytes = expect_wants_write(&mut cor, None);
265 let req = core::str::from_utf8(&bytes).expect("utf8 request");
266 assert!(req.starts_with("POST "));
267 }
268
269 fn expect_wants_write(cor: &mut JmapBlobUpload, arg: Option<&[u8]>) -> Vec<u8> {
272 match cor.resume(arg) {
273 JmapCoroutineState::Yielded(JmapRedirectYield::WantsWrite(bytes)) => bytes,
274 state => panic!("expected WantsWrite, got {state:?}"),
275 }
276 }
277
278 fn expect_wants_read(cor: &mut JmapBlobUpload) {
279 match cor.resume(None) {
280 JmapCoroutineState::Yielded(JmapRedirectYield::WantsRead) => {}
281 state => panic!("expected WantsRead, got {state:?}"),
282 }
283 }
284
285 fn expect_complete_ok(cor: &mut JmapBlobUpload, reply: &[u8]) -> JmapBlobUploadOutput {
286 match cor.resume(Some(reply)) {
287 JmapCoroutineState::Complete(Ok(out)) => out,
288 state => panic!("expected Complete(Ok), got {state:?}"),
289 }
290 }
291
292 fn expect_complete_err(cor: &mut JmapBlobUpload, reply: &[u8]) -> JmapBlobUploadError {
293 match cor.resume(Some(reply)) {
294 JmapCoroutineState::Complete(Err(err)) => err,
295 state => panic!("expected Complete(Err), got {state:?}"),
296 }
297 }
298}