Skip to content

Commit d9d5baa

Browse files
committed
delete object and atomic action remove fix: return 204
1 parent 71d84d5 commit d9d5baa

File tree

12 files changed

+246
-72
lines changed

12 files changed

+246
-72
lines changed

fastapi_jsonapi/api.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -212,35 +212,39 @@ def _register_get_resource_detail(self, path: str):
212212
)
213213

214214
def _register_patch_resource_detail(self, path: str):
215-
detail_response_example = {
215+
update_response_example = {
216216
status.HTTP_200_OK: {"model": self.detail_response_schema},
217217
}
218218
self._router.add_api_route(
219219
# TODO: variable path param name (set default name on DetailView class)
220220
# TODO: trailing slash (optional)
221221
path=path + "/{obj_id}",
222222
tags=self._tags,
223-
responses=detail_response_example | self.default_error_responses,
223+
responses=update_response_example | self.default_error_responses,
224224
methods=["PATCH"],
225225
summary=f"Patch object `{self.type_}` by id",
226226
endpoint=self._create_patch_resource_detail_view(),
227227
name=self.get_endpoint_name("update", "detail"),
228228
)
229229

230230
def _register_delete_resource_detail(self, path: str):
231-
detail_response_example = {
232-
status.HTTP_200_OK: {"model": self.detail_response_schema},
231+
delete_response_example = {
232+
status.HTTP_204_NO_CONTENT: {
233+
"description": "If a server is able to delete the resource,"
234+
" the server MUST return a result with no data",
235+
},
233236
}
234237
self._router.add_api_route(
235238
# TODO: variable path param name (set default name on DetailView class)
236239
# TODO: trailing slash (optional)
237240
path=path + "/{obj_id}",
238241
tags=self._tags,
239-
responses=detail_response_example | self.default_error_responses,
242+
responses=delete_response_example | self.default_error_responses,
240243
methods=["DELETE"],
241244
summary=f"Delete object `{self.type_}` by id",
242245
endpoint=self._create_delete_resource_detail_view(),
243246
name=self.get_endpoint_name("delete", "detail"),
247+
status_code=status.HTTP_204_NO_CONTENT,
244248
)
245249

246250
def _create_pagination_query_params(self) -> List[Parameter]:

fastapi_jsonapi/atomic/atomic.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Type,
44
)
55

6-
from fastapi import APIRouter, Request
6+
from fastapi import APIRouter, Request, Response, status
77

88
from fastapi_jsonapi.atomic.atomic_handler import AtomicViewHandler
99
from fastapi_jsonapi.atomic.schemas import (
@@ -33,7 +33,10 @@ async def view_atomic(
3333
request=request,
3434
operations_request=operations_request,
3535
)
36-
return await atomic_handler.handle()
36+
result = await atomic_handler.handle()
37+
if result:
38+
return result
39+
return Response(status_code=status.HTTP_204_NO_CONTENT)
3740

3841
def _register_view(self):
3942
self.router.add_api_route(

fastapi_jsonapi/atomic/atomic_handler.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ async def prepare_operations(self) -> List[OperationBase]:
5454
prepared_operations: List[OperationBase] = []
5555

5656
for operation in self.operations_request.operations:
57-
jsonapi = RoutersJSONAPI.all_jsonapi_routers[operation.data.type]
57+
operation_type = operation.ref and operation.ref.type or operation.data and operation.data.type
58+
assert operation_type
59+
jsonapi = RoutersJSONAPI.all_jsonapi_routers[operation_type]
5860

5961
dependencies_result: Dict[str, Any] = await self.handle_view_dependencies(
6062
jsonapi=jsonapi,
@@ -63,14 +65,15 @@ async def prepare_operations(self) -> List[OperationBase]:
6365
action=operation.op,
6466
request=self.request,
6567
jsonapi=jsonapi,
68+
ref=operation.ref,
6669
data=operation.data,
6770
data_layer_view_dependencies=dependencies_result,
6871
)
6972
prepared_operations.append(one_operation)
7073

7174
return prepared_operations
7275

73-
async def handle(self) -> Union[AtomicResponseDict, AtomicResultResponse]:
76+
async def handle(self) -> Union[AtomicResponseDict, AtomicResultResponse, None]:
7477
prepared_operations = await self.prepare_operations()
7578
results = []
7679

@@ -84,9 +87,16 @@ async def handle(self) -> Union[AtomicResponseDict, AtomicResultResponse]:
8487
previous_dl = dl
8588
response = await operation.handle(dl=dl)
8689
# response.data.id
87-
results.append({"data": response.data})
90+
if response:
91+
results.append({"data": response.data})
8892

8993
if previous_dl:
9094
await previous_dl.atomic_end(success=success)
9195

92-
return {"atomic:results": results}
96+
if results:
97+
return {"atomic:results": results}
98+
99+
"""
100+
if all results are empty,
101+
the server MAY respond with 204 No Content and no document.
102+
"""

fastapi_jsonapi/atomic/prepared_atomic_operation.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
from __future__ import annotations
22

33
from dataclasses import dataclass
4-
from typing import TYPE_CHECKING, Any, Dict, Type
4+
from typing import TYPE_CHECKING, Any, Dict, Optional, Type
55

66
from fastapi import Request
77

88
from fastapi_jsonapi import RoutersJSONAPI
9-
from fastapi_jsonapi.atomic.schemas import AtomicOperationAction, OperationDataType
9+
from fastapi_jsonapi.atomic.schemas import AtomicOperationAction, AtomicOperationRef, OperationDataType
1010

1111
if TYPE_CHECKING:
1212
from fastapi_jsonapi.data_layers.base import BaseDataLayer
@@ -19,6 +19,7 @@
1919
class OperationBase:
2020
jsonapi: RoutersJSONAPI
2121
view: ViewBase
22+
ref: Optional[AtomicOperationRef]
2223
data: OperationDataType
2324
data_layer_view_dependencies: Dict[str, Any]
2425

@@ -28,6 +29,7 @@ def prepare(
2829
action: str,
2930
request: Request,
3031
jsonapi: RoutersJSONAPI,
32+
ref: Optional[AtomicOperationRef],
3133
data: OperationDataType,
3234
data_layer_view_dependencies: Dict[str, Any],
3335
) -> "OperationBase":
@@ -49,6 +51,7 @@ def prepare(
4951
return operation_cls(
5052
jsonapi=jsonapi,
5153
view=view,
54+
ref=ref,
5255
data=data,
5356
data_layer_view_dependencies=data_layer_view_dependencies,
5457
)
@@ -81,9 +84,10 @@ async def handle(self, dl: BaseDataLayer):
8184
class OperationUpdate(DetailOperationBase):
8285
async def handle(self, dl: BaseDataLayer):
8386
data_in = self.jsonapi.schema_in_patch(data=self.data)
87+
obj_id = self.ref and self.ref.id or self.data and self.data.id
8488
response = await self.view.process_update_object(
8589
dl=dl,
86-
obj_id=data_in.data.id,
90+
obj_id=obj_id,
8791
data_update=data_in.data,
8892
)
8993
return response
@@ -93,9 +97,19 @@ class OperationRemove(DetailOperationBase):
9397
async def handle(
9498
self,
9599
dl: BaseDataLayer,
96-
):
97-
response = await self.view.process_delete_object(
100+
) -> None:
101+
"""
102+
Todo: fix atomic delete
103+
Deleting Resources
104+
An operation that deletes a resource
105+
MUST target that resource
106+
through the operation’s ref or href members,
107+
but not both.
108+
109+
:param dl:
110+
:return:
111+
"""
112+
await self.view.process_delete_object(
98113
dl=dl,
99-
obj_id=self.data.id,
114+
obj_id=self.ref and self.ref.id,
100115
)
101-
return response

fastapi_jsonapi/atomic/schemas.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,8 @@ class AtomicOperation(BaseModel):
121121
description="a meta object that contains non-standard meta-information about the operation",
122122
)
123123

124-
@root_validator
125-
def validate_operation(cls, values: dict):
124+
@classmethod
125+
def _validate_one_of_ref_or_href(cls, values: dict):
126126
"""
127127
An operation object MAY contain either of the following members,
128128
but not both, to specify the target of the operation: (ref, href)
@@ -148,6 +148,32 @@ def validate_operation(cls, values: dict):
148148
# TODO: pydantic V2
149149
raise ValueError(msg)
150150

151+
@root_validator
152+
def validate_operation(cls, values: dict):
153+
"""
154+
:param values:
155+
:return:
156+
"""
157+
cls._validate_one_of_ref_or_href(values=values)
158+
op = values.get("op")
159+
ref: Optional[AtomicOperationRef] = values.get("ref")
160+
if op == AtomicOperationAction.remove:
161+
if not ref:
162+
msg = f"ref should be present for action {op.value!r}"
163+
raise ValueError(msg)
164+
# when updating / removing item, ref [l]id has to be present
165+
if not (ref.id or ref.lid):
166+
msg = f"id or local id has to be present for action {op.value!r}"
167+
raise ValueError(msg)
168+
169+
data: OperationDataType = values.get("data")
170+
operation_type = ref and ref.type or data and data.type
171+
if not operation_type:
172+
msg = "Operation has to be in ref or in data"
173+
raise ValueError(msg)
174+
175+
return values
176+
151177

152178
class AtomicOperationRequest(BaseModel):
153179
operations: List[AtomicOperation] = Field(

fastapi_jsonapi/views/detail_view.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,18 +73,16 @@ async def handle_delete_resource(
7373
self,
7474
obj_id: str,
7575
**extra_view_deps,
76-
):
76+
) -> None:
7777
dl: "BaseDataLayer" = await self.get_data_layer(extra_view_deps)
78-
return await self.process_delete_object(dl=dl, obj_id=obj_id)
78+
await self.process_delete_object(dl=dl, obj_id=obj_id)
7979

8080
async def process_delete_object(
8181
self,
8282
dl: "BaseDataLayer",
8383
obj_id: str,
84-
):
84+
) -> None:
8585
view_kwargs = {dl.url_id_field: obj_id}
8686
db_object = await dl.get_object(view_kwargs=view_kwargs, qs=self.query_params)
8787

8888
await dl.delete_object(db_object, view_kwargs)
89-
90-
return self._build_detail_response(db_object)

tests/schemas.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ class Config:
7070
# User Bio Schemas ⬇️
7171

7272

73-
class UserBioBaseSchema(BaseModel):
73+
class UserBioAttributesBaseSchema(BaseModel):
7474
"""UserBio base schema."""
7575

7676
class Config:
@@ -83,7 +83,7 @@ class Config:
8383
keys_to_ids_list: Dict[str, List[int]] = None
8484

8585

86-
class UserBioSchema(UserBioBaseSchema):
86+
class UserBioSchema(UserBioAttributesBaseSchema):
8787
"""UserBio item schema."""
8888

8989
id: int

tests/test_api/test_api_sqla_with_includes.py

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
PostCommentAttributesBaseSchema,
3333
SelfRelationshipSchema,
3434
UserAttributesBaseSchema,
35-
UserBioBaseSchema,
35+
UserBioAttributesBaseSchema,
3636
UserInSchemaAllowIdOnPost,
3737
UserPatchSchema,
3838
UserSchema,
@@ -689,7 +689,7 @@ async def test_create_object_with_relationship_and_fetch_include(
689689
):
690690
create_user_bio_body = {
691691
"data": {
692-
"attributes": UserBioBaseSchema(
692+
"attributes": UserBioAttributesBaseSchema(
693693
birth_city=fake.word(),
694694
favourite_movies=fake.sentence(),
695695
keys_to_ids_list={"foobar": [1, 2, 3], "spameggs": [2, 3, 4]},
@@ -1289,7 +1289,7 @@ async def test_fail_to_bind_relationship_with_constraint(
12891289
patch_user_bio_body = {
12901290
"data": {
12911291
"id": user_1_bio.id,
1292-
"attributes": UserBioBaseSchema.from_orm(user_1_bio).dict(),
1292+
"attributes": UserBioAttributesBaseSchema.from_orm(user_1_bio).dict(),
12931293
"relationships": {
12941294
"user": {
12951295
"data": {
@@ -1579,16 +1579,8 @@ async def test_delete_object_and_fetch_404(
15791579
):
15801580
url = app.url_path_for("get_user_detail", obj_id=user_1.id)
15811581
res = await client.delete(url)
1582-
assert res.status_code == status.HTTP_200_OK, res.text
1583-
assert res.json() == {
1584-
"data": {
1585-
"attributes": UserAttributesBaseSchema.from_orm(user_1),
1586-
"id": str(user_1.id),
1587-
"type": "user",
1588-
},
1589-
"jsonapi": {"version": "1.0"},
1590-
"meta": None,
1591-
}
1582+
assert res.status_code == status.HTTP_204_NO_CONTENT, res.text
1583+
assert res.content == b""
15921584

15931585
res = await client.get(url)
15941586
assert res.status_code == status.HTTP_404_NOT_FOUND, res.text

tests/test_atomic/test_create_objects.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from tests.misc.utils import fake
1212
from tests.models import User, UserBio
13-
from tests.schemas import UserAttributesBaseSchema, UserBioBaseSchema
13+
from tests.schemas import UserAttributesBaseSchema, UserBioAttributesBaseSchema
1414

1515
pytestmark = mark.asyncio
1616

@@ -205,7 +205,7 @@ async def test_create_bio_with_relationship_to_user_to_one(
205205
async_session: AsyncSession,
206206
user_1: User,
207207
):
208-
user_bio = UserBioBaseSchema(
208+
user_bio = UserBioAttributesBaseSchema(
209209
birth_city=fake.city(),
210210
favourite_movies=fake.sentence(),
211211
)
@@ -241,7 +241,7 @@ async def test_create_bio_with_relationship_to_user_to_one(
241241
result_bio_data = results[0]
242242
res: Result = await async_session.execute(stmt_bio)
243243
user_bio_created: UserBio = res.scalar_one()
244-
assert user_bio == UserBioBaseSchema.from_orm(user_bio_created)
244+
assert user_bio == UserBioAttributesBaseSchema.from_orm(user_bio_created)
245245
assert result_bio_data == {
246246
"data": {
247247
"attributes": user_bio.dict(),

0 commit comments

Comments
 (0)