Skip to main content

jsonapi_core/atomic/
operation.rs

1//! Atomic operations request types.
2
3use std::collections::HashSet;
4
5use serde::de;
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8use crate::{Identity, PrimaryData, Resource};
9
10/// Request envelope carrying an ordered list of atomic operations.
11///
12/// Wire form: `{"atomic:operations": [...]}`. Execution order is significant —
13/// later operations may reference `lid` values introduced by earlier `add` ops.
14#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
15pub struct AtomicRequest {
16    /// Ordered list of operations to execute atomically.
17    ///
18    /// The array must be present on the wire; an absent `atomic:operations`
19    /// key is a deserialization error.
20    #[serde(rename = "atomic:operations")]
21    pub operations: Vec<AtomicOperation>,
22}
23
24/// A single atomic operation.
25///
26/// Discriminated on the wire by the `op` field, which takes the lowercase
27/// values `"add"`, `"update"`, or `"remove"`.
28#[non_exhaustive]
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30#[serde(tag = "op", rename_all = "lowercase")]
31pub enum AtomicOperation {
32    /// Create a new resource, or add to a to-many relationship.
33    Add {
34        /// Where the operation applies. Often empty for top-level resource creation.
35        #[serde(flatten)]
36        target: OperationTarget,
37        /// Resource(s) being created, or identifier(s) being added to a relationship.
38        data: PrimaryData<Resource>,
39    },
40    /// Update an existing resource or relationship.
41    Update {
42        /// Target of the update.
43        #[serde(flatten)]
44        target: OperationTarget,
45        /// Replacement resource or relationship linkage.
46        data: PrimaryData<Resource>,
47    },
48    /// Delete a resource or remove linkage from a relationship.
49    Remove {
50        /// Target of the removal.
51        #[serde(flatten)]
52        target: OperationTarget,
53    },
54}
55
56/// Where an atomic operation applies.
57///
58/// Three legal wire states are representable:
59///
60/// | `ref`    | `href`   | Meaning                                               |
61/// |----------|----------|-------------------------------------------------------|
62/// | `Some`   | `None`   | Structured pointer (type + id/lid + optional rel).    |
63/// | `None`   | `Some`   | URL pointer (alternative to `ref`).                   |
64/// | `None`   | `None`   | No target — e.g. `add` creating a top-level resource. |
65///
66/// Having both set is spec-illegal. Use [`OperationTarget::is_valid`] to check,
67/// or [`AtomicRequest::validate_lid_refs`](crate::AtomicRequest::validate_lid_refs)
68/// which also flags this case along with `lid` reference errors.
69#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
70pub struct OperationTarget {
71    /// Structured reference to the target resource.
72    #[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
73    pub r#ref: Option<OperationRef>,
74
75    /// URL pointer to the target (alternative to `ref`).
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub href: Option<String>,
78}
79
80impl OperationTarget {
81    /// Returns `false` if both `ref` and `href` are set (spec violation).
82    #[must_use]
83    pub fn is_valid(&self) -> bool {
84        !(self.r#ref.is_some() && self.href.is_some())
85    }
86}
87
88/// Structured reference to the target of an atomic operation.
89///
90/// Reuses [`Identity`](crate::Identity) for id/lid consistency with
91/// [`ResourceIdentifier`](crate::ResourceIdentifier). An optional
92/// `relationship` name narrows the operation to a specific relationship
93/// of the referenced resource.
94#[derive(Debug, Clone, PartialEq)]
95pub struct OperationRef {
96    /// JSON:API type string.
97    pub type_: String,
98    /// Server-assigned id or client-local lid.
99    pub identity: Identity,
100    /// Relationship name, if this op targets a relationship rather than the resource itself.
101    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    /// Check that every `lid` referenced by an operation is introduced by a
167    /// strictly earlier `add` operation, that no `lid` is introduced twice,
168    /// and that no operation targets both `ref` and `href`.
169    ///
170    /// Returns `Error::InvalidAtomicOperation` at the first offending index.
171    ///
172    /// Serialization and deserialization of `AtomicRequest` remain infallible
173    /// with respect to `lid` semantics; pre-flight validation is opt-in.
174    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    /// Borrow the operation's target (present on all variants).
217    fn target(&self) -> &OperationTarget {
218        match self {
219            AtomicOperation::Add { target, .. }
220            | AtomicOperation::Update { target, .. }
221            | AtomicOperation::Remove { target } => target,
222        }
223    }
224}
225
226/// Iterate over the resources inside a `PrimaryData<Resource>`, skipping `Null`.
227fn 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        // Spec requires `atomic:operations`. Missing it must fail.
611        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}