io_jmap/rfc8620/
blob_download.rs1use 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#[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#[derive(Clone, Debug)]
79pub struct JmapBlobDownloadOutput {
80 pub data: Vec<u8>,
81 pub keep_alive: bool,
82}
83
84pub struct JmapBlobDownload {
86 state: State,
87}
88
89impl JmapBlobDownload {
90 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 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}