django-ninja-jsonapi 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. django_ninja_jsonapi/VERSION +1 -0
  2. django_ninja_jsonapi/__init__.py +34 -0
  3. django_ninja_jsonapi/api/__init__.py +0 -0
  4. django_ninja_jsonapi/api/application_builder.py +216 -0
  5. django_ninja_jsonapi/api/endpoint_builder.py +117 -0
  6. django_ninja_jsonapi/api/schemas.py +26 -0
  7. django_ninja_jsonapi/atomic/__init__.py +7 -0
  8. django_ninja_jsonapi/atomic/atomic.py +49 -0
  9. django_ninja_jsonapi/atomic/atomic_handler.py +176 -0
  10. django_ninja_jsonapi/atomic/prepared_atomic_operation.py +342 -0
  11. django_ninja_jsonapi/atomic/schemas.py +220 -0
  12. django_ninja_jsonapi/common.py +16 -0
  13. django_ninja_jsonapi/data_layers/__init__.py +0 -0
  14. django_ninja_jsonapi/data_layers/base.py +504 -0
  15. django_ninja_jsonapi/data_layers/django_orm/__init__.py +3 -0
  16. django_ninja_jsonapi/data_layers/django_orm/base_model.py +44 -0
  17. django_ninja_jsonapi/data_layers/django_orm/orm.py +469 -0
  18. django_ninja_jsonapi/data_layers/django_orm/query_building.py +84 -0
  19. django_ninja_jsonapi/data_layers/fields/__init__.py +1 -0
  20. django_ninja_jsonapi/data_layers/fields/enums.py +11 -0
  21. django_ninja_jsonapi/data_layers/fields/mixins.py +33 -0
  22. django_ninja_jsonapi/data_typing.py +6 -0
  23. django_ninja_jsonapi/exceptions/__init__.py +39 -0
  24. django_ninja_jsonapi/exceptions/base.py +29 -0
  25. django_ninja_jsonapi/exceptions/handlers.py +12 -0
  26. django_ninja_jsonapi/exceptions/json_api.py +192 -0
  27. django_ninja_jsonapi/generics.py +3 -0
  28. django_ninja_jsonapi/misc/__init__.py +0 -0
  29. django_ninja_jsonapi/misc/django_orm/__init__.py +0 -0
  30. django_ninja_jsonapi/misc/django_orm/generics/__init__.py +3 -0
  31. django_ninja_jsonapi/misc/django_orm/generics/base.py +6 -0
  32. django_ninja_jsonapi/misc/generics/__init__.py +3 -0
  33. django_ninja_jsonapi/misc/generics/base.py +3 -0
  34. django_ninja_jsonapi/py.typed +0 -0
  35. django_ninja_jsonapi/querystring.py +286 -0
  36. django_ninja_jsonapi/renderers.py +7 -0
  37. django_ninja_jsonapi/schema.py +274 -0
  38. django_ninja_jsonapi/schema_base.py +36 -0
  39. django_ninja_jsonapi/schema_builder.py +536 -0
  40. django_ninja_jsonapi/storages/__init__.py +9 -0
  41. django_ninja_jsonapi/storages/models_storage.py +105 -0
  42. django_ninja_jsonapi/storages/schemas_storage.py +191 -0
  43. django_ninja_jsonapi/storages/views_storage.py +30 -0
  44. django_ninja_jsonapi/types_metadata/__init__.py +7 -0
  45. django_ninja_jsonapi/types_metadata/client_can_set_id.py +7 -0
  46. django_ninja_jsonapi/types_metadata/relationship_info.py +11 -0
  47. django_ninja_jsonapi/utils/__init__.py +0 -0
  48. django_ninja_jsonapi/utils/exceptions.py +20 -0
  49. django_ninja_jsonapi/utils/metadata_instance_search.py +23 -0
  50. django_ninja_jsonapi/validation_utils.py +51 -0
  51. django_ninja_jsonapi/views/__init__.py +19 -0
  52. django_ninja_jsonapi/views/enums.py +32 -0
  53. django_ninja_jsonapi/views/schemas.py +22 -0
  54. django_ninja_jsonapi/views/view_base.py +676 -0
  55. django_ninja_jsonapi-0.2.0.dist-info/METADATA +174 -0
  56. django_ninja_jsonapi-0.2.0.dist-info/RECORD +57 -0
  57. django_ninja_jsonapi-0.2.0.dist-info/WHEEL +4 -0
@@ -0,0 +1 @@
1
+ 0.1.0
@@ -0,0 +1,34 @@
1
+ """JSON API utils package."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from django_ninja_jsonapi.exceptions import BadRequest
7
+ from django_ninja_jsonapi.exceptions.json_api import HTTPException
8
+ from django_ninja_jsonapi.querystring import QueryStringManager
9
+ from django_ninja_jsonapi.renderers import JSONAPIRenderer
10
+
11
+ __version__ = Path(__file__).parent.joinpath("VERSION").read_text().strip()
12
+
13
+ __all__ = [
14
+ "ApplicationBuilder",
15
+ "BadRequest",
16
+ "HTTPException",
17
+ "JSONAPIRenderer",
18
+ "QueryStringManager",
19
+ "ViewBaseGeneric",
20
+ ]
21
+
22
+
23
+ def __getattr__(name: str) -> Any:
24
+ if name == "ApplicationBuilder":
25
+ from django_ninja_jsonapi.api.application_builder import ApplicationBuilder
26
+
27
+ return ApplicationBuilder
28
+
29
+ if name == "ViewBaseGeneric":
30
+ from django_ninja_jsonapi.generics import ViewBaseGeneric
31
+
32
+ return ViewBaseGeneric
33
+
34
+ raise AttributeError(name)
File without changes
@@ -0,0 +1,216 @@
1
+ from http import HTTPStatus
2
+ from typing import Any, Callable, Iterable, Optional, Type
3
+
4
+ from ninja import NinjaAPI, Router
5
+ from pydantic import BaseModel
6
+
7
+ from django_ninja_jsonapi.api.endpoint_builder import EndpointsBuilder
8
+ from django_ninja_jsonapi.api.schemas import ResourceData
9
+ from django_ninja_jsonapi.atomic.atomic import AtomicOperations
10
+ from django_ninja_jsonapi.data_typing import TypeModel
11
+ from django_ninja_jsonapi.exceptions import HTTPException
12
+ from django_ninja_jsonapi.exceptions.handlers import base_exception_handler
13
+ from django_ninja_jsonapi.renderers import JSONAPIRenderer
14
+ from django_ninja_jsonapi.schema_builder import SchemaBuilder
15
+ from django_ninja_jsonapi.storages.models_storage import models_storage
16
+ from django_ninja_jsonapi.storages.schemas_storage import schemas_storage
17
+ from django_ninja_jsonapi.storages.views_storage import views_storage
18
+ from django_ninja_jsonapi.views.enums import Operation
19
+
20
+
21
+ class ApplicationBuilderError(Exception):
22
+ pass
23
+
24
+
25
+ class ApplicationBuilder:
26
+ def __init__(
27
+ self,
28
+ api: NinjaAPI,
29
+ base_router: Optional[Router] = None,
30
+ exception_handler: Optional[Callable] = None,
31
+ **base_router_include_kwargs,
32
+ ):
33
+ self._api = api
34
+ self._base_router = base_router or Router()
35
+ self._base_router_include_kwargs = base_router_include_kwargs
36
+ self._routers: dict[str, Router] = {}
37
+ self._router_include_kwargs: dict[str, dict[str, Any]] = {}
38
+ self._resource_data: dict[str, ResourceData] = {}
39
+ self._exception_handler: Callable = exception_handler or base_exception_handler
40
+ self._initialized = False
41
+ self._api.renderer = JSONAPIRenderer()
42
+
43
+ def add_resource(
44
+ self,
45
+ path: str,
46
+ tags: Iterable[str],
47
+ resource_type: str,
48
+ view: Type[Any],
49
+ model: Type[TypeModel],
50
+ schema: Type[BaseModel],
51
+ router: Optional[Router] = None,
52
+ schema_in_post: Optional[Type[BaseModel]] = None,
53
+ schema_in_patch: Optional[Type[BaseModel]] = None,
54
+ pagination_default_size: Optional[int] = 25,
55
+ pagination_default_number: Optional[int] = 1,
56
+ pagination_default_offset: Optional[int] = None,
57
+ pagination_default_limit: Optional[int] = None,
58
+ operations: Iterable[Operation] = (),
59
+ ending_slash: bool = True,
60
+ model_id_field_name: str = "id",
61
+ include_router_kwargs: Optional[dict] = None,
62
+ ):
63
+ if self._initialized:
64
+ raise ApplicationBuilderError("Can't add resource after app initialization")
65
+
66
+ if resource_type in self._resource_data:
67
+ raise ApplicationBuilderError(f"Resource {resource_type!r} already registered")
68
+
69
+ if include_router_kwargs is not None and router is None:
70
+ raise ApplicationBuilderError(
71
+ "The argument 'include_router_kwargs' is not allowed when 'router' is missing"
72
+ )
73
+
74
+ models_storage.add_model(resource_type, model, model_id_field_name, path)
75
+ views_storage.add_view(resource_type, view)
76
+
77
+ dto = SchemaBuilder(resource_type).create_schemas(
78
+ schema=schema,
79
+ schema_in_post=schema_in_post,
80
+ schema_in_patch=schema_in_patch,
81
+ )
82
+
83
+ resource_operations = list(operations) or Operation.real_operations()
84
+ if Operation.ALL in resource_operations:
85
+ resource_operations = Operation.real_operations()
86
+
87
+ self._resource_data[resource_type] = ResourceData(
88
+ path=path,
89
+ tags=list(tags),
90
+ view=view,
91
+ model=model,
92
+ source_schema=schema,
93
+ schema_in_post=schema_in_post,
94
+ schema_in_post_data=dto.schema_in_post_data,
95
+ schema_in_patch=schema_in_patch,
96
+ schema_in_patch_data=dto.schema_in_patch_data,
97
+ detail_response_schema=dto.detail_response_schema,
98
+ list_response_schema=dto.list_response_schema,
99
+ pagination_default_size=pagination_default_size,
100
+ pagination_default_number=pagination_default_number,
101
+ pagination_default_offset=pagination_default_offset,
102
+ pagination_default_limit=pagination_default_limit,
103
+ operations=resource_operations,
104
+ ending_slash=ending_slash,
105
+ )
106
+
107
+ resolved_router = router or self._base_router
108
+ self._routers[resource_type] = resolved_router
109
+ self._router_include_kwargs[resource_type] = include_router_kwargs or {}
110
+
111
+ def initialize(self) -> NinjaAPI:
112
+ if self._initialized:
113
+ raise ApplicationBuilderError("Application already initialized")
114
+
115
+ self._initialized = True
116
+ self._register_exception_handler()
117
+
118
+ for resource_type, data in self._resource_data.items():
119
+ builder = EndpointsBuilder(resource_type, data)
120
+ router = self._routers[resource_type]
121
+
122
+ for operation in data.operations:
123
+ name, endpoint = builder.create_common_ninja_endpoint(operation)
124
+ method = operation.http_method().lower()
125
+ path = self._create_path(
126
+ path=data.path,
127
+ include_object_id=operation in {Operation.GET, Operation.UPDATE, Operation.DELETE},
128
+ ending_slash=data.ending_slash,
129
+ )
130
+
131
+ response_schema = self._response_for(data, operation)
132
+ route = getattr(router, method)
133
+ route(
134
+ path,
135
+ response=response_schema,
136
+ tags=data.tags,
137
+ operation_id=name,
138
+ )(endpoint)
139
+
140
+ relationships_info = schemas_storage.get_relationships_info(
141
+ resource_type=resource_type,
142
+ operation_type="get",
143
+ )
144
+ for relationship_name, info in relationships_info.items():
145
+ if not views_storage.has_view(info.resource_type):
146
+ continue
147
+
148
+ operation = Operation.GET_LIST if info.many else Operation.GET
149
+ relationship_name_id, relationship_endpoint = builder.create_relationship_endpoint(
150
+ parent_resource_type=resource_type,
151
+ relationship_name=relationship_name,
152
+ operation=operation,
153
+ )
154
+ relationship_path = self._create_relationship_path(
155
+ resource_path=data.path,
156
+ relationship_name=relationship_name,
157
+ ending_slash=data.ending_slash,
158
+ )
159
+
160
+ related_data = self._resource_data.get(info.resource_type, data)
161
+ relationship_response = (
162
+ related_data.list_response_schema if info.many else related_data.detail_response_schema
163
+ )
164
+ getattr(router, operation.http_method().lower())(
165
+ relationship_path,
166
+ response=relationship_response,
167
+ tags=data.tags,
168
+ operation_id=relationship_name_id,
169
+ )(relationship_endpoint)
170
+
171
+ registered_routers = set()
172
+ for resource_type, router in self._routers.items():
173
+ if id(router) in registered_routers:
174
+ continue
175
+
176
+ include_kwargs = self._router_include_kwargs.get(resource_type, {})
177
+ if router is self._base_router:
178
+ include_kwargs = self._base_router_include_kwargs
179
+
180
+ self._api.add_router("", router, **include_kwargs)
181
+ registered_routers.add(id(router))
182
+
183
+ atomic = AtomicOperations()
184
+ self._api.add_router("", atomic.router)
185
+
186
+ return self._api
187
+
188
+ def _register_exception_handler(self):
189
+ add_handler = getattr(self._api, "add_exception_handler", None)
190
+ if callable(add_handler):
191
+ add_handler(HTTPException, self._exception_handler)
192
+
193
+ @staticmethod
194
+ def _response_for(data: ResourceData, operation: Operation):
195
+ if operation == Operation.DELETE:
196
+ return {HTTPStatus.NO_CONTENT: None}
197
+ if operation == Operation.GET_LIST:
198
+ return data.list_response_schema
199
+ return data.detail_response_schema
200
+
201
+ @staticmethod
202
+ def _create_path(path: str, include_object_id: bool, ending_slash: bool) -> str:
203
+ base_path = path.rstrip("/")
204
+ if include_object_id:
205
+ base_path = f"{base_path}/{{obj_id}}"
206
+ if ending_slash:
207
+ return f"{base_path}/"
208
+ return base_path
209
+
210
+ @staticmethod
211
+ def _create_relationship_path(resource_path: str, relationship_name: str, ending_slash: bool) -> str:
212
+ base_path = resource_path.rstrip("/")
213
+ path = f"{base_path}/{{obj_id}}/relationships/{relationship_name}"
214
+ if ending_slash:
215
+ return f"{path}/"
216
+ return path
@@ -0,0 +1,117 @@
1
+ import json
2
+ from typing import Any, Awaitable, Callable
3
+
4
+ from django.http import HttpRequest
5
+
6
+ from django_ninja_jsonapi.api.schemas import ResourceData
7
+ from django_ninja_jsonapi.views.enums import Operation
8
+
9
+
10
+ class EndpointsBuilder:
11
+ def __init__(self, resource_type: str, data: ResourceData):
12
+ self.resource_type = resource_type
13
+ self.data = data
14
+
15
+ def _build_view(self, request: HttpRequest, operation: Operation):
16
+ return self.data.view(
17
+ request=request,
18
+ resource_type=self.resource_type,
19
+ operation=operation,
20
+ model=self.data.model,
21
+ schema=self.data.source_schema,
22
+ )
23
+
24
+ @staticmethod
25
+ def _parse_json_body(request: HttpRequest) -> dict[str, Any]:
26
+ raw = request.body.decode() if request.body else "{}"
27
+ return json.loads(raw)
28
+
29
+ def create_common_ninja_endpoint(self, operation: Operation) -> tuple[str, Callable[..., Awaitable[Any]]]:
30
+ if operation == Operation.GET:
31
+ return self._create_get_detail()
32
+ if operation == Operation.GET_LIST:
33
+ return self._create_get_list()
34
+ if operation == Operation.CREATE:
35
+ return self._create_create()
36
+ if operation == Operation.UPDATE:
37
+ return self._create_update()
38
+ if operation == Operation.DELETE:
39
+ return self._create_delete()
40
+ if operation == Operation.DELETE_LIST:
41
+ return self._create_delete_list()
42
+
43
+ raise ValueError(f"Unsupported operation {operation!r}")
44
+
45
+ def create_relationship_endpoint(
46
+ self,
47
+ *,
48
+ parent_resource_type: str,
49
+ relationship_name: str,
50
+ operation: Operation,
51
+ ) -> tuple[str, Callable[..., Awaitable[Any]]]:
52
+ if operation == Operation.GET_LIST:
53
+
54
+ async def endpoint(request: HttpRequest, obj_id: str):
55
+ view = self._build_view(request, operation)
56
+ return await view.handle_get_resource_relationship_list(
57
+ obj_id=obj_id,
58
+ relationship_name=relationship_name,
59
+ parent_resource_type=parent_resource_type,
60
+ )
61
+
62
+ return f"{parent_resource_type}_{relationship_name}_get_list", endpoint
63
+
64
+ async def endpoint(request: HttpRequest, obj_id: str):
65
+ view = self._build_view(request, operation)
66
+ return await view.handle_get_resource_relationship(
67
+ obj_id=obj_id,
68
+ relationship_name=relationship_name,
69
+ parent_resource_type=parent_resource_type,
70
+ )
71
+
72
+ return f"{parent_resource_type}_{relationship_name}_get", endpoint
73
+
74
+ def _create_get_detail(self):
75
+ async def endpoint(request: HttpRequest, obj_id: str):
76
+ view = self._build_view(request, Operation.GET)
77
+ return await view.handle_get_resource_detail(obj_id=obj_id)
78
+
79
+ return f"{self.resource_type}_get", endpoint
80
+
81
+ def _create_get_list(self):
82
+ async def endpoint(request: HttpRequest):
83
+ view = self._build_view(request, Operation.GET_LIST)
84
+ return await view.handle_get_resource_list()
85
+
86
+ return f"{self.resource_type}_get_list", endpoint
87
+
88
+ def _create_create(self):
89
+ async def endpoint(request: HttpRequest):
90
+ view = self._build_view(request, Operation.CREATE)
91
+ payload = self.data.schema_in_post_data.model_validate(self._parse_json_body(request))
92
+ return await view.handle_post_resource_list(data_create=payload.data)
93
+
94
+ return f"{self.resource_type}_create", endpoint
95
+
96
+ def _create_update(self):
97
+ async def endpoint(request: HttpRequest, obj_id: str):
98
+ view = self._build_view(request, Operation.UPDATE)
99
+ payload = self.data.schema_in_patch_data.model_validate(self._parse_json_body(request))
100
+ return await view.handle_update_resource(obj_id=obj_id, data_update=payload.data)
101
+
102
+ return f"{self.resource_type}_update", endpoint
103
+
104
+ def _create_delete(self):
105
+ async def endpoint(request: HttpRequest, obj_id: str):
106
+ view = self._build_view(request, Operation.DELETE)
107
+ await view.handle_delete_resource(obj_id=obj_id)
108
+ return 204, None
109
+
110
+ return f"{self.resource_type}_delete", endpoint
111
+
112
+ def _create_delete_list(self):
113
+ async def endpoint(request: HttpRequest):
114
+ view = self._build_view(request, Operation.DELETE_LIST)
115
+ return await view.handle_delete_resource_list()
116
+
117
+ return f"{self.resource_type}_delete_list", endpoint
@@ -0,0 +1,26 @@
1
+ from typing import Any, Iterable, Optional, Type, Union
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from django_ninja_jsonapi.data_typing import TypeModel, TypeSchema
6
+ from django_ninja_jsonapi.views.enums import Operation
7
+
8
+
9
+ class ResourceData(BaseModel):
10
+ path: Union[str, list[str]]
11
+ tags: list[str]
12
+ view: Type[Any]
13
+ model: Type[TypeModel]
14
+ source_schema: Type[TypeSchema]
15
+ schema_in_post: Optional[Type[BaseModel]]
16
+ schema_in_post_data: Type[BaseModel]
17
+ schema_in_patch: Optional[Type[BaseModel]]
18
+ schema_in_patch_data: Type[BaseModel]
19
+ detail_response_schema: Type[BaseModel]
20
+ list_response_schema: Type[BaseModel]
21
+ pagination_default_size: Optional[int] = 25
22
+ pagination_default_number: Optional[int] = 1
23
+ pagination_default_offset: Optional[int] = None
24
+ pagination_default_limit: Optional[int] = None
25
+ operations: Iterable[Operation] = ()
26
+ ending_slash: bool = True
@@ -0,0 +1,7 @@
1
+ from .atomic import AtomicOperations
2
+ from .atomic_handler import current_atomic_operation
3
+
4
+ __all__ = (
5
+ "AtomicOperations",
6
+ "current_atomic_operation",
7
+ )
@@ -0,0 +1,49 @@
1
+ from http import HTTPStatus
2
+ from typing import Optional, Type
3
+
4
+ from django.http import HttpRequest, HttpResponse
5
+ from ninja import Router
6
+
7
+ from django_ninja_jsonapi.atomic.atomic_handler import AtomicViewHandler
8
+ from django_ninja_jsonapi.atomic.schemas import AtomicOperationRequest, AtomicResultResponse
9
+
10
+
11
+ class AtomicOperations:
12
+ atomic_handler: Type[AtomicViewHandler] = AtomicViewHandler
13
+
14
+ def __init__(
15
+ self,
16
+ url_path: str = "/operations",
17
+ router: Optional[Router] = None,
18
+ ):
19
+ self.router = router or Router(tags=["Atomic Operations"])
20
+ self.url_path = url_path
21
+ self._register_view()
22
+
23
+ async def view_atomic(
24
+ self,
25
+ request: HttpRequest,
26
+ operations_request: AtomicOperationRequest,
27
+ ):
28
+ atomic_handler = self.atomic_handler(
29
+ request=request,
30
+ operations_request=operations_request,
31
+ )
32
+ result = await atomic_handler.handle()
33
+ if result:
34
+ return result
35
+ return HttpResponse(status=HTTPStatus.NO_CONTENT)
36
+
37
+ def _register_view(self) -> None:
38
+ async def endpoint(request: HttpRequest, operations_request: AtomicOperationRequest):
39
+ return await self.view_atomic(
40
+ request=request,
41
+ operations_request=operations_request,
42
+ )
43
+
44
+ self.router.post(
45
+ self.url_path,
46
+ response=AtomicResultResponse,
47
+ summary="Atomic operations",
48
+ description="""[https://jsonapi.org/ext/atomic/](https://jsonapi.org/ext/atomic/)""",
49
+ )(endpoint)
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections import defaultdict
5
+ from contextvars import ContextVar
6
+ from functools import wraps
7
+ from http import HTTPStatus
8
+ from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional, TypedDict, Union
9
+
10
+ from django.http import HttpRequest
11
+ from pydantic import ValidationError
12
+
13
+ from django_ninja_jsonapi.atomic.prepared_atomic_operation import LocalIdsType, OperationBase
14
+ from django_ninja_jsonapi.atomic.schemas import (
15
+ AtomicOperation,
16
+ AtomicOperationRequest,
17
+ AtomicResultResponse,
18
+ OperationItemInSchema,
19
+ )
20
+ from django_ninja_jsonapi.exceptions import HTTPException
21
+ from django_ninja_jsonapi.storages.schemas_storage import schemas_storage
22
+
23
+ if TYPE_CHECKING:
24
+ from django_ninja_jsonapi.data_layers.base import BaseDataLayer
25
+ from django_ninja_jsonapi.data_typing import TypeSchema
26
+
27
+ log = logging.getLogger(__name__)
28
+ AtomicResponseDict = TypedDict("AtomicResponseDict", {"atomic:results": list[Any]})
29
+ current_atomic_operation: ContextVar[OperationBase] = ContextVar("current_atomic_operation")
30
+
31
+
32
+ def catch_exc_on_operation_handle(func: Callable[..., Awaitable]):
33
+ @wraps(func)
34
+ async def wrapper(*a, operation: OperationBase, **kw):
35
+ try:
36
+ return await func(*a, operation=operation, **kw)
37
+ except (ValidationError, ValueError) as ex:
38
+ log.exception(
39
+ "Validation error on atomic action ref=%s, data=%s",
40
+ operation.ref,
41
+ operation.data,
42
+ )
43
+ errors_details = {
44
+ "message": f"Validation error on operation {operation.op_type}",
45
+ "ref": operation.ref,
46
+ "data": operation.data.model_dump() if hasattr(operation.data, "model_dump") else operation.data,
47
+ }
48
+ if isinstance(ex, ValidationError):
49
+ errors_details.update(errors=ex.errors())
50
+ elif isinstance(ex, ValueError):
51
+ errors_details.update(error=f"{ex}")
52
+ else:
53
+ raise
54
+ # TODO: json:api exception
55
+ raise HTTPException(
56
+ status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
57
+ detail=errors_details,
58
+ ) from ex
59
+
60
+ return wrapper
61
+
62
+
63
+ class AtomicViewHandler:
64
+ def __init__(
65
+ self,
66
+ request: HttpRequest,
67
+ operations_request: AtomicOperationRequest,
68
+ ):
69
+ self.request = request
70
+ self.operations_request = operations_request
71
+ self.local_ids_cache: LocalIdsType = defaultdict(dict)
72
+
73
+ async def prepare_one_operation(self, operation: AtomicOperation):
74
+ """
75
+ Prepare one atomic operation
76
+
77
+ :param operation:
78
+ :return:
79
+ """
80
+ resource_type = (operation.ref and operation.ref.type) or (operation.data and operation.data.type)
81
+ if not schemas_storage.has_resource(resource_type):
82
+ msg = f"Unknown resource type {resource_type!r}."
83
+ raise ValueError(msg)
84
+
85
+ return OperationBase.prepare(
86
+ action=operation.op,
87
+ request=self.request,
88
+ resource_type=resource_type,
89
+ ref=operation.ref,
90
+ data=operation.data,
91
+ )
92
+
93
+ async def prepare_operations(self) -> list[OperationBase]:
94
+ prepared_operations: list[OperationBase] = []
95
+
96
+ for operation in self.operations_request.operations:
97
+ one_operation = await self.prepare_one_operation(operation)
98
+ prepared_operations.append(one_operation)
99
+
100
+ return prepared_operations
101
+
102
+ @catch_exc_on_operation_handle
103
+ async def process_one_operation(
104
+ self,
105
+ dl: BaseDataLayer,
106
+ operation: OperationBase,
107
+ ):
108
+ operation.update_relationships_with_lid(local_ids=self.local_ids_cache)
109
+ return await operation.handle(dl=dl)
110
+
111
+ async def process_next_operation(
112
+ self,
113
+ operation: OperationBase,
114
+ previous_dl: Optional[BaseDataLayer],
115
+ ) -> tuple[Optional[TypeSchema], BaseDataLayer]:
116
+ dl = await operation.get_data_layer()
117
+ await dl.atomic_start(
118
+ previous_dl=previous_dl,
119
+ )
120
+ try:
121
+ response = await self.process_one_operation(
122
+ dl=dl,
123
+ operation=operation,
124
+ )
125
+ except HTTPException as ex:
126
+ await dl.atomic_end(
127
+ success=False,
128
+ exception=ex,
129
+ )
130
+ raise ex
131
+
132
+ return response, dl
133
+
134
+ async def handle(self) -> Union[AtomicResponseDict, AtomicResultResponse, None]:
135
+ prepared_operations = await self.prepare_operations()
136
+ results = []
137
+ only_empty_responses = True
138
+ success = True
139
+ previous_dl: Optional[BaseDataLayer] = None
140
+ for operation in prepared_operations:
141
+ # set context var
142
+ ctx_var_token = current_atomic_operation.set(operation)
143
+ try:
144
+ response, dl = await self.process_next_operation(operation, previous_dl)
145
+ previous_dl = dl
146
+
147
+ # response.data.id
148
+ if not response:
149
+ # https://jsonapi.org/ext/atomic/#result-objects
150
+ # An empty result object ({}) is acceptable
151
+ # for operations that are not required to return data.
152
+ results.append({})
153
+ continue
154
+ only_empty_responses = False
155
+
156
+ data = response["data"]
157
+ results.append(
158
+ {"data": data},
159
+ )
160
+
161
+ if isinstance(operation.data, OperationItemInSchema) and operation.data.lid and data and "id" in data:
162
+ self.local_ids_cache[operation.data.type][operation.data.lid] = data["id"]
163
+ finally:
164
+ # reset context var even when operation fails
165
+ current_atomic_operation.reset(ctx_var_token)
166
+
167
+ if previous_dl:
168
+ await previous_dl.atomic_end(success=success)
169
+
170
+ if not only_empty_responses:
171
+ return {"atomic:results": results}
172
+
173
+ """
174
+ if all results are empty,
175
+ the server MAY respond with 204 No Content and no document.
176
+ """