Skip to main content

io_jmap/rfc8620/
blob_upload.rs

1//! JMAP blob upload coroutine (RFC 8620 §6.1): POSTs raw bytes to the
2//! caller-resolved `upload_url` and returns the server-assigned blob id.
3//!
4//! A 3xx response surfaces as [`JmapRedirectYield::WantsRedirect`]; the caller
5//! must open a new connection to the redirect target and build a fresh
6//! coroutine.
7//!
8//! # Example
9//!
10//! ```rust,no_run
11//! use std::{
12//!     io::{Read, Write},
13//!     net::TcpStream,
14//! };
15//!
16//! use io_jmap::{
17//!     coroutine::{JmapCoroutine, JmapCoroutineState},
18//!     rfc8620::{blob_upload::JmapBlobUpload, coroutine::JmapRedirectYield},
19//! };
20//! use secrecy::SecretString;
21//! use url::Url;
22//!
23//! // Ready stream needed (TCP-connected, TLS-negociated)
24//! let mut stream = TcpStream::connect("api.example.com:443").unwrap();
25//! let mut buf = [0u8; 4096];
26//!
27//! let url: Url = "https://api.example.com/jmap/upload/a1/".parse().unwrap();
28//! let auth = SecretString::from("Bearer xyz");
29//! let data = b"hello".to_vec();
30//! let mut coroutine = JmapBlobUpload::new(&auth, &url, "application/octet-stream", data);
31//! let mut arg = None;
32//!
33//! let out = loop {
34//!     match coroutine.resume(arg.take()) {
35//!         JmapCoroutineState::Yielded(JmapRedirectYield::WantsWrite(bytes)) => {
36//!             stream.write_all(&bytes).unwrap();
37//!         }
38//!         JmapCoroutineState::Yielded(JmapRedirectYield::WantsRead) => {
39//!             let n = stream.read(&mut buf).unwrap();
40//!             arg = Some(&buf[..n]);
41//!         }
42//!         JmapCoroutineState::Yielded(JmapRedirectYield::WantsRedirect { .. }) => {
43//!             unimplemented!("open a new connection and start over");
44//!         }
45//!         JmapCoroutineState::Complete(Ok(out)) => break out,
46//!         JmapCoroutineState::Complete(Err(err)) => panic!("{err}"),
47//!     }
48//! };
49//!
50//! println!("uploaded blob {}", out.blob_id);
51//! ```
52
53use 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/// Failure causes during the JMAP blob-upload flow.
71#[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/// Successful terminal output of [`JmapBlobUpload`].
82#[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
98/// I/O-free coroutine uploading a blob to a JMAP server (RFC 8620 §6.1).
99pub struct JmapBlobUpload {
100    state: State,
101}
102
103impl JmapBlobUpload {
104    /// - `upload_url`: the fully resolved upload URL (no template placeholders)
105    /// - `content_type`: MIME type of the blob (e.g. `"message/rfc822"`)
106    /// - `data`: raw bytes to upload
107    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    // --- utils
270
271    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}