Skip to content

Commit 6014571

Browse files
committed
refactor atomic handler, create separate operation types, use enum for op action validation
1 parent 636dc55 commit 6014571

File tree

6 files changed

+235
-84
lines changed

6 files changed

+235
-84
lines changed
Lines changed: 29 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,25 @@
1-
from typing import Any, Dict, List, Optional, Type, TypedDict, Union
1+
from __future__ import annotations
2+
3+
from typing import (
4+
TYPE_CHECKING,
5+
Any,
6+
Dict,
7+
List,
8+
Optional,
9+
TypedDict,
10+
Union,
11+
)
212

313
from starlette.requests import Request
414

515
from fastapi_jsonapi import RoutersJSONAPI
6-
from fastapi_jsonapi.atomic.prepared_atomic_operation import PreparedOperation
16+
from fastapi_jsonapi.atomic.prepared_atomic_operation import OperationBase
717
from fastapi_jsonapi.atomic.schemas import AtomicOperationRequest, AtomicResultResponse
8-
from fastapi_jsonapi.data_layers.base import BaseDataLayer
918
from fastapi_jsonapi.utils.dependency_helper import DependencyHelper
10-
from fastapi_jsonapi.views.detail_view import DetailViewBase
11-
from fastapi_jsonapi.views.list_view import ListViewBase
1219
from fastapi_jsonapi.views.utils import HTTPMethodConfig
13-
from fastapi_jsonapi.views.view_base import ViewBase
20+
21+
if TYPE_CHECKING:
22+
from fastapi_jsonapi.data_layers.base import BaseDataLayer
1423

1524
AtomicResponseDict = TypedDict("AtomicResponseDict", {"atomic:results": List[Any]})
1625

@@ -26,7 +35,6 @@ def __init__(
2635

2736
async def handle_view_dependencies(
2837
self,
29-
request: Request,
3038
jsonapi: RoutersJSONAPI,
3139
) -> Dict[str, Any]:
3240
method_config: HTTPMethodConfig = jsonapi.get_method_config_for_create()
@@ -39,30 +47,24 @@ def handle_dependencies(**dep_kwargs):
3947
method_config=method_config,
4048
)
4149

42-
dependencies_result: Dict[str, Any] = await DependencyHelper(request=request).run(handle_dependencies)
50+
dependencies_result: Dict[str, Any] = await DependencyHelper(request=self.request).run(handle_dependencies)
4351
return dependencies_result
4452

45-
async def prepare_operations(self) -> List[PreparedOperation]:
46-
prepared_operations: List[PreparedOperation] = []
53+
async def prepare_operations(self) -> List[OperationBase]:
54+
prepared_operations: List[OperationBase] = []
4755

4856
for operation in self.operations_request.operations:
4957
jsonapi = RoutersJSONAPI.all_jsonapi_routers[operation.data.type]
50-
view_cls: Type["ViewBase"] = jsonapi.detail_view_resource
51-
if operation.op == "add":
52-
view_cls = jsonapi.list_view_resource
53-
view = view_cls(request=self.request, jsonapi=jsonapi)
58+
5459
dependencies_result: Dict[str, Any] = await self.handle_view_dependencies(
55-
request=self.request,
5660
jsonapi=jsonapi,
5761
)
58-
dl: "BaseDataLayer" = await view.get_data_layer(dependencies_result)
59-
60-
one_operation = PreparedOperation(
62+
one_operation = OperationBase.prepare(
6163
action=operation.op,
62-
data_layer=dl,
63-
view=view,
64+
request=self.request,
6465
jsonapi=jsonapi,
6566
data=operation.data,
67+
data_layer_view_dependencies=dependencies_result,
6668
)
6769
prepared_operations.append(one_operation)
6870

@@ -74,42 +76,17 @@ async def handle(self) -> Union[AtomicResponseDict, AtomicResultResponse]:
7476

7577
# TODO: try/except, catch schema ValidationError
7678

77-
previous_dl: Optional["BaseDataLayer"] = None
79+
success = True
80+
previous_dl: Optional[BaseDataLayer] = None
7881
for operation in prepared_operations:
79-
dl = operation.data_layer
82+
dl: BaseDataLayer = await operation.get_data_layer()
8083
await dl.atomic_start(previous_dl=previous_dl)
8184
previous_dl = dl
82-
if operation.action == "add":
83-
data_in = operation.jsonapi.schema_in_post(data=operation.data)
84-
assert isinstance(operation.view, ListViewBase)
85-
view: "ListViewBase" = operation.view
86-
response = await view.process_create_object(dl=operation.data_layer, data_create=data_in.data)
87-
# response.data.id
88-
results.append({"data": response.data})
89-
elif operation.action == "update":
90-
data_in = operation.jsonapi.schema_in_patch(data=operation.data)
91-
assert isinstance(operation.view, DetailViewBase)
92-
view: "DetailViewBase" = operation.view
93-
response = await view.process_update_object(
94-
dl=dl,
95-
obj_id=data_in.data.id,
96-
data_update=data_in.data,
97-
)
98-
# response.data.id
99-
results.append({"data": response.data})
100-
elif operation.action == "remove":
101-
assert isinstance(operation.view, DetailViewBase)
102-
view: "DetailViewBase" = operation.view
103-
response = await view.process_delete_object(
104-
dl=dl,
105-
obj_id=operation.data.id,
106-
)
107-
results.append({"data": response.data})
108-
else:
109-
msg = f"unknown action {operation.action!r}"
110-
raise ValueError(msg)
85+
response = await operation.handle(dl=dl)
86+
# response.data.id
87+
results.append({"data": response.data})
11188

11289
if previous_dl:
113-
await previous_dl.atomic_end(success=True)
90+
await previous_dl.atomic_end(success=success)
11491

11592
return {"atomic:results": results}
Lines changed: 94 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,101 @@
1+
from __future__ import annotations
2+
13
from dataclasses import dataclass
2-
from typing import List, Literal, Union
4+
from typing import TYPE_CHECKING, Any, Dict, Type
5+
6+
from fastapi import Request
37

48
from fastapi_jsonapi import RoutersJSONAPI
5-
from fastapi_jsonapi.atomic.schemas import OperationItemInSchema, OperationRelationshipSchema
6-
from fastapi_jsonapi.data_layers.base import BaseDataLayer
7-
from fastapi_jsonapi.views.view_base import ViewBase
9+
from fastapi_jsonapi.atomic.schemas import AtomicOperationAction, OperationDataType
10+
11+
if TYPE_CHECKING:
12+
from fastapi_jsonapi.data_layers.base import BaseDataLayer
13+
from fastapi_jsonapi.views.detail_view import DetailViewBase
14+
from fastapi_jsonapi.views.list_view import ListViewBase
15+
from fastapi_jsonapi.views.view_base import ViewBase
816

917

1018
@dataclass
11-
class PreparedOperation:
12-
action: Literal["add", "update", "remove"]
13-
data_layer: "BaseDataLayer"
14-
view: "ViewBase"
19+
class OperationBase:
1520
jsonapi: RoutersJSONAPI
16-
data: Union[
17-
# from biggest to smallest!
18-
# any object creation
19-
OperationItemInSchema,
20-
# to-many relationship
21-
List[OperationRelationshipSchema],
22-
# to-one relationship
23-
OperationRelationshipSchema,
24-
# not required
25-
None,
26-
] = None
21+
view: ViewBase
22+
data: OperationDataType
23+
data_layer_view_dependencies: Dict[str, Any]
24+
25+
@classmethod
26+
def prepare(
27+
cls,
28+
action: str,
29+
request: Request,
30+
jsonapi: RoutersJSONAPI,
31+
data: OperationDataType,
32+
data_layer_view_dependencies: Dict[str, Any],
33+
) -> "OperationBase":
34+
view_cls: Type[ViewBase] = jsonapi.detail_view_resource
35+
36+
if action == AtomicOperationAction.add:
37+
operation_cls = OperationAdd
38+
view_cls = jsonapi.list_view_resource
39+
elif action == AtomicOperationAction.update:
40+
operation_cls = OperationUpdate
41+
elif action == AtomicOperationAction.remove:
42+
operation_cls = OperationRemove
43+
else:
44+
msg = f"Unknown operation {action!r}"
45+
raise ValueError(msg)
46+
47+
view = view_cls(request=request, jsonapi=jsonapi)
48+
49+
return operation_cls(
50+
jsonapi=jsonapi,
51+
view=view,
52+
data=data,
53+
data_layer_view_dependencies=data_layer_view_dependencies,
54+
)
55+
56+
async def get_data_layer(self) -> BaseDataLayer:
57+
return await self.view.get_data_layer(self.data_layer_view_dependencies)
58+
59+
async def handle(self, dl: BaseDataLayer):
60+
raise NotImplementedError
61+
62+
63+
class ListOperationBase(OperationBase):
64+
view: ListViewBase
65+
66+
67+
class DetailOperationBase(OperationBase):
68+
view: DetailViewBase
69+
70+
71+
class OperationAdd(ListOperationBase):
72+
async def handle(self, dl: BaseDataLayer):
73+
data_in = self.jsonapi.schema_in_post(data=self.data)
74+
response = await self.view.process_create_object(
75+
dl=dl,
76+
data_create=data_in.data,
77+
)
78+
return response
79+
80+
81+
class OperationUpdate(DetailOperationBase):
82+
async def handle(self, dl: BaseDataLayer):
83+
data_in = self.jsonapi.schema_in_patch(data=self.data)
84+
response = await self.view.process_update_object(
85+
dl=dl,
86+
obj_id=data_in.data.id,
87+
data_update=data_in.data,
88+
)
89+
return response
90+
91+
92+
class OperationRemove(DetailOperationBase):
93+
async def handle(
94+
self,
95+
dl: BaseDataLayer,
96+
):
97+
response = await self.view.process_delete_object(
98+
dl=dl,
99+
obj_id=self.data.id,
100+
)
101+
return response

fastapi_jsonapi/atomic/schemas.py

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
from typing import List, Literal, Optional, Union
1+
from __future__ import annotations
2+
3+
from enum import Enum
4+
from typing import List, Optional, Union
25

36
from pydantic import BaseModel, Field, root_validator
47
from starlette.datastructures import URLPath
@@ -20,6 +23,19 @@ class OperationItemInSchema(BaseModel):
2023
relationships: Optional[dict] = Field(None, description="Resource object relationships")
2124

2225

26+
OperationDataType = Union[
27+
# from biggest to smallest!
28+
# any object creation
29+
OperationItemInSchema,
30+
# to-many relationship
31+
List[OperationRelationshipSchema],
32+
# to-one relationship
33+
OperationRelationshipSchema,
34+
# not required
35+
None,
36+
]
37+
38+
2339
class AtomicOperationRef(BaseModel):
2440
"""
2541
@@ -65,6 +81,12 @@ def validate_atomic_operation_ref(cls, values: dict):
6581
raise ValueError(msg)
6682

6783

84+
class AtomicOperationAction(str, Enum):
85+
add = "add"
86+
update = "update"
87+
remove = "remove"
88+
89+
6890
class AtomicOperation(BaseModel):
6991
"""
7092
An operation object MUST contain the following member: op
@@ -79,7 +101,7 @@ class AtomicOperation(BaseModel):
79101
https://jsonapi.org/ext/atomic/#operation-objects
80102
"""
81103

82-
op: Literal["add", "update", "remove"] = Field(
104+
op: AtomicOperationAction = Field(
83105
default=...,
84106
description="an operation code, expressed as a string, that indicates the type of operation to perform.",
85107
)
@@ -89,17 +111,10 @@ class AtomicOperation(BaseModel):
89111
description="a string that contains a URI-reference that identifies the target of the operation.",
90112
)
91113

92-
data: Union[
93-
# from biggest to smallest!
94-
# any object creation
95-
OperationItemInSchema,
96-
# to-many relationship
97-
List[OperationRelationshipSchema],
98-
# to-one relationship
99-
OperationRelationshipSchema,
100-
# not required
101-
None,
102-
] = Field(default=None, description="the operation’s “primary data”.")
114+
data: OperationDataType = Field(
115+
default=None,
116+
description="the operation’s “primary data”.",
117+
)
103118

104119
meta: Optional[dict] = Field(
105120
default=None,

tests/test_atomic/conftest.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
5+
from fastapi_jsonapi.atomic.schemas import AtomicOperationAction
6+
7+
8+
@pytest.fixture()
9+
def allowed_atomic_actions_list() -> list[str]:
10+
return [op.value for op in AtomicOperationAction]
11+
12+
13+
@pytest.fixture()
14+
def allowed_atomic_actions_as_string(allowed_atomic_actions_list) -> str:
15+
return ", ".join(repr(op) for op in allowed_atomic_actions_list)

tests/test_atomic/test_mixed_atomic.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,45 @@
1717

1818

1919
class TestAtomicMixedActions:
20+
async def test_schema_validation_error(
21+
self,
22+
client: AsyncClient,
23+
allowed_atomic_actions_list: list[str],
24+
allowed_atomic_actions_as_string: str,
25+
):
26+
operation_name = fake.word()
27+
atomic_request_data = {
28+
"atomic:operations": [
29+
{
30+
"op": operation_name,
31+
"href": "/any",
32+
"data": {
33+
"type": "any",
34+
"attributes": {
35+
"any": "any",
36+
},
37+
},
38+
},
39+
],
40+
}
41+
response = await client.post("/operations", json=atomic_request_data)
42+
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY, response.text
43+
response_data = response.json()
44+
45+
assert response_data == {
46+
# TODO: jsonapi exception?
47+
"detail": [
48+
{
49+
"loc": ["body", "atomic:operations", 0, "op"],
50+
"msg": f"value is not a valid enumeration member; permitted: {allowed_atomic_actions_as_string}",
51+
"type": "type_error.enum",
52+
"ctx": {
53+
"enum_values": allowed_atomic_actions_list,
54+
},
55+
},
56+
],
57+
}
58+
2059
async def test_create_and_update_atomic_success(
2160
self,
2261
client: AsyncClient,

0 commit comments

Comments
 (0)