io_jmap/rfc8620/
blob_download.rs1use alloc::vec::Vec;
53
54use io_http::{
55 coroutine::*,
56 rfc9110::{request::HttpRequest, send::HttpSendOutput},
57 rfc9112::send::{Http11Send, Http11SendError},
58};
59use log::{debug, trace};
60use secrecy::{ExposeSecret, SecretString};
61use thiserror::Error;
62use url::Url;
63
64use crate::{coroutine::*, rfc8620::coroutine::JmapRedirectYield};
65
66#[derive(Debug, Error)]
68pub enum JmapBlobDownloadError {
69 #[error("JMAP blob-download failed: HTTP {0}")]
71 HttpStatus(u16),
72 #[error("JMAP blob-download failed: {0}")]
74 Send(#[from] Http11SendError),
75}
76
77#[derive(Clone, Debug)]
79pub struct JmapBlobDownloadOutput {
80 pub data: Vec<u8>,
82 pub keep_alive: bool,
84}
85
86pub struct JmapBlobDownload {
88 state: State,
89}
90
91impl JmapBlobDownload {
92 pub fn new(http_auth: &SecretString, download_url: &Url) -> Self {
95 let host = download_url.host_str().unwrap_or("localhost");
96
97 let http_request = HttpRequest::get(download_url.clone())
98 .header("Host", host)
99 .header("Authorization", http_auth.expose_secret());
100
101 debug!("prepare blob download request");
102 trace!("download url: {download_url}");
103
104 Self {
105 state: State::Send(Http11Send::new(http_request)),
106 }
107 }
108}
109
110impl JmapCoroutine for JmapBlobDownload {
111 type Yield = JmapRedirectYield;
112 type Return = Result<JmapBlobDownloadOutput, JmapBlobDownloadError>;
113
114 fn resume(&mut self, arg: Option<&[u8]>) -> JmapCoroutineState<Self::Yield, Self::Return> {
115 match &mut self.state {
116 State::Send(send) => match send.resume(arg) {
117 HttpCoroutineState::Yielded(y) => JmapCoroutineState::Yielded(y.into()),
118 HttpCoroutineState::Complete(Err(err)) => {
119 JmapCoroutineState::Complete(Err(err.into()))
120 }
121 HttpCoroutineState::Complete(Ok(HttpSendOutput {
122 response,
123 keep_alive,
124 ..
125 })) => {
126 if !response.status.is_success() {
127 let err = JmapBlobDownloadError::HttpStatus(*response.status);
128 return JmapCoroutineState::Complete(Err(err));
129 }
130
131 JmapCoroutineState::Complete(Ok(JmapBlobDownloadOutput {
132 data: response.body,
133 keep_alive,
134 }))
135 }
136 },
137 }
138 }
139}
140
141enum State {
142 Send(Http11Send),
143}
144
145#[cfg(test)]
146mod tests {
147 use alloc::format;
148
149 use crate::rfc8620::blob_download::*;
150
151 fn make_auth() -> SecretString {
152 SecretString::from("Bearer test")
153 }
154
155 fn make_url() -> Url {
156 "https://api.example.com/jmap/download/a1/b1/blob"
157 .parse()
158 .unwrap()
159 }
160
161 fn build_http_reply(status: u16, body: &[u8]) -> Vec<u8> {
162 let head = format!(
163 "HTTP/1.1 {} OK\r\nContent-Length: {}\r\n\r\n",
164 status,
165 body.len()
166 );
167 let mut bytes = head.into_bytes();
168 bytes.extend_from_slice(body);
169 bytes
170 }
171
172 #[test]
173 fn success_returns_ok() {
174 let mut cor = JmapBlobDownload::new(&make_auth(), &make_url());
175
176 expect_wants_write(&mut cor, None);
177 expect_wants_read(&mut cor);
178
179 let reply = build_http_reply(200, b"hello blob");
180 let out = expect_complete_ok(&mut cor, &reply);
181 assert_eq!(out.data, b"hello blob");
182 }
183
184 #[test]
185 fn http_error_returns_status() {
186 let mut cor = JmapBlobDownload::new(&make_auth(), &make_url());
187
188 expect_wants_write(&mut cor, None);
189 expect_wants_read(&mut cor);
190
191 let reply = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n";
192 let err = expect_complete_err(&mut cor, reply);
193 assert!(matches!(err, JmapBlobDownloadError::HttpStatus(404)));
194 }
195
196 #[test]
197 fn redirect_yields_redirect() {
198 let mut cor = JmapBlobDownload::new(&make_auth(), &make_url());
199
200 expect_wants_write(&mut cor, None);
201 expect_wants_read(&mut cor);
202
203 let reply = b"HTTP/1.1 302 Found\r\nLocation: https://cdn.example.com/blob\r\nContent-Length: 0\r\n\r\n";
204 match cor.resume(Some(reply)) {
205 JmapCoroutineState::Yielded(JmapRedirectYield::WantsRedirect { url, .. }) => {
206 assert_eq!(url.host_str(), Some("cdn.example.com"));
207 }
208 state => panic!("expected WantsRedirect, got {state:?}"),
209 }
210 }
211
212 #[test]
213 fn empty_body_succeeds() {
214 let mut cor = JmapBlobDownload::new(&make_auth(), &make_url());
215
216 expect_wants_write(&mut cor, None);
217 expect_wants_read(&mut cor);
218
219 let reply = build_http_reply(200, b"");
220 let out = expect_complete_ok(&mut cor, &reply);
221 assert!(out.data.is_empty());
222 }
223
224 #[test]
225 fn request_uses_get_method() {
226 let mut cor = JmapBlobDownload::new(&make_auth(), &make_url());
227 let bytes = expect_wants_write(&mut cor, None);
228 let req = core::str::from_utf8(&bytes).expect("utf8 request");
229 assert!(req.starts_with("GET "));
230 }
231
232 fn expect_wants_write(cor: &mut JmapBlobDownload, arg: Option<&[u8]>) -> Vec<u8> {
233 match cor.resume(arg) {
234 JmapCoroutineState::Yielded(JmapRedirectYield::WantsWrite(bytes)) => bytes,
235 state => panic!("expected WantsWrite, got {state:?}"),
236 }
237 }
238
239 fn expect_wants_read(cor: &mut JmapBlobDownload) {
240 match cor.resume(None) {
241 JmapCoroutineState::Yielded(JmapRedirectYield::WantsRead) => {}
242 state => panic!("expected WantsRead, got {state:?}"),
243 }
244 }
245
246 fn expect_complete_ok(cor: &mut JmapBlobDownload, reply: &[u8]) -> JmapBlobDownloadOutput {
247 match cor.resume(Some(reply)) {
248 JmapCoroutineState::Complete(Ok(out)) => out,
249 state => panic!("expected Complete(Ok), got {state:?}"),
250 }
251 }
252
253 fn expect_complete_err(cor: &mut JmapBlobDownload, reply: &[u8]) -> JmapBlobDownloadError {
254 match cor.resume(Some(reply)) {
255 JmapCoroutineState::Complete(Err(err)) => err,
256 state => panic!("expected Complete(Err), got {state:?}"),
257 }
258 }
259}