Skip to main content

io_jmap/rfc8620/
send.rs

1//! Base coroutine all higher-level JMAP coroutines delegate to: serialises a
2//! [`JmapRequest`] as JSON, runs an HTTP/1.1 POST, and deserialises the
3//! [`JmapResponse`] body.
4//!
5//! 3xx redirects surface as [`JmapSendError::UnexpectedRedirect`];
6//! redirect-aware coroutines resume [`Http11Send`] directly instead.
7//!
8//! [`JmapRequest`]: crate::rfc8620::request::JmapRequest
9//! [`JmapResponse`]: crate::rfc8620::request::JmapResponse
10//!
11//! # Example
12//!
13//! ```rust,no_run
14//! use std::{
15//!     io::{Read, Write},
16//!     net::TcpStream,
17//! };
18//!
19//! use io_jmap::{
20//!     coroutine::{JmapCoroutine, JmapCoroutineState, JmapYield},
21//!     rfc8620::{request::JmapBatch, send::JmapSend},
22//! };
23//! use secrecy::SecretString;
24//! use serde_json::json;
25//! use url::Url;
26//!
27//! // Ready stream needed (TCP-connected, TLS-negociated)
28//! let mut stream = TcpStream::connect("api.example.com:443").unwrap();
29//! let mut buf = [0u8; 4096];
30//!
31//! let mut batch = JmapBatch::new();
32//! batch.add("Email/get", json!({ "accountId": "a1", "ids": null }));
33//! let request = batch.into_request(vec!["urn:ietf:params:jmap:core".into()]);
34//!
35//! let api_url: Url = "https://api.example.com/jmap/".parse().unwrap();
36//! let auth = SecretString::from("Bearer xyz");
37//! let mut coroutine = JmapSend::new(&auth, &api_url, request).unwrap();
38//! let mut arg = None;
39//!
40//! let out = loop {
41//!     match coroutine.resume(arg.take()) {
42//!         JmapCoroutineState::Yielded(JmapYield::WantsWrite(bytes)) => {
43//!             stream.write_all(&bytes).unwrap();
44//!         }
45//!         JmapCoroutineState::Yielded(JmapYield::WantsRead) => {
46//!             let n = stream.read(&mut buf).unwrap();
47//!             arg = Some(&buf[..n]);
48//!         }
49//!         JmapCoroutineState::Complete(Ok(out)) => break out,
50//!         JmapCoroutineState::Complete(Err(err)) => panic!("{err}"),
51//!     }
52//! };
53//!
54//! println!("{:?}", out.response);
55//! ```
56
57use io_http::{
58    coroutine::*,
59    rfc9110::{
60        request::HttpRequest,
61        send::{HttpSendOutput, HttpSendYield},
62    },
63    rfc9112::send::{Http11Send, Http11SendError},
64};
65use log::{debug, trace};
66use secrecy::{ExposeSecret, SecretString};
67use thiserror::Error;
68use url::Url;
69
70use crate::{
71    coroutine::*,
72    rfc8620::request::{JmapRequest, JmapResponse},
73};
74
75/// Failure causes during the JMAP send flow.
76#[derive(Debug, Error)]
77pub enum JmapSendError {
78    /// The server answered with a non-2xx status.
79    #[error("JMAP send failed: HTTP {0}")]
80    HttpStatus(u16),
81    /// The server answered with an unexpected redirect.
82    #[error("JMAP send failed: unexpected redirect")]
83    UnexpectedRedirect,
84    /// The inner HTTP/1.1 send coroutine failed.
85    #[error("JMAP send failed: {0}")]
86    Send(#[from] Http11SendError),
87    /// The request could not be serialized.
88    #[error("JMAP send failed: serialize request: {0}")]
89    SerializeRequest(#[source] serde_json::Error),
90    /// The method response could not be parsed.
91    #[error("JMAP send failed: parse response: {0}")]
92    ParseResponse(#[source] serde_json::Error),
93}
94
95/// Successful terminal output of the [`JmapSend`] coroutine.
96#[derive(Clone, Debug)]
97pub struct JmapSendOutput {
98    /// The parsed JMAP response.
99    pub response: JmapResponse,
100    /// Whether the server indicated the connection can be reused.
101    pub keep_alive: bool,
102}
103
104/// I/O-free coroutine sending one JMAP API request and parsing its response.
105pub struct JmapSend {
106    state: State,
107}
108
109impl JmapSend {
110    /// Serialises `request` as JSON and builds an HTTP POST to `api_url`
111    /// with the bearer token from `http_auth`.
112    pub fn new(
113        http_auth: &SecretString,
114        api_url: &Url,
115        request: JmapRequest,
116    ) -> Result<Self, JmapSendError> {
117        let body = serde_json::to_vec(&request).map_err(JmapSendError::SerializeRequest)?;
118
119        let host = api_url.host_str().unwrap_or("localhost");
120
121        let mut http_request = HttpRequest::get(api_url.clone())
122            .header("Host", host)
123            .header("Content-Type", "application/json")
124            .header("Accept", "application/json")
125            .header("Authorization", http_auth.expose_secret())
126            .body(body);
127
128        http_request.method = "POST".into();
129
130        debug!("prepare request to send");
131        trace!("api url: {api_url}");
132
133        Ok(Self {
134            state: State::Send(Http11Send::new(http_request)),
135        })
136    }
137}
138
139impl JmapCoroutine for JmapSend {
140    type Yield = JmapYield;
141    type Return = Result<JmapSendOutput, JmapSendError>;
142
143    fn resume(&mut self, arg: Option<&[u8]>) -> JmapCoroutineState<Self::Yield, Self::Return> {
144        match &mut self.state {
145            State::Send(send) => match send.resume(arg) {
146                HttpCoroutineState::Yielded(HttpSendYield::WantsRead) => {
147                    JmapCoroutineState::Yielded(JmapYield::WantsRead)
148                }
149                HttpCoroutineState::Yielded(HttpSendYield::WantsWrite(bytes)) => {
150                    JmapCoroutineState::Yielded(JmapYield::WantsWrite(bytes))
151                }
152                HttpCoroutineState::Yielded(HttpSendYield::WantsRedirect { .. }) => {
153                    JmapCoroutineState::Complete(Err(JmapSendError::UnexpectedRedirect))
154                }
155                HttpCoroutineState::Complete(Err(err)) => {
156                    JmapCoroutineState::Complete(Err(err.into()))
157                }
158                HttpCoroutineState::Complete(Ok(HttpSendOutput {
159                    response,
160                    keep_alive,
161                    ..
162                })) => {
163                    if !response.status.is_success() {
164                        let err = JmapSendError::HttpStatus(*response.status);
165                        return JmapCoroutineState::Complete(Err(err));
166                    }
167
168                    match serde_json::from_slice::<JmapResponse>(&response.body) {
169                        Ok(response) => JmapCoroutineState::Complete(Ok(JmapSendOutput {
170                            response,
171                            keep_alive,
172                        })),
173                        Err(err) => {
174                            JmapCoroutineState::Complete(Err(JmapSendError::ParseResponse(err)))
175                        }
176                    }
177                }
178            },
179        }
180    }
181}
182
183enum State {
184    Send(Http11Send),
185}
186
187#[cfg(test)]
188mod tests {
189    use alloc::{format, string::ToString, vec, vec::Vec};
190
191    use crate::rfc8620::request::JmapBatch;
192    use crate::rfc8620::send::*;
193
194    fn make_auth() -> SecretString {
195        SecretString::from("Bearer test")
196    }
197
198    fn make_url() -> Url {
199        "https://api.example.com/jmap/".parse().unwrap()
200    }
201
202    fn make_request() -> JmapRequest {
203        let mut batch = JmapBatch::new();
204        batch.add("Mailbox/get", serde_json::json!({ "accountId": "a1" }));
205        batch.into_request(vec!["urn:ietf:params:jmap:core".to_string()])
206    }
207
208    fn make_response_body() -> Vec<u8> {
209        br#"{
210            "methodResponses": [["Mailbox/get", {"list":[],"notFound":[],"state":"s1"}, "c0"]],
211            "sessionState": "s1"
212        }"#
213        .to_vec()
214    }
215
216    fn build_http_reply(status: u16, body: &[u8]) -> Vec<u8> {
217        let head = format!(
218            "HTTP/1.1 {} OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n",
219            status,
220            body.len()
221        );
222        let mut bytes = head.into_bytes();
223        bytes.extend_from_slice(body);
224        bytes
225    }
226
227    #[test]
228    fn success_returns_ok() {
229        let mut cor = JmapSend::new(&make_auth(), &make_url(), make_request()).unwrap();
230
231        expect_wants_write(&mut cor, None);
232        expect_wants_read(&mut cor);
233
234        let reply = build_http_reply(200, &make_response_body());
235        let out = expect_complete_ok(&mut cor, &reply);
236        assert_eq!(out.response.method_responses.len(), 1);
237        assert_eq!(out.response.session_state, "s1");
238    }
239
240    #[test]
241    fn http_error_returns_status() {
242        let mut cor = JmapSend::new(&make_auth(), &make_url(), make_request()).unwrap();
243
244        expect_wants_write(&mut cor, None);
245        expect_wants_read(&mut cor);
246
247        let reply = b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n";
248        let err = expect_complete_err(&mut cor, reply);
249        assert!(matches!(err, JmapSendError::HttpStatus(401)));
250    }
251
252    #[test]
253    fn redirect_returns_unexpected_redirect() {
254        let mut cor = JmapSend::new(&make_auth(), &make_url(), make_request()).unwrap();
255
256        expect_wants_write(&mut cor, None);
257        expect_wants_read(&mut cor);
258
259        let reply = b"HTTP/1.1 301 Moved\r\nLocation: https://other.example.com/jmap/\r\nContent-Length: 0\r\n\r\n";
260        let err = expect_complete_err(&mut cor, reply);
261        assert!(matches!(err, JmapSendError::UnexpectedRedirect));
262    }
263
264    #[test]
265    fn invalid_json_returns_parse_error() {
266        let mut cor = JmapSend::new(&make_auth(), &make_url(), make_request()).unwrap();
267
268        expect_wants_write(&mut cor, None);
269        expect_wants_read(&mut cor);
270
271        let reply = build_http_reply(200, b"{not json");
272        let err = expect_complete_err(&mut cor, &reply);
273        assert!(matches!(err, JmapSendError::ParseResponse(_)));
274    }
275
276    #[test]
277    fn batch_assigns_sequential_ids() {
278        let mut batch = JmapBatch::new();
279        let a = batch.add("A", serde_json::json!({}));
280        let b = batch.add("B", serde_json::json!({}));
281        assert_eq!(a, "c0");
282        assert_eq!(b, "c1");
283    }
284
285    fn expect_wants_write(cor: &mut JmapSend, arg: Option<&[u8]>) -> Vec<u8> {
286        match cor.resume(arg) {
287            JmapCoroutineState::Yielded(JmapYield::WantsWrite(bytes)) => bytes,
288            state => panic!("expected WantsWrite, got {state:?}"),
289        }
290    }
291
292    fn expect_wants_read(cor: &mut JmapSend) {
293        match cor.resume(None) {
294            JmapCoroutineState::Yielded(JmapYield::WantsRead) => {}
295            state => panic!("expected WantsRead, got {state:?}"),
296        }
297    }
298
299    fn expect_complete_ok(cor: &mut JmapSend, reply: &[u8]) -> JmapSendOutput {
300        match cor.resume(Some(reply)) {
301            JmapCoroutineState::Complete(Ok(out)) => out,
302            state => panic!("expected Complete(Ok), got {state:?}"),
303        }
304    }
305
306    fn expect_complete_err(cor: &mut JmapSend, reply: &[u8]) -> JmapSendError {
307        match cor.resume(Some(reply)) {
308            JmapCoroutineState::Complete(Err(err)) => err,
309            state => panic!("expected Complete(Err), got {state:?}"),
310        }
311    }
312}