1use alloc::{collections::BTreeMap, string::String, vec, vec::Vec};
71
72use secrecy::SecretString;
73use serde::{Deserialize, Serialize};
74use thiserror::Error;
75
76use crate::{
77 coroutine::*,
78 jmap_try,
79 rfc8620::{
80 JMAP_CORE_CAPABILITY, error::JmapMethodError, error::JmapSetError,
81 push_subscription::JmapPushSubscription, request::JmapBatch, send::*, session::JmapSession,
82 },
83};
84
85#[derive(Clone, Debug, Default, Serialize)]
92#[serde(rename_all = "camelCase")]
93pub struct JmapPushSubscriptionCreate {
94 pub device_client_id: String,
97 pub url: String,
99 #[serde(skip_serializing_if = "Option::is_none")]
102 pub keys: Option<JmapPushSubscriptionKeys>,
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub expires: Option<String>,
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub types: Option<Vec<String>>,
109}
110
111#[derive(Clone, Debug, Default, Serialize)]
117#[serde(rename_all = "camelCase")]
118pub struct JmapPushSubscriptionUpdate {
119 #[serde(skip_serializing_if = "Option::is_none")]
122 pub verification_code: Option<String>,
123 #[serde(skip_serializing_if = "Option::is_none")]
126 pub expires: Option<String>,
127 #[serde(skip_serializing_if = "Option::is_none")]
129 pub types: Option<Vec<String>>,
130}
131
132#[derive(Clone, Debug, Serialize, Deserialize)]
135pub struct JmapPushSubscriptionKeys {
136 pub p256dh: String,
138 pub auth: String,
140}
141
142#[derive(Debug, Error)]
144pub enum JmapPushSubscriptionSetError {
145 #[error("JMAP PushSubscription/set failed: missing response in method_responses")]
147 MissingResponse,
148 #[error("JMAP PushSubscription/set failed: {0}")]
150 Send(#[from] JmapSendError),
151 #[error("JMAP PushSubscription/set failed: serialize args: {0}")]
153 SerializeArgs(#[source] serde_json::Error),
154 #[error("JMAP PushSubscription/set failed: parse response: {0}")]
156 ParseResponse(#[source] serde_json::Error),
157 #[error("JMAP PushSubscription/set failed: {0}")]
159 Method(#[from] JmapMethodError),
160}
161
162#[derive(Clone, Debug, Default)]
164pub struct JmapPushSubscriptionSetArgs {
165 pub create: BTreeMap<String, JmapPushSubscriptionCreate>,
167 pub update: BTreeMap<String, JmapPushSubscriptionUpdate>,
169 pub destroy: Vec<String>,
171}
172
173impl JmapPushSubscriptionSetArgs {
174 pub fn create(
176 &mut self,
177 client_id: impl Into<String>,
178 subscription: JmapPushSubscriptionCreate,
179 ) -> &mut Self {
180 self.create.insert(client_id.into(), subscription);
181 self
182 }
183
184 pub fn update(
186 &mut self,
187 id: impl Into<String>,
188 patch: JmapPushSubscriptionUpdate,
189 ) -> &mut Self {
190 self.update.insert(id.into(), patch);
191 self
192 }
193
194 pub fn destroy(&mut self, id: impl Into<String>) -> &mut Self {
196 self.destroy.push(id.into());
197 self
198 }
199}
200
201#[derive(Clone, Debug)]
206pub struct JmapPushSubscriptionSetOutput {
207 pub created: BTreeMap<String, JmapPushSubscription>,
209 pub updated: BTreeMap<String, Option<JmapPushSubscription>>,
211 pub destroyed: Vec<String>,
213 pub not_created: BTreeMap<String, JmapSetError>,
215 pub not_updated: BTreeMap<String, JmapSetError>,
217 pub not_destroyed: BTreeMap<String, JmapSetError>,
219 pub keep_alive: bool,
221}
222
223pub struct JmapPushSubscriptionSet {
225 state: State,
226}
227
228impl JmapPushSubscriptionSet {
229 pub fn new(
231 session: &JmapSession,
232 http_auth: &SecretString,
233 args: JmapPushSubscriptionSetArgs,
234 ) -> Result<Self, JmapPushSubscriptionSetError> {
235 let json_args = serde_json::to_value(PushSubscriptionSetRequest {
236 create: args.create,
237 update: args.update,
238 destroy: args.destroy,
239 })
240 .map_err(JmapPushSubscriptionSetError::SerializeArgs)?;
241
242 let mut batch = JmapBatch::new();
243 batch.add("PushSubscription/set", json_args);
244 let request = batch.into_request(vec![JMAP_CORE_CAPABILITY.into()]);
245
246 Ok(Self {
247 state: State::Send(JmapSend::new(http_auth, &session.api_url, request)?),
248 })
249 }
250}
251
252impl JmapCoroutine for JmapPushSubscriptionSet {
253 type Yield = JmapYield;
254 type Return = Result<JmapPushSubscriptionSetOutput, JmapPushSubscriptionSetError>;
255
256 fn resume(&mut self, arg: Option<&[u8]>) -> JmapCoroutineState<Self::Yield, Self::Return> {
257 match &mut self.state {
258 State::Send(send) => {
259 let JmapSendOutput {
260 response,
261 keep_alive,
262 } = jmap_try!(send, arg);
263
264 let Some((name, args, _)) = response.method_responses.into_iter().next() else {
265 return JmapCoroutineState::Complete(Err(
266 JmapPushSubscriptionSetError::MissingResponse,
267 ));
268 };
269
270 if name == "error" {
271 let err = serde_json::from_value::<JmapMethodError>(args)
272 .unwrap_or(JmapMethodError::Unknown);
273 return JmapCoroutineState::Complete(Err(err.into()));
274 }
275
276 match serde_json::from_value::<PushSubscriptionSetResponse>(args) {
277 Ok(r) => JmapCoroutineState::Complete(Ok(JmapPushSubscriptionSetOutput {
278 created: r.created.unwrap_or_default(),
279 updated: r.updated.unwrap_or_default(),
280 destroyed: r.destroyed.unwrap_or_default(),
281 not_created: r.not_created.unwrap_or_default(),
282 not_updated: r.not_updated.unwrap_or_default(),
283 not_destroyed: r.not_destroyed.unwrap_or_default(),
284 keep_alive,
285 })),
286 Err(err) => JmapCoroutineState::Complete(Err(
287 JmapPushSubscriptionSetError::ParseResponse(err),
288 )),
289 }
290 }
291 }
292 }
293}
294
295enum State {
296 Send(JmapSend),
297}
298
299#[derive(Serialize)]
300#[serde(rename_all = "camelCase")]
301struct PushSubscriptionSetRequest {
302 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
303 create: BTreeMap<String, JmapPushSubscriptionCreate>,
304 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
305 update: BTreeMap<String, JmapPushSubscriptionUpdate>,
306 #[serde(skip_serializing_if = "Vec::is_empty")]
307 destroy: Vec<String>,
308}
309
310#[derive(Deserialize)]
311#[serde(rename_all = "camelCase")]
312struct PushSubscriptionSetResponse {
313 #[serde(default)]
314 created: Option<BTreeMap<String, JmapPushSubscription>>,
315 #[serde(default)]
316 updated: Option<BTreeMap<String, Option<JmapPushSubscription>>>,
317 #[serde(default)]
318 destroyed: Option<Vec<String>>,
319 #[serde(default)]
320 not_created: Option<BTreeMap<String, JmapSetError>>,
321 #[serde(default)]
322 not_updated: Option<BTreeMap<String, JmapSetError>>,
323 #[serde(default)]
324 not_destroyed: Option<BTreeMap<String, JmapSetError>>,
325}
326
327#[cfg(test)]
328mod tests {
329 use alloc::{format, string::ToString};
330
331 use crate::rfc8620::push_subscription::set::*;
332
333 fn make_auth() -> SecretString {
334 SecretString::from("Bearer test")
335 }
336
337 fn make_session() -> JmapSession {
338 serde_json::from_str(
339 r#"{
340 "username": "",
341 "accounts": {},
342 "primaryAccounts": {},
343 "capabilities": {},
344 "apiUrl": "https://api.example.com/jmap/",
345 "downloadUrl": "",
346 "uploadUrl": "",
347 "eventSourceUrl": "",
348 "state": ""
349 }"#,
350 )
351 .unwrap()
352 }
353
354 fn make_set() -> JmapPushSubscriptionSet {
355 let mut args = JmapPushSubscriptionSetArgs::default();
356 args.create(
357 "c1",
358 JmapPushSubscriptionCreate {
359 device_client_id: "a889-ffea-910".to_string(),
360 url: "https://push.example.com/?device=X8980fc".to_string(),
361 ..Default::default()
362 },
363 );
364 JmapPushSubscriptionSet::new(&make_session(), &make_auth(), args).unwrap()
365 }
366
367 fn build_http_reply(body: &[u8]) -> Vec<u8> {
368 let head = format!(
369 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n",
370 body.len()
371 );
372 let mut bytes = head.into_bytes();
373 bytes.extend_from_slice(body);
374 bytes
375 }
376
377 #[test]
378 fn request_omits_account_id_and_if_in_state() {
379 let mut cor = make_set();
380 let bytes = expect_wants_write(&mut cor, None);
381 let request = String::from_utf8(bytes).unwrap();
382 assert!(!request.contains("accountId"));
383 assert!(!request.contains("ifInState"));
384 }
385
386 #[test]
387 fn success_returns_ok_without_state() {
388 let mut cor = make_set();
389 expect_wants_write(&mut cor, None);
390 expect_wants_read(&mut cor);
391
392 let body = br#"{
393 "methodResponses": [["PushSubscription/set", {
394 "created": {
395 "c1": {
396 "id": "P1",
397 "keys": null,
398 "expires": "2018-07-13T02:14:29Z"
399 }
400 }
401 }, "c0"]],
402 "sessionState": "s1"
403 }"#;
404 let reply = build_http_reply(body);
405 let out = expect_complete_ok(&mut cor, &reply);
406 assert_eq!(out.created["c1"].id, "P1");
407 assert_eq!(
408 out.created["c1"].expires.as_deref(),
409 Some("2018-07-13T02:14:29Z")
410 );
411 }
412
413 #[test]
414 fn updated_echo_without_id_parses() {
415 let mut cor = make_set();
416 expect_wants_write(&mut cor, None);
417 expect_wants_read(&mut cor);
418
419 let body = br#"{
420 "methodResponses": [["PushSubscription/set", {
421 "updated": {
422 "P1": { "expires": "2018-07-15T02:22:50Z" }
423 }
424 }, "c0"]],
425 "sessionState": "s1"
426 }"#;
427 let reply = build_http_reply(body);
428 let out = expect_complete_ok(&mut cor, &reply);
429 let echo = out.updated["P1"].as_ref().unwrap();
430 assert!(echo.id.is_empty());
431 assert_eq!(echo.expires.as_deref(), Some("2018-07-15T02:22:50Z"));
432 }
433
434 #[test]
435 fn invalid_verification_code_surfaces_in_not_updated() {
436 let mut cor = make_set();
437 expect_wants_write(&mut cor, None);
438 expect_wants_read(&mut cor);
439
440 let body = br#"{
441 "methodResponses": [["PushSubscription/set", {
442 "notUpdated": {
443 "P1": {
444 "type": "invalidProperties",
445 "properties": ["verificationCode"]
446 }
447 }
448 }, "c0"]],
449 "sessionState": "s1"
450 }"#;
451 let reply = build_http_reply(body);
452 let out = expect_complete_ok(&mut cor, &reply);
453 assert_eq!(out.not_updated["P1"].r#type, "invalidProperties");
454 }
455
456 #[test]
457 fn method_error_returns_method_error() {
458 let mut cor = make_set();
459 expect_wants_write(&mut cor, None);
460 expect_wants_read(&mut cor);
461
462 let body = br#"{
463 "methodResponses": [["error", {"type":"invalidArguments"}, "c0"]],
464 "sessionState": "s1"
465 }"#;
466 let reply = build_http_reply(body);
467 let err = expect_complete_err(&mut cor, &reply);
468 assert!(matches!(err, JmapPushSubscriptionSetError::Method(_)));
469 }
470
471 #[test]
472 fn missing_response_returns_missing_response() {
473 let mut cor = make_set();
474 expect_wants_write(&mut cor, None);
475 expect_wants_read(&mut cor);
476
477 let reply = build_http_reply(br#"{"methodResponses":[], "sessionState":"s1"}"#);
478 let err = expect_complete_err(&mut cor, &reply);
479 assert!(matches!(err, JmapPushSubscriptionSetError::MissingResponse));
480 }
481
482 #[test]
483 fn parse_error_returns_parse_response() {
484 let mut cor = make_set();
485 expect_wants_write(&mut cor, None);
486 expect_wants_read(&mut cor);
487
488 let body = br#"{
489 "methodResponses": [["PushSubscription/set", {"created":42}, "c0"]],
490 "sessionState": "s1"
491 }"#;
492 let reply = build_http_reply(body);
493 let err = expect_complete_err(&mut cor, &reply);
494 assert!(matches!(
495 err,
496 JmapPushSubscriptionSetError::ParseResponse(_)
497 ));
498 }
499
500 #[test]
501 fn http_error_surfaces_as_send_error() {
502 let mut cor = make_set();
503 expect_wants_write(&mut cor, None);
504 expect_wants_read(&mut cor);
505
506 let reply = b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n";
507 let err = expect_complete_err(&mut cor, reply);
508 assert!(matches!(
509 err,
510 JmapPushSubscriptionSetError::Send(JmapSendError::HttpStatus(401))
511 ));
512 }
513
514 fn expect_wants_write(cor: &mut JmapPushSubscriptionSet, arg: Option<&[u8]>) -> Vec<u8> {
515 match cor.resume(arg) {
516 JmapCoroutineState::Yielded(JmapYield::WantsWrite(bytes)) => bytes,
517 state => panic!("expected WantsWrite, got {state:?}"),
518 }
519 }
520
521 fn expect_wants_read(cor: &mut JmapPushSubscriptionSet) {
522 match cor.resume(None) {
523 JmapCoroutineState::Yielded(JmapYield::WantsRead) => {}
524 state => panic!("expected WantsRead, got {state:?}"),
525 }
526 }
527
528 fn expect_complete_ok(
529 cor: &mut JmapPushSubscriptionSet,
530 reply: &[u8],
531 ) -> JmapPushSubscriptionSetOutput {
532 match cor.resume(Some(reply)) {
533 JmapCoroutineState::Complete(Ok(out)) => out,
534 state => panic!("expected Complete(Ok), got {state:?}"),
535 }
536 }
537
538 fn expect_complete_err(
539 cor: &mut JmapPushSubscriptionSet,
540 reply: &[u8],
541 ) -> JmapPushSubscriptionSetError {
542 match cor.resume(Some(reply)) {
543 JmapCoroutineState::Complete(Err(err)) => err,
544 state => panic!("expected Complete(Err), got {state:?}"),
545 }
546 }
547}