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