Skip to content

Commit 1ee5901

Browse files
authored
Relationship resources (#3)
* Added BaseRelationshipResource and tests * More documentation through comments and docstrings * Modify JSONAPIRelationship to pass allow_none=True * Added example for BaseRelationshipResource in sample-plain * Added basic example for setting up a relationship resource in readme
1 parent 0fb4ec2 commit 1ee5901

10 files changed

Lines changed: 1251 additions & 16 deletions

File tree

README.md

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,13 @@ Since this project is under development, please pin your dependencies to avoid p
2020
- basic json:api serialization
2121
- including related resources
2222
- starlette friendly route generation
23-
- exception handlers to serialize as json:api responses
23+
- exception handlers to serialize as json:api responses
24+
- relationship resources
2425

2526
### Todo:
2627
- sparse fields
2728
- pagination helpers
2829
- sorting helpers
29-
- relationship resources
3030
- documentation
3131
- examples for other ORMs
3232

@@ -162,6 +162,67 @@ class ExampleResource(BaseResource):
162162
return await self.serialize(example)
163163
```
164164

165+
### Defining a relationship resource
166+
You can choose to define a relationship resource by subclassing `starlette_jsonapi.resource.BaseRelationshipResource`.
167+
168+
A json:api compliant service might implement GET, POST, PATCH, DELETE HTTP methods on a relationship resource,
169+
so the `BaseRelationshipResource` comes with 4 methods that you can override:
170+
- `get -> handling GET /<parent_type>/<parent_id>/relationships/<relationship_name>`
171+
- `patch -> handling PATCH /<parent_type>/<parent_id>/relationships/<relationship_name>`
172+
- `delete -> handling DELETE /<parent_type>/<parent_id>/relationships/<relationship_name>`
173+
- `post -> handling POST /<parent_type>/<parent_id>/relationships/<relationship_name>`
174+
175+
All methods return 405 Method Not Allowed by default.
176+
You can also customize `allowed_methods` to a subset of the above HTTP methods.
177+
178+
When defining a relationship resource, the following class attributes must be set:
179+
- `parent_resource` -> must point to the BaseResource subclass that is exposing the parent resource
180+
- `relationship_name` -> must contain the relationship name, as found on `parent_resource.schema`
181+
182+
Example:
183+
```python
184+
from marshmallow_jsonapi import fields
185+
from starlette.applications import Starlette
186+
from starlette_jsonapi.fields import JSONAPIRelationship
187+
from starlette_jsonapi.resource import BaseRelationshipResource, BaseResource
188+
from starlette_jsonapi.schema import JSONAPISchema
189+
190+
class EmployeeSchema(JSONAPISchema):
191+
class Meta:
192+
type_ = 'employees'
193+
self_route = 'employees:get'
194+
self_route_kwargs = {'id': '<id>'}
195+
self_route_many = 'employees:get_all'
196+
197+
id = fields.Str(dump_only=True)
198+
name = fields.Str()
199+
200+
manager = JSONAPIRelationship(
201+
type_='employees',
202+
schema='EmployeeSchema',
203+
include_resource_linkage=True,
204+
self_route='employees:manager',
205+
self_route_kwargs={'parent_id': '<id>'},
206+
related_route='employees:get',
207+
related_route_kwargs={'id': '<id>'},
208+
)
209+
210+
211+
class EmployeeResource(BaseResource):
212+
type_ = 'employees'
213+
schema = EmployeeSchema
214+
215+
216+
class EmployeeManagerResource(BaseRelationshipResource):
217+
parent_resource = EmployeeResource
218+
relationship_name = 'manager'
219+
220+
221+
app = Starlette()
222+
EmployeeResource.register_routes(app=app, base_path='/')
223+
EmployeeManagerResource.register_routes(app=app, base_path='/')
224+
```
225+
165226
#### Registering resource paths
166227
To register a defined resource class, we need to add the appropriate paths to the Starlette app.
167228
Considering the ExampleResource implementation above, it's as simple as:

examples/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ You can then access [users](http://127.0.0.1:8000/api/users/) and [organizations
2424

2525
## 1. sample-plain
2626

27-
Contains a basic implementation of 2 resources, `users` and `organizations` using plain Python classes.
27+
Contains a basic implementation of 3 resources: `users`, `teams` and `organizations` using plain Python classes.
2828

2929
#### Running:
3030
In order to start the service on http://127.0.0.1:8000/, you can run:

examples/sample-plain/accounts/app.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ def create_app():
1010
register_jsonapi_exception_handlers(app)
1111

1212
# register routes
13-
from accounts.resources import users, organizations
13+
from accounts.resources import users, organizations, teams
1414
users.UsersResource.register_routes(app, '/api')
1515
organizations.OrganizationsResource.register_routes(app, '/api')
16+
teams.TeamsResource.register_routes(app, '/api')
17+
teams.TeamUsersResource.register_routes(app)
1618

1719
return app

examples/sample-plain/accounts/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,7 @@ class OrganizationNotFound(ResourceNotFound):
77

88
class UserNotFound(ResourceNotFound):
99
detail = 'User not found.'
10+
11+
12+
class TeamNotFound(ResourceNotFound):
13+
detail = 'Team not found.'

examples/sample-plain/accounts/models.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,22 @@ def delete(self) -> None:
3434

3535

3636
class Organization(Model):
37-
__db_name__: str = 'organizations'
37+
__db_name__ = 'organizations'
3838

3939
name: str
4040
contact_url: Optional[str] = None
4141
contact_phone: Optional[str] = None
4242

4343

4444
class User(Model):
45-
__db_name__: str = 'users'
45+
__db_name__ = 'users'
4646

4747
username: str
4848
organization: Organization
49+
50+
51+
class Team(Model):
52+
__db_name__ = 'teams'
53+
54+
name: str
55+
users: List[User]
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import logging
2+
from typing import List
3+
4+
from marshmallow_jsonapi import fields
5+
from starlette.responses import Response
6+
7+
from starlette_jsonapi.fields import JSONAPIRelationship
8+
from starlette_jsonapi.resource import BaseResource, BaseRelationshipResource
9+
from starlette_jsonapi.responses import JSONAPIResponse
10+
from starlette_jsonapi.schema import JSONAPISchema
11+
12+
from accounts.exceptions import TeamNotFound, UserNotFound
13+
from accounts.models import User, Team
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
class TeamSchema(JSONAPISchema):
19+
id = fields.Str(dump_only=True)
20+
name = fields.Str(required=True)
21+
22+
users = JSONAPIRelationship(
23+
type_='users',
24+
schema='UserSchema',
25+
include_resource_linkage=True,
26+
many=True,
27+
required=True,
28+
)
29+
30+
class Meta:
31+
type_ = 'teams'
32+
self_route = 'teams:get'
33+
self_route_kwargs = {'id': '<id>'}
34+
self_route_many = 'teams:get_all'
35+
36+
37+
class TeamsResource(BaseResource):
38+
type_ = 'teams'
39+
schema = TeamSchema
40+
id_mask = 'int'
41+
42+
async def prepare_relations(self, obj: Team, relations: List[str]):
43+
"""
44+
We override this to allow `included` requests against this resource,
45+
but we don't actually have to do anything here.
46+
The attribute is already populated because we're using plain NamedTuples.
47+
"""
48+
return None
49+
50+
async def get(self, id=None, *args, **kwargs) -> Response:
51+
if not id:
52+
raise TeamNotFound
53+
team = Team.get_item(id)
54+
if not team:
55+
raise TeamNotFound
56+
57+
return await self.serialize(data=team)
58+
59+
async def patch(self, id=None, *args, **kwargs) -> Response:
60+
if not id:
61+
raise TeamNotFound
62+
team = Team.get_item(id)
63+
if not team:
64+
raise TeamNotFound
65+
66+
json_body = await self.deserialize_body(partial=True)
67+
name = json_body.get('name')
68+
if name:
69+
team.name = name
70+
71+
user_ids = json_body.get('users')
72+
if user_ids:
73+
users = []
74+
for user_id in user_ids:
75+
user = User.get_item(int(user_id))
76+
if not user:
77+
raise UserNotFound
78+
users.append(user)
79+
team.users = users
80+
81+
team.save()
82+
83+
return await self.serialize(data=team)
84+
85+
async def delete(self, id=None, *args, **kwargs) -> Response:
86+
if not id:
87+
raise TeamNotFound
88+
team = Team.get_item(id)
89+
if not team:
90+
raise TeamNotFound
91+
92+
team.delete()
93+
94+
return JSONAPIResponse(status_code=204)
95+
96+
async def get_all(self, *args, **kwargs) -> Response:
97+
teams = Team.get_items()
98+
return await self.serialize(data=teams, many=True)
99+
100+
async def post(self, *args, **kwargs) -> Response:
101+
json_body = await self.deserialize_body()
102+
103+
team = Team()
104+
name = json_body.get('name')
105+
if name:
106+
team.name = name
107+
108+
user_ids = json_body['users']
109+
users = []
110+
for user_id in user_ids:
111+
user = User.get_item(int(user_id))
112+
if not user:
113+
raise UserNotFound
114+
users.append(user)
115+
team.users = users
116+
117+
team.save()
118+
119+
result = await self.serialize(data=team)
120+
result.status_code = 201
121+
return result
122+
123+
124+
class TeamUsersResource(BaseRelationshipResource):
125+
parent_resource = TeamsResource
126+
relationship_name = 'users'
127+
128+
async def get(self, parent_id: str, *args, **kwargs) -> Response:
129+
team = Team.get_item(int(parent_id))
130+
if not team:
131+
raise TeamNotFound
132+
return await self.serialize(data=team)
133+
134+
async def patch(self, parent_id: str, *args, **kwargs) -> Response:
135+
team = Team.get_item(int(parent_id))
136+
if not team:
137+
raise TeamNotFound
138+
139+
user_ids = await self.deserialize_ids()
140+
if not user_ids:
141+
users = []
142+
else:
143+
users = []
144+
for user_id in user_ids:
145+
user = User.get_item(int(user_id))
146+
if not user:
147+
raise UserNotFound
148+
users.append(user)
149+
team.users = users
150+
team.save()
151+
return await self.serialize(data=team)
152+
153+
async def post(self, parent_id: str, *args, **kwargs) -> Response:
154+
team = Team.get_item(int(parent_id))
155+
if not team:
156+
raise TeamNotFound
157+
158+
user_ids = await self.deserialize_ids()
159+
if not user_ids:
160+
users = []
161+
else:
162+
users = team.users
163+
for user_id in user_ids:
164+
user = User.get_item(int(user_id))
165+
if not user:
166+
raise UserNotFound
167+
users.append(user)
168+
team.users = users
169+
team.save()
170+
return await self.serialize(data=team)
171+
172+
async def delete(self, parent_id: str, *args, **kwargs) -> Response:
173+
team = Team.get_item(int(parent_id))
174+
if not team:
175+
raise TeamNotFound
176+
user_ids = await self.deserialize_ids()
177+
if not user_ids:
178+
user_ids = []
179+
users = []
180+
for user in team.users:
181+
if str(user.id) not in user_ids:
182+
users.append(user)
183+
team.users = users
184+
team.save()
185+
return await self.serialize(data=team)

0 commit comments

Comments
 (0)