Skip to content

Commit 9fc805e

Browse files
committed
atomic operations: requests schemas
1 parent b62f470 commit 9fc805e

File tree

4 files changed

+222
-0
lines changed

4 files changed

+222
-0
lines changed

fastapi_jsonapi/atomic/__init__.py

Whitespace-only changes.

fastapi_jsonapi/atomic/schemas.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
from typing import List, Literal, Optional, Union
2+
3+
from pydantic import Field, root_validator
4+
from starlette.datastructures import URLPath
5+
6+
from fastapi_jsonapi.schema_base import BaseModel
7+
8+
9+
class OperationRelationshipSchema(BaseModel):
10+
id: str = Field(default=..., description="Related object ID")
11+
type: str = Field(default=..., description="Type of the related resource object")
12+
13+
14+
class OperationItemInSchema(BaseModel):
15+
"""
16+
add/update
17+
"""
18+
19+
type: str = Field(default=..., description="Resource type")
20+
id: Optional[str] = Field(default=None, description="Resource object ID")
21+
attributes: Optional[dict] = Field(None, description="Resource object attributes")
22+
relationships: Optional[dict] = Field(None, description="Resource object relationships")
23+
24+
25+
class AtomicOperationRef(BaseModel):
26+
"""
27+
28+
ref: an object that MUST contain one of the following combinations of members:
29+
type and id: to target an individual resource.
30+
type and lid: to target an individual resource
31+
that has been assigned a local identity (lid) in a prior operation object.
32+
type, id, and relationship: to target the relationship of an individual resource.
33+
type, lid, and relationship: to target the relationship of an individual resource
34+
that has been assigned a local identity (lid) in a prior operation object.
35+
"""
36+
37+
type: str = Field(default=...)
38+
id: Optional[str] = Field(default=None)
39+
lid: Optional[str] = Field(default=None)
40+
relationship: Optional[str] = Field(default=None)
41+
42+
@root_validator
43+
def validate_op_ref(cls, values: dict):
44+
"""
45+
type is required on schema
46+
so id or lid has to be present
47+
48+
:param values:
49+
:return:
50+
"""
51+
if (
52+
# XOR
53+
bool(values.get("lid"))
54+
!= bool(values.get("id"))
55+
):
56+
# if one of id/lid is present, ref is ok
57+
return values
58+
59+
msg = (
60+
"invalid operation ref. has to be one of:\n"
61+
"- (type, id)\n"
62+
"- (type, lid)\n"
63+
"- (type, id, relationship)\n"
64+
"- (type, lid, relationship)"
65+
)
66+
# TODO: pydantic V2
67+
raise ValueError(msg)
68+
69+
70+
class AtomicOperation(BaseModel):
71+
"""
72+
An operation object MUST contain the following member: op
73+
74+
An operation object MAY contain either of the following members: (ref, href),
75+
but not both, to specify the target of the operation:
76+
77+
An operation object MAY also contain any of the following members: (data, meta),
78+
data: the operation’s “primary data”.
79+
meta: a meta object that contains non-standard meta-information about the operation.
80+
81+
https://jsonapi.org/ext/atomic/#operation-objects
82+
"""
83+
84+
op: Literal["add", "update", "remove"] = Field(
85+
default=...,
86+
description="an operation code, expressed as a string, that indicates the type of operation to perform.",
87+
)
88+
ref: Optional[AtomicOperationRef] = Field(default=None)
89+
href: Optional[URLPath] = Field(
90+
default=None,
91+
description="a string that contains a URI-reference that identifies the target of the operation.",
92+
)
93+
94+
data: Union[
95+
# from biggest to smallest!
96+
# any object creation
97+
OperationItemInSchema,
98+
# to-many relationship
99+
List[OperationRelationshipSchema],
100+
# to-one relationship
101+
OperationRelationshipSchema,
102+
# not required
103+
None,
104+
] = Field(default=None, description="the operation’s “primary data”.")
105+
106+
meta: Optional[dict] = Field(
107+
default=None,
108+
description="a meta object that contains non-standard meta-information about the operation",
109+
)
110+
111+
@root_validator
112+
def validate_operation(cls, values: dict):
113+
"""
114+
An operation object MAY contain either of the following members,
115+
but not both, to specify the target of the operation: (ref, href)
116+
117+
:param values:
118+
:return:
119+
"""
120+
ref = values.get("ref")
121+
href = values.get("href")
122+
if not ref and not href:
123+
# if no one is passed, it's OK
124+
return values
125+
126+
# XOR
127+
if bool(ref) != bool(href):
128+
# if one of ref/href is present, it's ok
129+
return values
130+
131+
msg = (
132+
"An operation object MAY contain either of the following members,"
133+
"but not both, to specify the target of the operation (ref, href)"
134+
)
135+
# TODO: pydantic V2
136+
raise ValueError(msg)
137+
138+
139+
class AtomicOperationRequest(BaseModel):
140+
operations: List[AtomicOperation] = Field(alias="atomic:operations")

tests/test_atomic/__init__.py

Whitespace-only changes.

tests/test_atomic/test_request.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import pytest
2+
3+
from fastapi_jsonapi.atomic.schemas import AtomicOperationRequest
4+
5+
6+
class TestAtomicOperationRequest:
7+
@pytest.mark.parametrize(
8+
"operation_request",
9+
[
10+
{
11+
"atomic:operations": [
12+
{
13+
"op": "add",
14+
"href": "/blogPosts",
15+
"data": {
16+
"type": "articles",
17+
"attributes": {
18+
"title": "JSON API paints my bikeshed!",
19+
},
20+
},
21+
},
22+
],
23+
},
24+
{
25+
"atomic:operations": [
26+
{
27+
"op": "update",
28+
"data": {
29+
"type": "articles",
30+
"id": "13",
31+
"attributes": {"title": "To TDD or Not"},
32+
},
33+
},
34+
],
35+
},
36+
{
37+
"atomic:operations": [
38+
{
39+
"op": "remove",
40+
"ref": {
41+
"type": "articles",
42+
"id": "13",
43+
},
44+
},
45+
],
46+
},
47+
{
48+
# the following request assigns a to-one relationship:
49+
"atomic:operations": [
50+
{
51+
"op": "update",
52+
"ref": {
53+
"type": "articles",
54+
"id": "13",
55+
"relationship": "author",
56+
},
57+
"data": {
58+
"type": "people",
59+
"id": "9",
60+
},
61+
},
62+
],
63+
},
64+
{
65+
# the following request clears a to-one relationship
66+
"atomic:operations": [
67+
{
68+
"op": "update",
69+
"ref": {
70+
"type": "articles",
71+
"id": "13",
72+
"relationship": "author",
73+
},
74+
"data": None,
75+
},
76+
],
77+
},
78+
],
79+
)
80+
def test_request_data(self, operation_request: dict):
81+
validated = AtomicOperationRequest.parse_obj(operation_request)
82+
assert validated.dict(exclude_unset=True, by_alias=True) == operation_request

0 commit comments

Comments
 (0)