1use std::collections::HashSet;
4
5use serde::de;
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8use crate::{Identity, PrimaryData, Resource};
9
10#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
15pub struct AtomicRequest {
16 #[serde(rename = "atomic:operations")]
21 pub operations: Vec<AtomicOperation>,
22}
23
24#[non_exhaustive]
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30#[serde(tag = "op", rename_all = "lowercase")]
31pub enum AtomicOperation {
32 Add {
34 #[serde(flatten)]
36 target: OperationTarget,
37 data: PrimaryData<Resource>,
39 },
40 Update {
42 #[serde(flatten)]
44 target: OperationTarget,
45 data: PrimaryData<Resource>,
47 },
48 Remove {
50 #[serde(flatten)]
52 target: OperationTarget,
53 },
54}
55
56#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
70pub struct OperationTarget {
71 #[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
73 pub r#ref: Option<OperationRef>,
74
75 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub href: Option<String>,
78}
79
80impl OperationTarget {
81 #[must_use]
83 pub fn is_valid(&self) -> bool {
84 !(self.r#ref.is_some() && self.href.is_some())
85 }
86}
87
88#[derive(Debug, Clone, PartialEq)]
95pub struct OperationRef {
96 pub type_: String,
98 pub identity: Identity,
100 pub relationship: Option<String>,
102}
103
104#[derive(Serialize)]
105struct OperationRefSerRepr<'a> {
106 #[serde(rename = "type")]
107 type_: &'a str,
108 #[serde(skip_serializing_if = "Option::is_none")]
109 id: Option<&'a str>,
110 #[serde(skip_serializing_if = "Option::is_none")]
111 lid: Option<&'a str>,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 relationship: Option<&'a str>,
114}
115
116#[derive(Deserialize)]
117struct OperationRefDeRepr {
118 #[serde(rename = "type")]
119 type_: String,
120 #[serde(default)]
121 id: Option<String>,
122 #[serde(default)]
123 lid: Option<String>,
124 #[serde(default)]
125 relationship: Option<String>,
126}
127
128impl Serialize for OperationRef {
129 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
130 let (id, lid) = match &self.identity {
131 Identity::Id(id) => (Some(id.as_str()), None),
132 Identity::Lid(lid) => (None, Some(lid.as_str())),
133 };
134 OperationRefSerRepr {
135 type_: &self.type_,
136 id,
137 lid,
138 relationship: self.relationship.as_deref(),
139 }
140 .serialize(serializer)
141 }
142}
143
144impl<'de> Deserialize<'de> for OperationRef {
145 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
146 let repr = OperationRefDeRepr::deserialize(deserializer)?;
147 let identity = match (repr.id, repr.lid) {
148 (Some(_), Some(_)) => {
149 return Err(de::Error::custom(
150 "operation ref must not have both `id` and `lid`",
151 ));
152 }
153 (Some(id), None) => Identity::Id(id),
154 (None, Some(lid)) => Identity::Lid(lid),
155 (None, None) => return Err(de::Error::custom("operation ref must have `id` or `lid`")),
156 };
157 Ok(OperationRef {
158 type_: repr.type_,
159 identity,
160 relationship: repr.relationship,
161 })
162 }
163}
164
165impl AtomicRequest {
166 pub fn validate_lid_refs(&self) -> crate::Result<()> {
175 let mut introduced: HashSet<String> = HashSet::new();
176
177 for (index, op) in self.operations.iter().enumerate() {
178 let target = op.target();
179
180 if !target.is_valid() {
181 return Err(crate::Error::InvalidAtomicOperation {
182 index,
183 reason: "operation target must not have both `ref` and `href`".into(),
184 });
185 }
186
187 if let Some(r) = &target.r#ref
188 && let Identity::Lid(lid) = &r.identity
189 && !introduced.contains(lid)
190 {
191 return Err(crate::Error::InvalidAtomicOperation {
192 index,
193 reason: format!("ref.lid `{lid}` not introduced by an earlier `add`"),
194 });
195 }
196
197 if let AtomicOperation::Add { data, .. } = op {
198 for resource in primary_data_resources(data) {
199 if let Some(lid) = &resource.lid
200 && !introduced.insert(lid.clone())
201 {
202 return Err(crate::Error::InvalidAtomicOperation {
203 index,
204 reason: format!("duplicate `lid` introduction: `{lid}`"),
205 });
206 }
207 }
208 }
209 }
210
211 Ok(())
212 }
213}
214
215impl AtomicOperation {
216 fn target(&self) -> &OperationTarget {
218 match self {
219 AtomicOperation::Add { target, .. }
220 | AtomicOperation::Update { target, .. }
221 | AtomicOperation::Remove { target } => target,
222 }
223 }
224}
225
226fn primary_data_resources(
228 data: &PrimaryData<Resource>,
229) -> Box<dyn Iterator<Item = &Resource> + '_> {
230 match data {
231 PrimaryData::Null => Box::new(std::iter::empty()),
232 PrimaryData::Single(boxed) => Box::new(std::iter::once(boxed.as_ref())),
233 PrimaryData::Many(vec) => Box::new(vec.iter()),
234 }
235}
236
237#[cfg(test)]
238mod operation_ref_tests {
239 use super::*;
240 use crate::Identity;
241
242 #[test]
243 fn serializes_with_id() {
244 let r = OperationRef {
245 type_: "articles".into(),
246 identity: Identity::Id("1".into()),
247 relationship: None,
248 };
249 assert_eq!(
250 serde_json::to_string(&r).unwrap(),
251 r#"{"type":"articles","id":"1"}"#
252 );
253 }
254
255 #[test]
256 fn serializes_with_lid() {
257 let r = OperationRef {
258 type_: "articles".into(),
259 identity: Identity::Lid("local-1".into()),
260 relationship: None,
261 };
262 assert_eq!(
263 serde_json::to_string(&r).unwrap(),
264 r#"{"type":"articles","lid":"local-1"}"#
265 );
266 }
267
268 #[test]
269 fn serializes_with_relationship() {
270 let r = OperationRef {
271 type_: "articles".into(),
272 identity: Identity::Id("1".into()),
273 relationship: Some("comments".into()),
274 };
275 assert_eq!(
276 serde_json::to_string(&r).unwrap(),
277 r#"{"type":"articles","id":"1","relationship":"comments"}"#
278 );
279 }
280
281 #[test]
282 fn deserializes_with_id() {
283 let r: OperationRef = serde_json::from_str(r#"{"type":"articles","id":"1"}"#).unwrap();
284 assert_eq!(r.type_, "articles");
285 assert_eq!(r.identity, Identity::Id("1".into()));
286 assert_eq!(r.relationship, None);
287 }
288
289 #[test]
290 fn deserializes_with_lid() {
291 let r: OperationRef =
292 serde_json::from_str(r#"{"type":"articles","lid":"local-1"}"#).unwrap();
293 assert_eq!(r.identity, Identity::Lid("local-1".into()));
294 }
295
296 #[test]
297 fn deserializes_with_relationship() {
298 let r: OperationRef =
299 serde_json::from_str(r#"{"type":"articles","id":"1","relationship":"comments"}"#)
300 .unwrap();
301 assert_eq!(r.relationship, Some("comments".into()));
302 }
303
304 #[test]
305 fn rejects_missing_identity() {
306 let result: std::result::Result<OperationRef, _> =
307 serde_json::from_str(r#"{"type":"articles"}"#);
308 assert!(result.is_err());
309 }
310
311 #[test]
312 fn rejects_both_id_and_lid() {
313 let result: std::result::Result<OperationRef, _> =
314 serde_json::from_str(r#"{"type":"articles","id":"1","lid":"local-1"}"#);
315 assert!(result.is_err());
316 }
317}
318
319#[cfg(test)]
320mod operation_target_tests {
321 use super::*;
322 use crate::Identity;
323
324 fn ref_only() -> OperationTarget {
325 OperationTarget {
326 r#ref: Some(OperationRef {
327 type_: "articles".into(),
328 identity: Identity::Id("1".into()),
329 relationship: None,
330 }),
331 href: None,
332 }
333 }
334
335 fn href_only() -> OperationTarget {
336 OperationTarget {
337 r#ref: None,
338 href: Some("/articles/1".into()),
339 }
340 }
341
342 fn neither() -> OperationTarget {
343 OperationTarget {
344 r#ref: None,
345 href: None,
346 }
347 }
348
349 fn both() -> OperationTarget {
350 OperationTarget {
351 r#ref: Some(OperationRef {
352 type_: "articles".into(),
353 identity: Identity::Id("1".into()),
354 relationship: None,
355 }),
356 href: Some("/articles/1".into()),
357 }
358 }
359
360 #[test]
361 fn is_valid_ref_only() {
362 assert!(ref_only().is_valid());
363 }
364
365 #[test]
366 fn is_valid_href_only() {
367 assert!(href_only().is_valid());
368 }
369
370 #[test]
371 fn is_valid_neither() {
372 assert!(neither().is_valid());
373 }
374
375 #[test]
376 fn is_invalid_when_both_set() {
377 assert!(!both().is_valid());
378 }
379
380 #[test]
381 fn serializes_ref_only() {
382 let json = serde_json::to_value(ref_only()).unwrap();
383 assert_eq!(json["ref"]["type"], "articles");
384 assert_eq!(json["ref"]["id"], "1");
385 assert!(json.get("href").is_none());
386 }
387
388 #[test]
389 fn serializes_href_only() {
390 let json = serde_json::to_value(href_only()).unwrap();
391 assert_eq!(json["href"], "/articles/1");
392 assert!(json.get("ref").is_none());
393 }
394
395 #[test]
396 fn serializes_neither_as_empty_object() {
397 let json = serde_json::to_string(&neither()).unwrap();
398 assert_eq!(json, "{}");
399 }
400
401 #[test]
402 fn deserializes_ref_only() {
403 let t: OperationTarget =
404 serde_json::from_str(r#"{"ref":{"type":"articles","id":"1"}}"#).unwrap();
405 assert!(t.r#ref.is_some());
406 assert!(t.href.is_none());
407 }
408
409 #[test]
410 fn deserializes_href_only() {
411 let t: OperationTarget = serde_json::from_str(r#"{"href":"/articles/1"}"#).unwrap();
412 assert!(t.r#ref.is_none());
413 assert_eq!(t.href.as_deref(), Some("/articles/1"));
414 }
415
416 #[test]
417 fn deserializes_empty_object() {
418 let t: OperationTarget = serde_json::from_str("{}").unwrap();
419 assert!(t.r#ref.is_none());
420 assert!(t.href.is_none());
421 }
422}
423
424#[cfg(test)]
425mod atomic_operation_tests {
426 use super::*;
427 use crate::{Identity, PrimaryData, Resource};
428
429 fn sample_ref() -> OperationRef {
430 OperationRef {
431 type_: "articles".into(),
432 identity: Identity::Id("1".into()),
433 relationship: None,
434 }
435 }
436
437 fn sample_resource() -> Resource {
438 Resource {
439 type_: "articles".into(),
440 id: None,
441 lid: Some("local-1".into()),
442 attributes: serde_json::json!({"title": "Hello"}),
443 relationships: std::collections::BTreeMap::new(),
444 links: None,
445 meta: None,
446 }
447 }
448
449 #[test]
450 fn add_op_serializes_with_lowercase_op() {
451 let op = AtomicOperation::Add {
452 target: OperationTarget::default(),
453 data: PrimaryData::Single(Box::new(sample_resource())),
454 };
455 let json = serde_json::to_value(&op).unwrap();
456 assert_eq!(json["op"], "add");
457 }
458
459 #[test]
460 fn update_op_serializes_with_lowercase_op() {
461 let op = AtomicOperation::Update {
462 target: OperationTarget {
463 r#ref: Some(sample_ref()),
464 href: None,
465 },
466 data: PrimaryData::Single(Box::new(sample_resource())),
467 };
468 let json = serde_json::to_value(&op).unwrap();
469 assert_eq!(json["op"], "update");
470 assert_eq!(json["ref"]["type"], "articles");
471 }
472
473 #[test]
474 fn remove_op_has_no_data_field() {
475 let op = AtomicOperation::Remove {
476 target: OperationTarget {
477 r#ref: Some(sample_ref()),
478 href: None,
479 },
480 };
481 let json = serde_json::to_value(&op).unwrap();
482 assert_eq!(json["op"], "remove");
483 assert_eq!(json["ref"]["id"], "1");
484 assert!(json.get("data").is_none());
485 }
486
487 #[test]
488 fn deserializes_add() {
489 let json = r#"{"op":"add","data":{"type":"articles","lid":"l1","attributes":{}}}"#;
490 let op: AtomicOperation = serde_json::from_str(json).unwrap();
491 match op {
492 AtomicOperation::Add { target, data } => {
493 assert!(target.r#ref.is_none() && target.href.is_none());
494 assert!(matches!(data, PrimaryData::Single(_)));
495 }
496 _ => panic!("expected Add variant"),
497 }
498 }
499
500 #[test]
501 fn deserializes_update_with_ref() {
502 let json = r#"{"op":"update","ref":{"type":"articles","id":"1"},"data":{"type":"articles","id":"1","attributes":{"title":"New"}}}"#;
503 let op: AtomicOperation = serde_json::from_str(json).unwrap();
504 match op {
505 AtomicOperation::Update { target, .. } => {
506 assert_eq!(target.r#ref.unwrap().type_, "articles");
507 }
508 _ => panic!("expected Update variant"),
509 }
510 }
511
512 #[test]
513 fn deserializes_remove() {
514 let json = r#"{"op":"remove","ref":{"type":"articles","id":"1"}}"#;
515 let op: AtomicOperation = serde_json::from_str(json).unwrap();
516 assert!(matches!(op, AtomicOperation::Remove { .. }));
517 }
518
519 #[test]
520 fn rejects_unknown_op() {
521 let json = r#"{"op":"frobnicate","ref":{"type":"articles","id":"1"}}"#;
522 let result: std::result::Result<AtomicOperation, _> = serde_json::from_str(json);
523 assert!(result.is_err());
524 }
525
526 #[test]
527 fn add_many_to_relationship_round_trip() {
528 let op = AtomicOperation::Add {
529 target: OperationTarget {
530 r#ref: Some(OperationRef {
531 type_: "articles".into(),
532 identity: Identity::Id("1".into()),
533 relationship: Some("tags".into()),
534 }),
535 href: None,
536 },
537 data: PrimaryData::Many(vec![Resource {
538 type_: "tags".into(),
539 id: Some("5".into()),
540 lid: None,
541 attributes: serde_json::json!({}),
542 relationships: std::collections::BTreeMap::new(),
543 links: None,
544 meta: None,
545 }]),
546 };
547 let json = serde_json::to_value(&op).unwrap();
548 assert_eq!(json["op"], "add");
549 assert_eq!(json["ref"]["relationship"], "tags");
550 assert_eq!(json["data"][0]["type"], "tags");
551
552 let round: AtomicOperation = serde_json::from_value(json).unwrap();
553 assert_eq!(round, op);
554 }
555}
556
557#[cfg(test)]
558mod atomic_request_tests {
559 use super::*;
560 use crate::{Identity, PrimaryData, Resource};
561
562 #[test]
563 fn empty_request_round_trips() {
564 let req = AtomicRequest::default();
565 let json = serde_json::to_string(&req).unwrap();
566 assert_eq!(json, r#"{"atomic:operations":[]}"#);
567 let round: AtomicRequest = serde_json::from_str(&json).unwrap();
568 assert_eq!(round, req);
569 }
570
571 #[test]
572 fn multi_op_request_round_trips() {
573 let req = AtomicRequest {
574 operations: vec![
575 AtomicOperation::Add {
576 target: OperationTarget::default(),
577 data: PrimaryData::Single(Box::new(Resource {
578 type_: "people".into(),
579 id: None,
580 lid: Some("p1".into()),
581 attributes: serde_json::json!({"name": "Dan"}),
582 relationships: std::collections::BTreeMap::new(),
583 links: None,
584 meta: None,
585 })),
586 },
587 AtomicOperation::Remove {
588 target: OperationTarget {
589 r#ref: Some(OperationRef {
590 type_: "articles".into(),
591 identity: Identity::Id("9".into()),
592 relationship: None,
593 }),
594 href: None,
595 },
596 },
597 ],
598 };
599 let json = serde_json::to_value(&req).unwrap();
600 assert_eq!(json["atomic:operations"].as_array().unwrap().len(), 2);
601 assert_eq!(json["atomic:operations"][0]["op"], "add");
602 assert_eq!(json["atomic:operations"][1]["op"], "remove");
603
604 let round: AtomicRequest = serde_json::from_value(json).unwrap();
605 assert_eq!(round, req);
606 }
607
608 #[test]
609 fn missing_operations_field_is_error() {
610 let result: std::result::Result<AtomicRequest, _> = serde_json::from_str("{}");
612 assert!(result.is_err());
613 }
614}
615
616#[cfg(test)]
617mod validate_lid_refs_tests {
618 use super::*;
619 use crate::{Error, Identity, PrimaryData, Resource};
620
621 fn add_with_lid(lid: &str) -> AtomicOperation {
622 AtomicOperation::Add {
623 target: OperationTarget::default(),
624 data: PrimaryData::Single(Box::new(Resource {
625 type_: "people".into(),
626 id: None,
627 lid: Some(lid.into()),
628 attributes: serde_json::json!({}),
629 relationships: std::collections::BTreeMap::new(),
630 links: None,
631 meta: None,
632 })),
633 }
634 }
635
636 fn update_ref_lid(lid: &str) -> AtomicOperation {
637 AtomicOperation::Update {
638 target: OperationTarget {
639 r#ref: Some(OperationRef {
640 type_: "people".into(),
641 identity: Identity::Lid(lid.into()),
642 relationship: None,
643 }),
644 href: None,
645 },
646 data: PrimaryData::Single(Box::new(Resource {
647 type_: "people".into(),
648 id: None,
649 lid: Some(lid.into()),
650 attributes: serde_json::json!({"name": "X"}),
651 relationships: std::collections::BTreeMap::new(),
652 links: None,
653 meta: None,
654 })),
655 }
656 }
657
658 #[test]
659 fn happy_path_introduce_then_reference() {
660 let req = AtomicRequest {
661 operations: vec![add_with_lid("p1"), update_ref_lid("p1")],
662 };
663 assert!(req.validate_lid_refs().is_ok());
664 }
665
666 #[test]
667 fn empty_request_is_valid() {
668 let req = AtomicRequest::default();
669 assert!(req.validate_lid_refs().is_ok());
670 }
671
672 #[test]
673 fn reference_before_introduction_errors() {
674 let req = AtomicRequest {
675 operations: vec![update_ref_lid("p1"), add_with_lid("p1")],
676 };
677 match req.validate_lid_refs().unwrap_err() {
678 Error::InvalidAtomicOperation { index, reason } => {
679 assert_eq!(index, 0);
680 assert!(reason.contains("p1"));
681 }
682 other => panic!("expected InvalidAtomicOperation, got {other:?}"),
683 }
684 }
685
686 #[test]
687 fn unresolvable_lid_errors() {
688 let req = AtomicRequest {
689 operations: vec![update_ref_lid("ghost")],
690 };
691 match req.validate_lid_refs().unwrap_err() {
692 Error::InvalidAtomicOperation { index, .. } => assert_eq!(index, 0),
693 other => panic!("expected InvalidAtomicOperation, got {other:?}"),
694 }
695 }
696
697 #[test]
698 fn duplicate_lid_introduction_errors() {
699 let req = AtomicRequest {
700 operations: vec![add_with_lid("p1"), add_with_lid("p1")],
701 };
702 match req.validate_lid_refs().unwrap_err() {
703 Error::InvalidAtomicOperation { index, reason } => {
704 assert_eq!(index, 1);
705 assert!(reason.contains("duplicate"));
706 }
707 other => panic!("expected InvalidAtomicOperation, got {other:?}"),
708 }
709 }
710
711 #[test]
712 fn target_with_both_ref_and_href_errors() {
713 let req = AtomicRequest {
714 operations: vec![AtomicOperation::Remove {
715 target: OperationTarget {
716 r#ref: Some(OperationRef {
717 type_: "articles".into(),
718 identity: Identity::Id("1".into()),
719 relationship: None,
720 }),
721 href: Some("/articles/1".into()),
722 },
723 }],
724 };
725 match req.validate_lid_refs().unwrap_err() {
726 Error::InvalidAtomicOperation { index, reason } => {
727 assert_eq!(index, 0);
728 assert!(reason.contains("ref") && reason.contains("href"));
729 }
730 other => panic!("expected InvalidAtomicOperation, got {other:?}"),
731 }
732 }
733}