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 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/// Failure causes during the JMAP blob-upload flow.
69#[derive(Debug, Error)]
70pub enum JmapBlobUploadError {
71    /// The server answered with a non-2xx status.
72    #[error("JMAP blob-upload failed: HTTP {0}")]
73    HttpStatus(u16),
74    /// The inner HTTP/1.1 send coroutine failed.
75    #[error("JMAP blob-upload failed: {0}")]
76    Send(#[from] Http11SendError),
77    /// The method response could not be parsed.
78    #[error("JMAP blob-upload failed: parse response: {0}")]
79    ParseResponse(#[source] serde_json::Error),
80}
81
82/// Successful terminal output of [`JmapBlobUpload`].
83#[derive(Clone, Debug)]
84pub struct JmapBlobUploadOutput {
85    /// The server-assigned blob id.
86    pub blob_id: String,
87    /// The media type of the blob, as detected by the server.
88    pub blob_type: String,
89    /// The size of the blob, in bytes.
90    pub size: u64,
91    /// Whether the server indicated the connection can be reused.
92    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
103/// I/O-free coroutine uploading a blob to a JMAP server (RFC 8620 §6.1).
104pub struct JmapBlobUpload {
105    state: State,
106}
107
108impl JmapBlobUpload {
109    /// - `upload_url`: the fully resolved upload URL (no template placeholders)
110    /// - `content_type`: MIME type of the blob (e.g. `"message/rfc822"`)
111    /// - `data`: raw bytes to upload
112    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}