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