Skip to content

Commit eabb8f4

Browse files
committed
atomic operations refactoring, new coverage
1 parent 1e1fa3b commit eabb8f4

File tree

3 files changed

+123
-49
lines changed

3 files changed

+123
-49
lines changed

fastapi_jsonapi/atomic/atomic_handler.py

Lines changed: 78 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22

33
import logging
44
from collections import defaultdict
5+
from functools import wraps
56
from typing import (
67
TYPE_CHECKING,
78
Any,
9+
Awaitable,
10+
Callable,
811
Dict,
912
List,
1013
Optional,
@@ -18,7 +21,7 @@
1821

1922
from fastapi_jsonapi import RoutersJSONAPI
2023
from fastapi_jsonapi.atomic.prepared_atomic_operation import LocalIdsType, OperationBase
21-
from fastapi_jsonapi.atomic.schemas import AtomicOperationRequest, AtomicResultResponse
24+
from fastapi_jsonapi.atomic.schemas import AtomicOperation, AtomicOperationRequest, AtomicResultResponse
2225
from fastapi_jsonapi.utils.dependency_helper import DependencyHelper
2326
from fastapi_jsonapi.views.utils import HTTPMethodConfig
2427

@@ -29,6 +32,37 @@
2932
AtomicResponseDict = TypedDict("AtomicResponseDict", {"atomic:results": List[Any]})
3033

3134

35+
def catch_exc_on_operation_handle(func: Callable[..., Awaitable]):
36+
@wraps(func)
37+
async def wrapper(*a, operation: OperationBase, **kw):
38+
try:
39+
return await func(*a, operation=operation, **kw)
40+
except (ValidationError, ValueError) as ex:
41+
log.exception(
42+
"Validation error on atomic action ref=%s, data=%s",
43+
operation.ref,
44+
operation.data,
45+
)
46+
errors_details = {
47+
"message": f"Validation error on operation {operation.op_type}",
48+
"ref": operation.ref,
49+
"data": operation.data.dict(),
50+
}
51+
if isinstance(ex, ValidationError):
52+
errors_details.update(errors=ex.errors())
53+
elif isinstance(ex, ValueError):
54+
errors_details.update(error=str(ex))
55+
else:
56+
raise
57+
# TODO: json:api exception
58+
raise HTTPException(
59+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
60+
detail=errors_details,
61+
)
62+
63+
return wrapper
64+
65+
3266
class AtomicViewHandler:
3367
def __init__(
3468
self,
@@ -37,6 +71,7 @@ def __init__(
3771
):
3872
self.request = request
3973
self.operations_request = operations_request
74+
self.local_ids_cache: LocalIdsType = defaultdict(dict)
4075

4176
async def handle_view_dependencies(
4277
self,
@@ -55,68 +90,64 @@ def handle_dependencies(**dep_kwargs):
5590
dependencies_result: Dict[str, Any] = await DependencyHelper(request=self.request).run(handle_dependencies)
5691
return dependencies_result
5792

93+
async def prepare_one_operation(self, operation: AtomicOperation):
94+
"""
95+
:param operation:
96+
:return:
97+
"""
98+
operation_type = operation.ref and operation.ref.type or operation.data and operation.data.type
99+
assert operation_type
100+
if operation_type not in RoutersJSONAPI.all_jsonapi_routers:
101+
msg = f"Unknown resource type {operation_type!r}. Register it via RoutersJSONAPI"
102+
raise ValueError(msg)
103+
jsonapi = RoutersJSONAPI.all_jsonapi_routers[operation_type]
104+
105+
dependencies_result: Dict[str, Any] = await self.handle_view_dependencies(
106+
jsonapi=jsonapi,
107+
)
108+
one_operation = OperationBase.prepare(
109+
action=operation.op,
110+
request=self.request,
111+
jsonapi=jsonapi,
112+
ref=operation.ref,
113+
data=operation.data,
114+
data_layer_view_dependencies=dependencies_result,
115+
)
116+
return one_operation
117+
58118
async def prepare_operations(self) -> List[OperationBase]:
59119
prepared_operations: List[OperationBase] = []
60120

61121
for operation in self.operations_request.operations:
62-
operation_type = operation.ref and operation.ref.type or operation.data and operation.data.type
63-
assert operation_type
64-
jsonapi = RoutersJSONAPI.all_jsonapi_routers[operation_type]
65-
66-
dependencies_result: Dict[str, Any] = await self.handle_view_dependencies(
67-
jsonapi=jsonapi,
68-
)
69-
one_operation = OperationBase.prepare(
70-
action=operation.op,
71-
request=self.request,
72-
jsonapi=jsonapi,
73-
ref=operation.ref,
74-
data=operation.data,
75-
data_layer_view_dependencies=dependencies_result,
76-
)
122+
one_operation = await self.prepare_one_operation(operation)
77123
prepared_operations.append(one_operation)
78124

79125
return prepared_operations
80126

127+
@catch_exc_on_operation_handle
128+
async def process_one_operation(
129+
self,
130+
dl: BaseDataLayer,
131+
operation: OperationBase,
132+
):
133+
operation.update_relationships_with_lid(local_ids=self.local_ids_cache)
134+
return await operation.handle(dl=dl)
135+
81136
async def handle(self) -> Union[AtomicResponseDict, AtomicResultResponse, None]:
82137
prepared_operations = await self.prepare_operations()
83138
results = []
84-
85-
# TODO: try/except, catch schema ValidationError
86-
87139
only_empty_responses = True
88-
local_ids_cache: LocalIdsType = defaultdict(dict)
89140
success = True
90141
previous_dl: Optional[BaseDataLayer] = None
91-
for idx, operation in enumerate(prepared_operations, start=1):
142+
for operation in prepared_operations:
92143
dl: BaseDataLayer = await operation.get_data_layer()
93144
await dl.atomic_start(previous_dl=previous_dl)
145+
response = await self.process_one_operation(
146+
dl=dl,
147+
operation=operation,
148+
)
94149
previous_dl = dl
95-
try:
96-
operation.update_relationships_with_lid(local_ids=local_ids_cache)
97-
response = await operation.handle(dl=dl)
98-
except (ValidationError, ValueError) as ex:
99-
log.exception(
100-
"Validation error on atomic action ref=%s, data=%s",
101-
operation.ref,
102-
operation.data,
103-
)
104-
errors_details = {
105-
"message": f"Validation error on operation #{idx}",
106-
"ref": operation.ref,
107-
"data": operation.data.dict(),
108-
}
109-
if isinstance(ex, ValidationError):
110-
errors_details.update(errors=ex.errors())
111-
elif isinstance(ex, ValueError):
112-
errors_details.update(error=str(ex))
113-
else:
114-
raise
115-
# TODO: json:api exception
116-
raise HTTPException(
117-
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
118-
detail=errors_details,
119-
)
150+
120151
# response.data.id
121152
if not response:
122153
# https://jsonapi.org/ext/atomic/#result-objects
@@ -127,7 +158,7 @@ async def handle(self) -> Union[AtomicResponseDict, AtomicResultResponse, None]:
127158
only_empty_responses = False
128159
results.append({"data": response.data})
129160
if operation.data.lid and response.data:
130-
local_ids_cache[operation.data.type][operation.data.lid] = response.data.id
161+
self.local_ids_cache[operation.data.type][operation.data.lid] = response.data.id
131162

132163
if previous_dl:
133164
await previous_dl.atomic_end(success=success)

fastapi_jsonapi/atomic/prepared_atomic_operation.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class OperationBase:
2424
ref: Optional[AtomicOperationRef]
2525
data: OperationDataType
2626
data_layer_view_dependencies: Dict[str, Any]
27+
op_type: str
2728

2829
@classmethod
2930
def prepare(
@@ -56,6 +57,7 @@ def prepare(
5657
ref=ref,
5758
data=data,
5859
data_layer_view_dependencies=data_layer_view_dependencies,
60+
op_type=action,
5961
)
6062

6163
async def get_data_layer(self) -> BaseDataLayer:

tests/test_atomic/test_create_objects.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -680,7 +680,7 @@ async def test_resource_type_with_local_id_not_found(
680680
"lid": None,
681681
},
682682
"error": expected_error_text,
683-
"message": "Validation error on operation #2",
683+
"message": f"Validation error on operation {action_1['op']}",
684684
"ref": None,
685685
},
686686
}
@@ -780,7 +780,7 @@ async def test_local_id_not_found(
780780
"lid": None,
781781
},
782782
"error": expected_error_text,
783-
"message": "Validation error on operation #2",
783+
"message": f"Validation error on operation {action_2['op']}",
784784
"ref": None,
785785
},
786786
}
@@ -897,6 +897,47 @@ async def test_create_and_associate_many_to_many(
897897
],
898898
}
899899

900+
async def test_create_object_schema_validation_error(
901+
self,
902+
client: AsyncClient,
903+
):
904+
action_add = {
905+
"op": "add",
906+
"data": {
907+
"type": "user",
908+
# not passing the required `name` attribute
909+
"attributes": {},
910+
},
911+
}
912+
data_atomic_request = {
913+
"atomic:operations": [
914+
action_add,
915+
],
916+
}
917+
918+
response = await client.post("/operations", json=data_atomic_request)
919+
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY, response.text
920+
# TODO: json:api exception
921+
assert response.json() == {
922+
"detail": {
923+
"data": {
924+
**action_add["data"],
925+
"id": None,
926+
"lid": None,
927+
"relationships": None,
928+
},
929+
"errors": [
930+
{
931+
"loc": ["data", "attributes", "name"],
932+
"msg": "field required",
933+
"type": "value_error.missing",
934+
},
935+
],
936+
"message": f"Validation error on operation {action_add['op']}",
937+
"ref": None,
938+
},
939+
}
940+
900941
@pytest.mark.skip("not ready yet")
901942
async def test_update_to_many_relationship_with_local_id(
902943
self,

0 commit comments

Comments
 (0)