io_jmap/rfc8620/
session_get.rs1use core::fmt;
51
52use io_http::{
53 coroutine::*,
54 rfc9110::request::HttpRequest,
55 rfc9112::send::{Http11Send, Http11SendError},
56};
57use log::trace;
58use secrecy::{ExposeSecret, SecretString};
59use thiserror::Error;
60use url::Url;
61
62use crate::{
63 coroutine::*,
64 rfc8620::{JmapSession, coroutine::JmapRedirectYield},
65};
66
67#[derive(Debug, Error)]
69pub enum JmapSessionGetError {
70 #[error("JMAP session-get failed: HTTP {0}")]
71 HttpStatus(u16),
72 #[error("JMAP session-get failed: no primary account for the mail capability")]
73 NoPrimaryMailAccount,
74 #[error("JMAP session-get failed: {0}")]
75 Send(#[from] Http11SendError),
76 #[error("JMAP session-get failed: parse session: {0}")]
77 ParseSession(#[source] serde_json::Error),
78}
79
80#[derive(Clone, Debug)]
82pub struct JmapSessionGetOutput {
83 pub session: JmapSession,
84 pub keep_alive: bool,
85}
86
87pub struct JmapSessionGet {
98 state: State,
99}
100
101impl JmapSessionGet {
102 pub fn new(http_auth: &SecretString, url: &Url) -> Self {
106 let host = url.host_str().unwrap_or("localhost");
107
108 let session_url = match url.path() {
109 "" | "/" => {
110 let mut u = url.clone();
111 u.set_path("/.well-known/jmap");
112 u
113 }
114 _ => url.clone(),
115 };
116
117 trace!("fetch JMAP session from {session_url}");
118
119 let http_request = HttpRequest::get(session_url)
120 .header("Host", host)
121 .header("Accept", "application/json")
122 .header("Authorization", http_auth.expose_secret());
123
124 Self {
125 state: State::Send(Http11Send::new(http_request)),
126 }
127 }
128}
129
130impl JmapCoroutine for JmapSessionGet {
131 type Yield = JmapRedirectYield;
132 type Return = Result<JmapSessionGetOutput, JmapSessionGetError>;
133
134 fn resume(&mut self, arg: Option<&[u8]>) -> JmapCoroutineState<Self::Yield, Self::Return> {
135 trace!("session-get: {}", self.state);
136 match &mut self.state {
137 State::Send(send) => match send.resume(arg) {
138 HttpCoroutineState::Yielded(y) => JmapCoroutineState::Yielded(y.into()),
139 HttpCoroutineState::Complete(Err(err)) => {
140 JmapCoroutineState::Complete(Err(err.into()))
141 }
142 HttpCoroutineState::Complete(Ok(out)) => {
143 if !out.response.status.is_success() {
144 let err = JmapSessionGetError::HttpStatus(*out.response.status);
145 return JmapCoroutineState::Complete(Err(err));
146 }
147
148 match serde_json::from_slice::<JmapSession>(&out.response.body) {
149 Ok(session) => JmapCoroutineState::Complete(Ok(JmapSessionGetOutput {
150 session,
151 keep_alive: out.keep_alive,
152 })),
153 Err(err) => JmapCoroutineState::Complete(Err(
154 JmapSessionGetError::ParseSession(err),
155 )),
156 }
157 }
158 },
159 }
160 }
161}
162
163enum State {
164 Send(Http11Send),
165}
166
167impl fmt::Display for State {
168 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169 match self {
170 Self::Send(_) => f.write_str("send"),
171 }
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use alloc::{format, vec::Vec};
178
179 use super::*;
180
181 fn make_auth() -> SecretString {
182 SecretString::from("Bearer test")
183 }
184
185 fn make_url() -> Url {
186 "https://api.example.com/".parse().unwrap()
187 }
188
189 fn session_json() -> &'static [u8] {
190 br#"{
191 "capabilities": {},
192 "accounts": {},
193 "primaryAccounts": {},
194 "username": "alice",
195 "apiUrl": "https://api.example.com/jmap/",
196 "downloadUrl": "https://api.example.com/jmap/download/{accountId}/{blobId}/{name}?accept={type}",
197 "uploadUrl": "https://api.example.com/jmap/upload/{accountId}/",
198 "eventSourceUrl": "https://api.example.com/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}",
199 "state": "abc"
200 }"#
201 }
202
203 #[test]
204 fn success_returns_ok() {
205 let mut cor = JmapSessionGet::new(&make_auth(), &make_url());
206
207 expect_wants_write(&mut cor, None);
208 expect_wants_read(&mut cor);
209
210 let body = session_json();
211 let reply = format!(
212 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n",
213 body.len()
214 );
215 let mut bytes = reply.into_bytes();
216 bytes.extend_from_slice(body);
217 let out = expect_complete_ok(&mut cor, &bytes);
218 assert_eq!(out.session.username, "alice");
219 }
220
221 #[test]
222 fn http_error_returns_status() {
223 let mut cor = JmapSessionGet::new(&make_auth(), &make_url());
224
225 expect_wants_write(&mut cor, None);
226 expect_wants_read(&mut cor);
227
228 let reply = b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n";
229 let err = expect_complete_err(&mut cor, reply);
230 assert!(matches!(err, JmapSessionGetError::HttpStatus(401)));
231 }
232
233 #[test]
234 fn redirect_yields_redirect() {
235 let mut cor = JmapSessionGet::new(&make_auth(), &make_url());
236
237 expect_wants_write(&mut cor, None);
238 expect_wants_read(&mut cor);
239
240 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";
241 match cor.resume(Some(reply)) {
242 JmapCoroutineState::Yielded(JmapRedirectYield::WantsRedirect { url, .. }) => {
243 assert_eq!(url.host_str(), Some("api2.example.com"));
244 }
245 state => panic!("expected WantsRedirect, got {state:?}"),
246 }
247 }
248
249 #[test]
250 fn invalid_json_returns_parse_error() {
251 let mut cor = JmapSessionGet::new(&make_auth(), &make_url());
252
253 expect_wants_write(&mut cor, None);
254 expect_wants_read(&mut cor);
255
256 let body = b"{not json";
257 let reply = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len());
258 let mut bytes = reply.into_bytes();
259 bytes.extend_from_slice(body);
260 let err = expect_complete_err(&mut cor, &bytes);
261 assert!(matches!(err, JmapSessionGetError::ParseSession(_)));
262 }
263
264 #[test]
265 fn uses_well_known_path_for_base_url() {
266 let mut cor = JmapSessionGet::new(&make_auth(), &make_url());
267 let bytes = expect_wants_write(&mut cor, None);
268 let req = core::str::from_utf8(&bytes).expect("utf8 request");
269 assert!(req.contains("/.well-known/jmap"));
270 }
271
272 fn expect_wants_write(cor: &mut JmapSessionGet, arg: Option<&[u8]>) -> Vec<u8> {
275 match cor.resume(arg) {
276 JmapCoroutineState::Yielded(JmapRedirectYield::WantsWrite(bytes)) => bytes,
277 state => panic!("expected WantsWrite, got {state:?}"),
278 }
279 }
280
281 fn expect_wants_read(cor: &mut JmapSessionGet) {
282 match cor.resume(None) {
283 JmapCoroutineState::Yielded(JmapRedirectYield::WantsRead) => {}
284 state => panic!("expected WantsRead, got {state:?}"),
285 }
286 }
287
288 fn expect_complete_ok(cor: &mut JmapSessionGet, reply: &[u8]) -> JmapSessionGetOutput {
289 match cor.resume(Some(reply)) {
290 JmapCoroutineState::Complete(Ok(out)) => out,
291 state => panic!("expected Complete(Ok), got {state:?}"),
292 }
293 }
294
295 fn expect_complete_err(cor: &mut JmapSessionGet, reply: &[u8]) -> JmapSessionGetError {
296 match cor.resume(Some(reply)) {
297 JmapCoroutineState::Complete(Err(err)) => err,
298 state => panic!("expected Complete(Err), got {state:?}"),
299 }
300 }
301}