use io_http::{
coroutine::*,
rfc9110::{
request::HttpRequest,
send::{HttpSendOutput, HttpSendYield},
},
rfc9112::send::{Http11Send, Http11SendError},
};
use log::{debug, trace};
use secrecy::{ExposeSecret, SecretString};
use thiserror::Error;
use url::Url;
use crate::{
coroutine::*,
rfc8620::request::{JmapRequest, JmapResponse},
};
#[derive(Debug, Error)]
pub enum JmapSendError {
#[error("JMAP send failed: HTTP {0}")]
HttpStatus(u16),
#[error("JMAP send failed: unexpected redirect")]
UnexpectedRedirect,
#[error("JMAP send failed: {0}")]
Send(#[from] Http11SendError),
#[error("JMAP send failed: serialize request: {0}")]
SerializeRequest(#[source] serde_json::Error),
#[error("JMAP send failed: parse response: {0}")]
ParseResponse(#[source] serde_json::Error),
}
#[derive(Clone, Debug)]
pub struct JmapSendOutput {
pub response: JmapResponse,
pub keep_alive: bool,
}
pub struct JmapSend {
state: State,
}
impl JmapSend {
pub fn new(
http_auth: &SecretString,
api_url: &Url,
request: JmapRequest,
) -> Result<Self, JmapSendError> {
let body = serde_json::to_vec(&request).map_err(JmapSendError::SerializeRequest)?;
let host = api_url.host_str().unwrap_or("localhost");
let mut http_request = HttpRequest::get(api_url.clone())
.header("Host", host)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("Authorization", http_auth.expose_secret())
.body(body);
http_request.method = "POST".into();
debug!("prepare request to send");
trace!("api url: {api_url}");
Ok(Self {
state: State::Send(Http11Send::new(http_request)),
})
}
}
impl JmapCoroutine for JmapSend {
type Yield = JmapYield;
type Return = Result<JmapSendOutput, JmapSendError>;
fn resume(&mut self, arg: Option<&[u8]>) -> JmapCoroutineState<Self::Yield, Self::Return> {
match &mut self.state {
State::Send(send) => match send.resume(arg) {
HttpCoroutineState::Yielded(HttpSendYield::WantsRead) => {
JmapCoroutineState::Yielded(JmapYield::WantsRead)
}
HttpCoroutineState::Yielded(HttpSendYield::WantsWrite(bytes)) => {
JmapCoroutineState::Yielded(JmapYield::WantsWrite(bytes))
}
HttpCoroutineState::Yielded(HttpSendYield::WantsRedirect { .. }) => {
JmapCoroutineState::Complete(Err(JmapSendError::UnexpectedRedirect))
}
HttpCoroutineState::Complete(Err(err)) => {
JmapCoroutineState::Complete(Err(err.into()))
}
HttpCoroutineState::Complete(Ok(HttpSendOutput {
response,
keep_alive,
..
})) => {
if !response.status.is_success() {
let err = JmapSendError::HttpStatus(*response.status);
return JmapCoroutineState::Complete(Err(err));
}
match serde_json::from_slice::<JmapResponse>(&response.body) {
Ok(response) => JmapCoroutineState::Complete(Ok(JmapSendOutput {
response,
keep_alive,
})),
Err(err) => {
JmapCoroutineState::Complete(Err(JmapSendError::ParseResponse(err)))
}
}
}
},
}
}
}
enum State {
Send(Http11Send),
}
#[cfg(test)]
mod tests {
use alloc::{format, string::ToString, vec, vec::Vec};
use crate::rfc8620::request::JmapBatch;
use crate::rfc8620::send::*;
fn make_auth() -> SecretString {
SecretString::from("Bearer test")
}
fn make_url() -> Url {
"https://api.example.com/jmap/".parse().unwrap()
}
fn make_request() -> JmapRequest {
let mut batch = JmapBatch::new();
batch.add("Mailbox/get", serde_json::json!({ "accountId": "a1" }));
batch.into_request(vec!["urn:ietf:params:jmap:core".to_string()])
}
fn make_response_body() -> Vec<u8> {
br#"{
"methodResponses": [["Mailbox/get", {"list":[],"notFound":[],"state":"s1"}, "c0"]],
"sessionState": "s1"
}"#
.to_vec()
}
fn build_http_reply(status: u16, body: &[u8]) -> Vec<u8> {
let head = format!(
"HTTP/1.1 {} OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n",
status,
body.len()
);
let mut bytes = head.into_bytes();
bytes.extend_from_slice(body);
bytes
}
#[test]
fn success_returns_ok() {
let mut cor = JmapSend::new(&make_auth(), &make_url(), make_request()).unwrap();
expect_wants_write(&mut cor, None);
expect_wants_read(&mut cor);
let reply = build_http_reply(200, &make_response_body());
let out = expect_complete_ok(&mut cor, &reply);
assert_eq!(out.response.method_responses.len(), 1);
assert_eq!(out.response.session_state, "s1");
}
#[test]
fn http_error_returns_status() {
let mut cor = JmapSend::new(&make_auth(), &make_url(), make_request()).unwrap();
expect_wants_write(&mut cor, None);
expect_wants_read(&mut cor);
let reply = b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n";
let err = expect_complete_err(&mut cor, reply);
assert!(matches!(err, JmapSendError::HttpStatus(401)));
}
#[test]
fn redirect_returns_unexpected_redirect() {
let mut cor = JmapSend::new(&make_auth(), &make_url(), make_request()).unwrap();
expect_wants_write(&mut cor, None);
expect_wants_read(&mut cor);
let reply = b"HTTP/1.1 301 Moved\r\nLocation: https://other.example.com/jmap/\r\nContent-Length: 0\r\n\r\n";
let err = expect_complete_err(&mut cor, reply);
assert!(matches!(err, JmapSendError::UnexpectedRedirect));
}
#[test]
fn invalid_json_returns_parse_error() {
let mut cor = JmapSend::new(&make_auth(), &make_url(), make_request()).unwrap();
expect_wants_write(&mut cor, None);
expect_wants_read(&mut cor);
let reply = build_http_reply(200, b"{not json");
let err = expect_complete_err(&mut cor, &reply);
assert!(matches!(err, JmapSendError::ParseResponse(_)));
}
#[test]
fn batch_assigns_sequential_ids() {
let mut batch = JmapBatch::new();
let a = batch.add("A", serde_json::json!({}));
let b = batch.add("B", serde_json::json!({}));
assert_eq!(a, "c0");
assert_eq!(b, "c1");
}
fn expect_wants_write(cor: &mut JmapSend, arg: Option<&[u8]>) -> Vec<u8> {
match cor.resume(arg) {
JmapCoroutineState::Yielded(JmapYield::WantsWrite(bytes)) => bytes,
state => panic!("expected WantsWrite, got {state:?}"),
}
}
fn expect_wants_read(cor: &mut JmapSend) {
match cor.resume(None) {
JmapCoroutineState::Yielded(JmapYield::WantsRead) => {}
state => panic!("expected WantsRead, got {state:?}"),
}
}
fn expect_complete_ok(cor: &mut JmapSend, reply: &[u8]) -> JmapSendOutput {
match cor.resume(Some(reply)) {
JmapCoroutineState::Complete(Ok(out)) => out,
state => panic!("expected Complete(Ok), got {state:?}"),
}
}
fn expect_complete_err(cor: &mut JmapSend, reply: &[u8]) -> JmapSendError {
match cor.resume(Some(reply)) {
JmapCoroutineState::Complete(Err(err)) => err,
state => panic!("expected Complete(Err), got {state:?}"),
}
}
}