use io_http::{
coroutine::*,
rfc9110::request::HttpRequest,
rfc9112::send::{Http11Send, Http11SendError},
};
use log::{debug, trace};
use secrecy::{ExposeSecret, SecretString};
use thiserror::Error;
use url::Url;
use crate::{
coroutine::*,
rfc8620::{coroutine::JmapRedirectYield, session::JmapSession},
};
#[derive(Debug, Error)]
pub enum JmapSessionGetError {
#[error("JMAP session-get failed: HTTP {0}")]
HttpStatus(u16),
#[error("JMAP session-get failed: no primary account for the mail capability")]
NoPrimaryMailAccount,
#[error("JMAP session-get failed: {0}")]
Send(#[from] Http11SendError),
#[error("JMAP session-get failed: parse session: {0}")]
ParseSession(#[source] serde_json::Error),
}
#[derive(Clone, Debug)]
pub struct JmapSessionGetOutput {
pub session: JmapSession,
pub keep_alive: bool,
}
pub struct JmapSessionGet {
state: State,
}
impl JmapSessionGet {
pub fn new(http_auth: &SecretString, url: &Url) -> Self {
let host = url.host_str().unwrap_or("localhost");
let session_url = match url.path() {
"" | "/" => {
let mut u = url.clone();
u.set_path("/.well-known/jmap");
u
}
_ => url.clone(),
};
debug!("prepare session fetch request");
trace!("session url: {session_url}");
let http_request = HttpRequest::get(session_url)
.header("Host", host)
.header("Accept", "application/json")
.header("Authorization", http_auth.expose_secret());
Self {
state: State::Send(Http11Send::new(http_request)),
}
}
}
impl JmapCoroutine for JmapSessionGet {
type Yield = JmapRedirectYield;
type Return = Result<JmapSessionGetOutput, JmapSessionGetError>;
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(y) => JmapCoroutineState::Yielded(y.into()),
HttpCoroutineState::Complete(Err(err)) => {
JmapCoroutineState::Complete(Err(err.into()))
}
HttpCoroutineState::Complete(Ok(out)) => {
if !out.response.status.is_success() {
let err = JmapSessionGetError::HttpStatus(*out.response.status);
return JmapCoroutineState::Complete(Err(err));
}
match serde_json::from_slice::<JmapSession>(&out.response.body) {
Ok(session) => JmapCoroutineState::Complete(Ok(JmapSessionGetOutput {
session,
keep_alive: out.keep_alive,
})),
Err(err) => JmapCoroutineState::Complete(Err(
JmapSessionGetError::ParseSession(err),
)),
}
}
},
}
}
}
enum State {
Send(Http11Send),
}
#[cfg(test)]
mod tests {
use alloc::{format, vec::Vec};
use crate::rfc8620::session_get::*;
fn make_auth() -> SecretString {
SecretString::from("Bearer test")
}
fn make_url() -> Url {
"https://api.example.com/".parse().unwrap()
}
fn session_json() -> &'static [u8] {
br#"{
"capabilities": {},
"accounts": {},
"primaryAccounts": {},
"username": "alice",
"apiUrl": "https://api.example.com/jmap/",
"downloadUrl": "https://api.example.com/jmap/download/{accountId}/{blobId}/{name}?accept={type}",
"uploadUrl": "https://api.example.com/jmap/upload/{accountId}/",
"eventSourceUrl": "https://api.example.com/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}",
"state": "abc"
}"#
}
#[test]
fn success_returns_ok() {
let mut cor = JmapSessionGet::new(&make_auth(), &make_url());
expect_wants_write(&mut cor, None);
expect_wants_read(&mut cor);
let body = session_json();
let reply = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n",
body.len()
);
let mut bytes = reply.into_bytes();
bytes.extend_from_slice(body);
let out = expect_complete_ok(&mut cor, &bytes);
assert_eq!(out.session.username, "alice");
}
#[test]
fn http_error_returns_status() {
let mut cor = JmapSessionGet::new(&make_auth(), &make_url());
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, JmapSessionGetError::HttpStatus(401)));
}
#[test]
fn redirect_yields_redirect() {
let mut cor = JmapSessionGet::new(&make_auth(), &make_url());
expect_wants_write(&mut cor, None);
expect_wants_read(&mut cor);
let reply = b"HTTP/1.1 301 Moved Permanently\r\nLocation: https://api2.example.com/.well-known/jmap\r\nContent-Length: 0\r\n\r\n";
match cor.resume(Some(reply)) {
JmapCoroutineState::Yielded(JmapRedirectYield::WantsRedirect { url, .. }) => {
assert_eq!(url.host_str(), Some("api2.example.com"));
}
state => panic!("expected WantsRedirect, got {state:?}"),
}
}
#[test]
fn invalid_json_returns_parse_error() {
let mut cor = JmapSessionGet::new(&make_auth(), &make_url());
expect_wants_write(&mut cor, None);
expect_wants_read(&mut cor);
let body = b"{not json";
let reply = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len());
let mut bytes = reply.into_bytes();
bytes.extend_from_slice(body);
let err = expect_complete_err(&mut cor, &bytes);
assert!(matches!(err, JmapSessionGetError::ParseSession(_)));
}
#[test]
fn uses_well_known_path_for_base_url() {
let mut cor = JmapSessionGet::new(&make_auth(), &make_url());
let bytes = expect_wants_write(&mut cor, None);
let req = core::str::from_utf8(&bytes).expect("utf8 request");
assert!(req.contains("/.well-known/jmap"));
}
fn expect_wants_write(cor: &mut JmapSessionGet, arg: Option<&[u8]>) -> Vec<u8> {
match cor.resume(arg) {
JmapCoroutineState::Yielded(JmapRedirectYield::WantsWrite(bytes)) => bytes,
state => panic!("expected WantsWrite, got {state:?}"),
}
}
fn expect_wants_read(cor: &mut JmapSessionGet) {
match cor.resume(None) {
JmapCoroutineState::Yielded(JmapRedirectYield::WantsRead) => {}
state => panic!("expected WantsRead, got {state:?}"),
}
}
fn expect_complete_ok(cor: &mut JmapSessionGet, reply: &[u8]) -> JmapSessionGetOutput {
match cor.resume(Some(reply)) {
JmapCoroutineState::Complete(Ok(out)) => out,
state => panic!("expected Complete(Ok), got {state:?}"),
}
}
fn expect_complete_err(cor: &mut JmapSessionGet, reply: &[u8]) -> JmapSessionGetError {
match cor.resume(Some(reply)) {
JmapCoroutineState::Complete(Err(err)) => err,
state => panic!("expected Complete(Err), got {state:?}"),
}
}
}