Skip to main content

io_jmap/rfc8620/push_subscription/
get.rs

1//! JMAP `PushSubscription/get` coroutine (RFC 8620 §7.2.1): builds a custom
2//! get batch (no generic [`JmapGet`](crate::rfc8620::get::JmapGet) reuse
3//! because the method takes no `accountId` and returns no `state`).
4//!
5//! # Example
6//!
7//! ```rust,no_run
8//! use std::{
9//!     io::{Read, Write},
10//!     net::TcpStream,
11//! };
12//!
13//! use io_jmap::{
14//!     coroutine::{JmapCoroutine, JmapCoroutineState, JmapYield},
15//!     rfc8620::{
16//!         session::JmapSession,
17//!         push_subscription::get::{JmapPushSubscriptionGet, JmapPushSubscriptionGetOptions},
18//!     },
19//! };
20//! use secrecy::SecretString;
21//!
22//! // Ready stream needed (TCP-connected, TLS-negociated)
23//! let mut stream = TcpStream::connect("api.example.com:443").unwrap();
24//! let mut buf = [0u8; 4096];
25//!
26//! let session: JmapSession = serde_json::from_str(r#"{
27//!     "username": "",
28//!     "accounts": {},
29//!     "primaryAccounts": {},
30//!     "capabilities": {},
31//!     "apiUrl": "https://api.example.com/jmap/",
32//!     "downloadUrl": "",
33//!     "uploadUrl": "",
34//!     "eventSourceUrl": "",
35//!     "state": ""
36//! }"#).unwrap();
37//! let auth = SecretString::from("Bearer xyz");
38//! let mut coroutine = JmapPushSubscriptionGet::new(
39//!     &session,
40//!     &auth,
41//!     JmapPushSubscriptionGetOptions::default(),
42//! )
43//! .unwrap();
44//! let mut arg = None;
45//!
46//! let out = loop {
47//!     match coroutine.resume(arg.take()) {
48//!         JmapCoroutineState::Yielded(JmapYield::WantsWrite(bytes)) => {
49//!             stream.write_all(&bytes).unwrap();
50//!         }
51//!         JmapCoroutineState::Yielded(JmapYield::WantsRead) => {
52//!             let n = stream.read(&mut buf).unwrap();
53//!             arg = Some(&buf[..n]);
54//!         }
55//!         JmapCoroutineState::Complete(Ok(out)) => break out,
56//!         JmapCoroutineState::Complete(Err(err)) => panic!("{err}"),
57//!     }
58//! };
59//!
60//! println!("{} push subscriptions", out.subscriptions.len());
61//! ```
62
63use 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/// Failure causes during a JMAP `PushSubscription/get` flow.
79#[derive(Debug, Error)]
80pub enum JmapPushSubscriptionGetError {
81    /// The response carried no method response.
82    #[error("JMAP PushSubscription/get failed: missing response in method_responses")]
83    MissingResponse,
84    /// The inner send coroutine failed.
85    #[error("JMAP PushSubscription/get failed: {0}")]
86    Send(#[from] JmapSendError),
87    /// The method arguments could not be serialized.
88    #[error("JMAP PushSubscription/get failed: serialize args: {0}")]
89    SerializeArgs(#[source] serde_json::Error),
90    /// The method response could not be parsed.
91    #[error("JMAP PushSubscription/get failed: parse response: {0}")]
92    ParseResponse(#[source] serde_json::Error),
93    /// The server returned a method-level error.
94    #[error("JMAP PushSubscription/get failed: {0}")]
95    Method(#[from] JmapMethodError),
96}
97
98/// Options for [`JmapPushSubscriptionGet::new`].
99#[derive(Clone, Debug, Default)]
100pub struct JmapPushSubscriptionGetOptions {
101    /// Restrict the fetch to these subscription IDs; `None` fetches all.
102    pub ids: Option<Vec<String>>,
103    /// Restrict the returned properties; `None` returns all but `url` and
104    /// `keys`. Requesting `url` or `keys` is rejected with a `forbidden`
105    /// error (RFC 8620 §7.2.1).
106    pub properties: Option<Vec<String>>,
107}
108
109/// Successful terminal output of [`JmapPushSubscriptionGet`].
110#[derive(Clone, Debug)]
111pub struct JmapPushSubscriptionGetOutput {
112    /// The fetched push subscriptions.
113    pub subscriptions: Vec<JmapPushSubscription>,
114    /// The requested ids the server did not find.
115    pub not_found: Vec<String>,
116    /// Whether the server indicated the connection can be reused.
117    pub keep_alive: bool,
118}
119
120/// I/O-free coroutine for the JMAP `PushSubscription/get` method.
121pub struct JmapPushSubscriptionGet {
122    state: State,
123}
124
125impl JmapPushSubscriptionGet {
126    /// Prepares the method call request and builds the coroutine.
127    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/// No `state` field: `PushSubscription/get` does not return one (RFC 8620
201/// §7.2.1).
202#[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}