Skip to main content

io_jmap/rfc8620/
blob_download.rs

1//! JMAP blob download coroutine (RFC 8620 §6.2): GETs from the caller-resolved
2//! `download_url` and returns the response body bytes.
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_download::JmapBlobDownload, 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/download/a1/b1/blob".parse().unwrap();
28//! let auth = SecretString::from("Bearer xyz");
29//! let mut coroutine = JmapBlobDownload::new(&auth, &url);
30//! let mut arg = None;
31//!
32//! let out = loop {
33//!     match coroutine.resume(arg.take()) {
34//!         JmapCoroutineState::Yielded(JmapRedirectYield::WantsWrite(bytes)) => {
35//!             stream.write_all(&bytes).unwrap();
36//!         }
37//!         JmapCoroutineState::Yielded(JmapRedirectYield::WantsRead) => {
38//!             let n = stream.read(&mut buf).unwrap();
39//!             arg = Some(&buf[..n]);
40//!         }
41//!         JmapCoroutineState::Yielded(JmapRedirectYield::WantsRedirect { .. }) => {
42//!             unimplemented!("open a new connection and start over");
43//!         }
44//!         JmapCoroutineState::Complete(Ok(out)) => break out,
45//!         JmapCoroutineState::Complete(Err(err)) => panic!("{err}"),
46//!     }
47//! };
48//!
49//! println!("{} bytes", out.data.len());
50//! ```
51
52use core::fmt;
53
54use alloc::vec::Vec;
55
56use io_http::{
57    coroutine::*,
58    rfc9110::{request::HttpRequest, send::HttpSendOutput},
59    rfc9112::send::{Http11Send, Http11SendError},
60};
61use log::trace;
62use secrecy::{ExposeSecret, SecretString};
63use thiserror::Error;
64use url::Url;
65
66use crate::{coroutine::*, rfc8620::coroutine::JmapRedirectYield};
67
68/// Failure causes during the JMAP blob-download flow.
69#[derive(Debug, Error)]
70pub enum JmapBlobDownloadError {
71    #[error("JMAP blob-download failed: HTTP {0}")]
72    HttpStatus(u16),
73    #[error("JMAP blob-download failed: {0}")]
74    Send(#[from] Http11SendError),
75}
76
77/// Successful terminal output of [`JmapBlobDownload`].
78#[derive(Clone, Debug)]
79pub struct JmapBlobDownloadOutput {
80    pub data: Vec<u8>,
81    pub keep_alive: bool,
82}
83
84/// I/O-free coroutine downloading a blob from a JMAP server (RFC 8620 §6.2).
85pub struct JmapBlobDownload {
86    state: State,
87}
88
89impl JmapBlobDownload {
90    /// `download_url` is the fully resolved download URL (no template
91    /// placeholders).
92    pub fn new(http_auth: &SecretString, download_url: &Url) -> Self {
93        let host = download_url.host_str().unwrap_or("localhost");
94
95        let http_request = HttpRequest::get(download_url.clone())
96            .header("Host", host)
97            .header("Authorization", http_auth.expose_secret());
98
99        trace!("download JMAP blob from {download_url}");
100
101        Self {
102            state: State::Send(Http11Send::new(http_request)),
103        }
104    }
105}
106
107impl JmapCoroutine for JmapBlobDownload {
108    type Yield = JmapRedirectYield;
109    type Return = Result<JmapBlobDownloadOutput, JmapBlobDownloadError>;
110
111    fn resume(&mut self, arg: Option<&[u8]>) -> JmapCoroutineState<Self::Yield, Self::Return> {
112        trace!("blob-download: {}", self.state);
113        match &mut self.state {
114            State::Send(send) => match send.resume(arg) {
115                HttpCoroutineState::Yielded(y) => JmapCoroutineState::Yielded(y.into()),
116                HttpCoroutineState::Complete(Err(err)) => {
117                    JmapCoroutineState::Complete(Err(err.into()))
118                }
119                HttpCoroutineState::Complete(Ok(HttpSendOutput {
120                    response,
121                    keep_alive,
122                    ..
123                })) => {
124                    if !response.status.is_success() {
125                        let err = JmapBlobDownloadError::HttpStatus(*response.status);
126                        return JmapCoroutineState::Complete(Err(err));
127                    }
128
129                    JmapCoroutineState::Complete(Ok(JmapBlobDownloadOutput {
130                        data: response.body,
131                        keep_alive,
132                    }))
133                }
134            },
135        }
136    }
137}
138
139enum State {
140    Send(Http11Send),
141}
142
143impl fmt::Display for State {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        match self {
146            Self::Send(_) => f.write_str("send"),
147        }
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use alloc::format;
154
155    use super::*;
156
157    fn make_auth() -> SecretString {
158        SecretString::from("Bearer test")
159    }
160
161    fn make_url() -> Url {
162        "https://api.example.com/jmap/download/a1/b1/blob"
163            .parse()
164            .unwrap()
165    }
166
167    fn build_http_reply(status: u16, body: &[u8]) -> Vec<u8> {
168        let head = format!(
169            "HTTP/1.1 {} OK\r\nContent-Length: {}\r\n\r\n",
170            status,
171            body.len()
172        );
173        let mut bytes = head.into_bytes();
174        bytes.extend_from_slice(body);
175        bytes
176    }
177
178    #[test]
179    fn success_returns_ok() {
180        let mut cor = JmapBlobDownload::new(&make_auth(), &make_url());
181
182        expect_wants_write(&mut cor, None);
183        expect_wants_read(&mut cor);
184
185        let reply = build_http_reply(200, b"hello blob");
186        let out = expect_complete_ok(&mut cor, &reply);
187        assert_eq!(out.data, b"hello blob");
188    }
189
190    #[test]
191    fn http_error_returns_status() {
192        let mut cor = JmapBlobDownload::new(&make_auth(), &make_url());
193
194        expect_wants_write(&mut cor, None);
195        expect_wants_read(&mut cor);
196
197        let reply = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n";
198        let err = expect_complete_err(&mut cor, reply);
199        assert!(matches!(err, JmapBlobDownloadError::HttpStatus(404)));
200    }
201
202    #[test]
203    fn redirect_yields_redirect() {
204        let mut cor = JmapBlobDownload::new(&make_auth(), &make_url());
205
206        expect_wants_write(&mut cor, None);
207        expect_wants_read(&mut cor);
208
209        let reply = b"HTTP/1.1 302 Found\r\nLocation: https://cdn.example.com/blob\r\nContent-Length: 0\r\n\r\n";
210        match cor.resume(Some(reply)) {
211            JmapCoroutineState::Yielded(JmapRedirectYield::WantsRedirect { url, .. }) => {
212                assert_eq!(url.host_str(), Some("cdn.example.com"));
213            }
214            state => panic!("expected WantsRedirect, got {state:?}"),
215        }
216    }
217
218    #[test]
219    fn empty_body_succeeds() {
220        let mut cor = JmapBlobDownload::new(&make_auth(), &make_url());
221
222        expect_wants_write(&mut cor, None);
223        expect_wants_read(&mut cor);
224
225        let reply = build_http_reply(200, b"");
226        let out = expect_complete_ok(&mut cor, &reply);
227        assert!(out.data.is_empty());
228    }
229
230    #[test]
231    fn request_uses_get_method() {
232        let mut cor = JmapBlobDownload::new(&make_auth(), &make_url());
233        let bytes = expect_wants_write(&mut cor, None);
234        let req = core::str::from_utf8(&bytes).expect("utf8 request");
235        assert!(req.starts_with("GET "));
236    }
237
238    // --- utils
239
240    fn expect_wants_write(cor: &mut JmapBlobDownload, arg: Option<&[u8]>) -> Vec<u8> {
241        match cor.resume(arg) {
242            JmapCoroutineState::Yielded(JmapRedirectYield::WantsWrite(bytes)) => bytes,
243            state => panic!("expected WantsWrite, got {state:?}"),
244        }
245    }
246
247    fn expect_wants_read(cor: &mut JmapBlobDownload) {
248        match cor.resume(None) {
249            JmapCoroutineState::Yielded(JmapRedirectYield::WantsRead) => {}
250            state => panic!("expected WantsRead, got {state:?}"),
251        }
252    }
253
254    fn expect_complete_ok(cor: &mut JmapBlobDownload, reply: &[u8]) -> JmapBlobDownloadOutput {
255        match cor.resume(Some(reply)) {
256            JmapCoroutineState::Complete(Ok(out)) => out,
257            state => panic!("expected Complete(Ok), got {state:?}"),
258        }
259    }
260
261    fn expect_complete_err(cor: &mut JmapBlobDownload, reply: &[u8]) -> JmapBlobDownloadError {
262        match cor.resume(Some(reply)) {
263            JmapCoroutineState::Complete(Err(err)) => err,
264            state => panic!("expected Complete(Err), got {state:?}"),
265        }
266    }
267}