io_jmap/rfc8620/push_subscription/
get.rs1use alloc::{string::String, vec, vec::Vec};
64
65use secrecy::SecretString;
66use serde::{Deserialize, Serialize};
67use thiserror::Error;
68
69use crate::{
70 coroutine::*,
71 jmap_try,
72 rfc8620::{
73 JMAP_CORE_CAPABILITY, error::JmapMethodError, push_subscription::JmapPushSubscription,
74 request::JmapBatch, send::*, session::JmapSession,
75 },
76};
77
78#[derive(Debug, Error)]
80pub enum JmapPushSubscriptionGetError {
81 #[error("JMAP PushSubscription/get failed: missing response in method_responses")]
83 MissingResponse,
84 #[error("JMAP PushSubscription/get failed: {0}")]
86 Send(#[from] JmapSendError),
87 #[error("JMAP PushSubscription/get failed: serialize args: {0}")]
89 SerializeArgs(#[source] serde_json::Error),
90 #[error("JMAP PushSubscription/get failed: parse response: {0}")]
92 ParseResponse(#[source] serde_json::Error),
93 #[error("JMAP PushSubscription/get failed: {0}")]
95 Method(#[from] JmapMethodError),
96}
97
98#[derive(Clone, Debug, Default)]
100pub struct JmapPushSubscriptionGetOptions {
101 pub ids: Option<Vec<String>>,
103 pub properties: Option<Vec<String>>,
107}
108
109#[derive(Clone, Debug)]
111pub struct JmapPushSubscriptionGetOutput {
112 pub subscriptions: Vec<JmapPushSubscription>,
114 pub not_found: Vec<String>,
116 pub keep_alive: bool,
118}
119
120pub struct JmapPushSubscriptionGet {
122 state: State,
123}
124
125impl JmapPushSubscriptionGet {
126 pub fn new(
128 session: &JmapSession,
129 http_auth: &SecretString,
130 opts: JmapPushSubscriptionGetOptions,
131 ) -> Result<Self, JmapPushSubscriptionGetError> {
132 let args = serde_json::to_value(PushSubscriptionGetArgs {
133 ids: opts.ids.as_deref(),
134 properties: opts.properties.as_deref(),
135 })
136 .map_err(JmapPushSubscriptionGetError::SerializeArgs)?;
137
138 let mut batch = JmapBatch::new();
139 batch.add("PushSubscription/get", args);
140 let request = batch.into_request(vec![JMAP_CORE_CAPABILITY.into()]);
141
142 Ok(Self {
143 state: State::Send(JmapSend::new(http_auth, &session.api_url, request)?),
144 })
145 }
146}
147
148impl JmapCoroutine for JmapPushSubscriptionGet {
149 type Yield = JmapYield;
150 type Return = Result<JmapPushSubscriptionGetOutput, JmapPushSubscriptionGetError>;
151
152 fn resume(&mut self, arg: Option<&[u8]>) -> JmapCoroutineState<Self::Yield, Self::Return> {
153 match &mut self.state {
154 State::Send(send) => {
155 let JmapSendOutput {
156 response,
157 keep_alive,
158 } = jmap_try!(send, arg);
159
160 let Some((name, args, _)) = response.method_responses.into_iter().next() else {
161 return JmapCoroutineState::Complete(Err(
162 JmapPushSubscriptionGetError::MissingResponse,
163 ));
164 };
165
166 if name == "error" {
167 let err = serde_json::from_value::<JmapMethodError>(args)
168 .unwrap_or(JmapMethodError::Unknown);
169 return JmapCoroutineState::Complete(Err(err.into()));
170 }
171
172 match serde_json::from_value::<PushSubscriptionGetResponse>(args) {
173 Ok(r) => JmapCoroutineState::Complete(Ok(JmapPushSubscriptionGetOutput {
174 subscriptions: r.list,
175 not_found: r.not_found,
176 keep_alive,
177 })),
178 Err(err) => JmapCoroutineState::Complete(Err(
179 JmapPushSubscriptionGetError::ParseResponse(err),
180 )),
181 }
182 }
183 }
184 }
185}
186
187enum State {
188 Send(JmapSend),
189}
190
191#[derive(Serialize)]
192#[serde(rename_all = "camelCase")]
193struct PushSubscriptionGetArgs<'a> {
194 #[serde(skip_serializing_if = "Option::is_none")]
195 ids: Option<&'a [String]>,
196 #[serde(skip_serializing_if = "Option::is_none")]
197 properties: Option<&'a [String]>,
198}
199
200#[derive(Deserialize)]
203#[serde(rename_all = "camelCase")]
204struct PushSubscriptionGetResponse {
205 list: Vec<JmapPushSubscription>,
206 #[serde(default)]
207 not_found: Vec<String>,
208}
209
210#[cfg(test)]
211mod tests {
212 use alloc::format;
213
214 use crate::rfc8620::push_subscription::get::*;
215
216 fn make_auth() -> SecretString {
217 SecretString::from("Bearer test")
218 }
219
220 fn make_session() -> JmapSession {
221 serde_json::from_str(
222 r#"{
223 "username": "",
224 "accounts": {},
225 "primaryAccounts": {},
226 "capabilities": {},
227 "apiUrl": "https://api.example.com/jmap/",
228 "downloadUrl": "",
229 "uploadUrl": "",
230 "eventSourceUrl": "",
231 "state": ""
232 }"#,
233 )
234 .unwrap()
235 }
236
237 fn make_get() -> JmapPushSubscriptionGet {
238 JmapPushSubscriptionGet::new(
239 &make_session(),
240 &make_auth(),
241 JmapPushSubscriptionGetOptions::default(),
242 )
243 .unwrap()
244 }
245
246 fn build_http_reply(body: &[u8]) -> Vec<u8> {
247 let head = format!(
248 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n",
249 body.len()
250 );
251 let mut bytes = head.into_bytes();
252 bytes.extend_from_slice(body);
253 bytes
254 }
255
256 #[test]
257 fn request_omits_account_id() {
258 let mut cor = make_get();
259 let bytes = expect_wants_write(&mut cor, None);
260 let request = String::from_utf8(bytes).unwrap();
261 assert!(!request.contains("accountId"));
262 }
263
264 #[test]
265 fn success_returns_ok_without_state() {
266 let mut cor = make_get();
267 expect_wants_write(&mut cor, None);
268 expect_wants_read(&mut cor);
269
270 let body = br#"{
271 "methodResponses": [["PushSubscription/get", {
272 "list": [{
273 "id": "P1",
274 "deviceClientId": "a889-ffea-910",
275 "verificationCode": "b210ef734fe5f439c1ca386421359f7b",
276 "expires": "2018-07-31T00:13:21Z",
277 "types": ["Email"]
278 }],
279 "notFound": []
280 }, "c0"]],
281 "sessionState": "s1"
282 }"#;
283 let reply = build_http_reply(body);
284 let out = expect_complete_ok(&mut cor, &reply);
285 assert_eq!(out.subscriptions.len(), 1);
286 assert_eq!(out.subscriptions[0].id, "P1");
287 assert_eq!(
288 out.subscriptions[0].device_client_id.as_deref(),
289 Some("a889-ffea-910")
290 );
291 assert!(out.not_found.is_empty());
292 }
293
294 #[test]
295 fn method_error_returns_method_error() {
296 let mut cor = make_get();
297 expect_wants_write(&mut cor, None);
298 expect_wants_read(&mut cor);
299
300 let body = br#"{
301 "methodResponses": [["error", {"type":"forbidden"}, "c0"]],
302 "sessionState": "s1"
303 }"#;
304 let reply = build_http_reply(body);
305 let err = expect_complete_err(&mut cor, &reply);
306 assert!(matches!(err, JmapPushSubscriptionGetError::Method(_)));
307 }
308
309 #[test]
310 fn missing_response_returns_missing_response() {
311 let mut cor = make_get();
312 expect_wants_write(&mut cor, None);
313 expect_wants_read(&mut cor);
314
315 let reply = build_http_reply(br#"{"methodResponses":[], "sessionState":"s1"}"#);
316 let err = expect_complete_err(&mut cor, &reply);
317 assert!(matches!(err, JmapPushSubscriptionGetError::MissingResponse));
318 }
319
320 #[test]
321 fn parse_error_returns_parse_response() {
322 let mut cor = make_get();
323 expect_wants_write(&mut cor, None);
324 expect_wants_read(&mut cor);
325
326 let body = br#"{
327 "methodResponses": [["PushSubscription/get", {"list":"nope"}, "c0"]],
328 "sessionState": "s1"
329 }"#;
330 let reply = build_http_reply(body);
331 let err = expect_complete_err(&mut cor, &reply);
332 assert!(matches!(
333 err,
334 JmapPushSubscriptionGetError::ParseResponse(_)
335 ));
336 }
337
338 #[test]
339 fn http_error_surfaces_as_send_error() {
340 let mut cor = make_get();
341 expect_wants_write(&mut cor, None);
342 expect_wants_read(&mut cor);
343
344 let reply = b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n";
345 let err = expect_complete_err(&mut cor, reply);
346 assert!(matches!(
347 err,
348 JmapPushSubscriptionGetError::Send(JmapSendError::HttpStatus(401))
349 ));
350 }
351
352 fn expect_wants_write(cor: &mut JmapPushSubscriptionGet, arg: Option<&[u8]>) -> Vec<u8> {
353 match cor.resume(arg) {
354 JmapCoroutineState::Yielded(JmapYield::WantsWrite(bytes)) => bytes,
355 state => panic!("expected WantsWrite, got {state:?}"),
356 }
357 }
358
359 fn expect_wants_read(cor: &mut JmapPushSubscriptionGet) {
360 match cor.resume(None) {
361 JmapCoroutineState::Yielded(JmapYield::WantsRead) => {}
362 state => panic!("expected WantsRead, got {state:?}"),
363 }
364 }
365
366 fn expect_complete_ok(
367 cor: &mut JmapPushSubscriptionGet,
368 reply: &[u8],
369 ) -> JmapPushSubscriptionGetOutput {
370 match cor.resume(Some(reply)) {
371 JmapCoroutineState::Complete(Ok(out)) => out,
372 state => panic!("expected Complete(Ok), got {state:?}"),
373 }
374 }
375
376 fn expect_complete_err(
377 cor: &mut JmapPushSubscriptionGet,
378 reply: &[u8],
379 ) -> JmapPushSubscriptionGetError {
380 match cor.resume(Some(reply)) {
381 JmapCoroutineState::Complete(Err(err)) => err,
382 state => panic!("expected Complete(Err), got {state:?}"),
383 }
384 }
385}