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