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.
- django_ninja_jsonapi/VERSION +1 -0
- django_ninja_jsonapi/__init__.py +34 -0
- django_ninja_jsonapi/api/__init__.py +0 -0
- django_ninja_jsonapi/api/application_builder.py +216 -0
- django_ninja_jsonapi/api/endpoint_builder.py +117 -0
- django_ninja_jsonapi/api/schemas.py +26 -0
- django_ninja_jsonapi/atomic/__init__.py +7 -0
- django_ninja_jsonapi/atomic/atomic.py +49 -0
- django_ninja_jsonapi/atomic/atomic_handler.py +176 -0
- django_ninja_jsonapi/atomic/prepared_atomic_operation.py +342 -0
- django_ninja_jsonapi/atomic/schemas.py +220 -0
- django_ninja_jsonapi/common.py +16 -0
- django_ninja_jsonapi/data_layers/__init__.py +0 -0
- django_ninja_jsonapi/data_layers/base.py +504 -0
- django_ninja_jsonapi/data_layers/django_orm/__init__.py +3 -0
- django_ninja_jsonapi/data_layers/django_orm/base_model.py +44 -0
- django_ninja_jsonapi/data_layers/django_orm/orm.py +469 -0
- django_ninja_jsonapi/data_layers/django_orm/query_building.py +84 -0
- django_ninja_jsonapi/data_layers/fields/__init__.py +1 -0
- django_ninja_jsonapi/data_layers/fields/enums.py +11 -0
- django_ninja_jsonapi/data_layers/fields/mixins.py +33 -0
- django_ninja_jsonapi/data_typing.py +6 -0
- django_ninja_jsonapi/exceptions/__init__.py +39 -0
- django_ninja_jsonapi/exceptions/base.py +29 -0
- django_ninja_jsonapi/exceptions/handlers.py +12 -0
- django_ninja_jsonapi/exceptions/json_api.py +192 -0
- django_ninja_jsonapi/generics.py +3 -0
- django_ninja_jsonapi/misc/__init__.py +0 -0
- django_ninja_jsonapi/misc/django_orm/__init__.py +0 -0
- django_ninja_jsonapi/misc/django_orm/generics/__init__.py +3 -0
- django_ninja_jsonapi/misc/django_orm/generics/base.py +6 -0
- django_ninja_jsonapi/misc/generics/__init__.py +3 -0
- django_ninja_jsonapi/misc/generics/base.py +3 -0
- django_ninja_jsonapi/py.typed +0 -0
- django_ninja_jsonapi/querystring.py +286 -0
- django_ninja_jsonapi/renderers.py +7 -0
- django_ninja_jsonapi/schema.py +274 -0
- django_ninja_jsonapi/schema_base.py +36 -0
- django_ninja_jsonapi/schema_builder.py +536 -0
- django_ninja_jsonapi/storages/__init__.py +9 -0
- django_ninja_jsonapi/storages/models_storage.py +105 -0
- django_ninja_jsonapi/storages/schemas_storage.py +191 -0
- django_ninja_jsonapi/storages/views_storage.py +30 -0
- django_ninja_jsonapi/types_metadata/__init__.py +7 -0
- django_ninja_jsonapi/types_metadata/client_can_set_id.py +7 -0
- django_ninja_jsonapi/types_metadata/relationship_info.py +11 -0
- django_ninja_jsonapi/utils/__init__.py +0 -0
- django_ninja_jsonapi/utils/exceptions.py +20 -0
- django_ninja_jsonapi/utils/metadata_instance_search.py +23 -0
- django_ninja_jsonapi/validation_utils.py +51 -0
- django_ninja_jsonapi/views/__init__.py +19 -0
- django_ninja_jsonapi/views/enums.py +32 -0
- django_ninja_jsonapi/views/schemas.py +22 -0
- django_ninja_jsonapi/views/view_base.py +676 -0
- django_ninja_jsonapi-0.2.0.dist-info/METADATA +174 -0
- django_ninja_jsonapi-0.2.0.dist-info/RECORD +57 -0
- 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,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
|
+
"""
|