Skip to content

Commit ff4dbf0

Browse files
authored
Sparse fields (#4)
* Added sparse fields implementation * Marked sparse fields as done in readme
1 parent 79393ea commit ff4dbf0

4 files changed

Lines changed: 317 additions & 9 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ Since this project is under development, please pin your dependencies to avoid p
2222
- starlette friendly route generation
2323
- exception handlers to serialize as json:api responses
2424
- relationship resources
25+
- sparse fields
2526

2627
### Todo:
27-
- sparse fields
2828
- pagination helpers
2929
- sorting helpers
3030
- documentation

starlette_jsonapi/resource.py

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@
1111
from starlette_jsonapi.fields import JSONAPIRelationship
1212
from starlette_jsonapi.responses import JSONAPIResponse
1313
from starlette_jsonapi.schema import JSONAPISchema
14-
from starlette_jsonapi.utils import parse_included_params, serialize_error
14+
from starlette_jsonapi.utils import (
15+
parse_included_params,
16+
parse_sparse_fields_params, filter_sparse_fields,
17+
serialize_error,
18+
)
1519

1620
logger = logging.getLogger(__name__)
1721

@@ -110,8 +114,9 @@ async def serialize(self, data: Any, many=False) -> JSONAPIResponse:
110114
included_relations = await self._prepare_included(data=data, many=many)
111115
schema = self.schema(app=self.request.app, include_data=included_relations)
112116
body = schema.dump(data, many=many)
117+
sparse_body = await self.process_sparse_fields(body, many=many)
113118
return JSONAPIResponse(
114-
content=body,
119+
content=sparse_body,
115120
)
116121

117122
@classmethod
@@ -224,14 +229,54 @@ async def prepare_relations(self, obj: Any, relations: List[str]) -> None:
224229
for asynchronous objects that may need fetching.
225230
226231
Example `relations`:
227-
url = /some-url?include=resource1,resource2.resource3
228-
relations = ['resource1', 'resource2.resource3']
232+
url = /some-url?include=resource1,resource1.resource2
233+
relations = ['resource1', 'resource1.resource2']
229234
230235
:param obj: an object that was passed to `serialize`
231-
:param relations: list of relations, ex: ['resource1', 'resource2.resource3']
236+
:param relations: list of relations, ex: ['resource1', 'resource1.resource2']
232237
"""
233238
raise _StopInclude
234239

240+
# Methods used to implement sparse fields
241+
# https://jsonapi.org/format/#fetching-sparse-fieldsets
242+
async def process_sparse_fields(self, serialized_data: dict, many: bool = False) -> dict:
243+
"""
244+
Processes sparse fields requests by cleaning the serialized
245+
data of extra attributes and relationships.
246+
"""
247+
sparse_fields = parse_sparse_fields_params(self.request)
248+
if not sparse_fields or not serialized_data.get('data'):
249+
return serialized_data
250+
251+
data = serialized_data['data']
252+
new_data = [] if many else {} # type: Union[List, dict]
253+
254+
included = serialized_data.get('included', None)
255+
new_included = []
256+
257+
for resource_name, fields in sparse_fields.items():
258+
# filter sparse fields in `data`
259+
if many:
260+
for item in data:
261+
if item['type'] == resource_name:
262+
new_data.append(filter_sparse_fields(item, fields)) # type: ignore
263+
else:
264+
if data['type'] == resource_name:
265+
new_data = filter_sparse_fields(data, fields)
266+
267+
# filter sparse fields in `included`
268+
if included:
269+
for item in included:
270+
if item['type'] == resource_name:
271+
new_included.append(filter_sparse_fields(item, fields))
272+
273+
new_serialized_data = serialized_data.copy()
274+
new_serialized_data['data'] = new_data
275+
if new_included:
276+
new_serialized_data['included'] = new_included
277+
278+
return serialized_data
279+
235280

236281
class _StopInclude(Exception):
237282
pass

starlette_jsonapi/utils.py

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Optional, Set
1+
from typing import Optional, Set, Dict, List
22

33
from starlette.applications import Starlette
44
from starlette.exceptions import HTTPException
@@ -47,11 +47,68 @@ def parse_included_params(request: Request) -> Optional[Set[str]]:
4747
Parses a request's `include` query parameter, if present,
4848
and returns a sequence of included relations.
4949
50-
For example, if a request were to reach `/some-resource/?include=foo,foo.bar`, then:
51-
`parse_included_params(request) -> {'foo', 'foo.bar'}`
50+
For example, if a request were to reach
51+
`/some-resource/?include=foo,foo.bar`
52+
then:
53+
`parse_included_params(request) == {'foo', 'foo.bar'}`
5254
"""
5355
include = request.query_params.get('include')
5456
if include:
5557
include = set(include.split(','))
5658
return include
5759
return None
60+
61+
62+
def parse_sparse_fields_params(request: Request) -> Dict[str, List[str]]:
63+
"""
64+
Parses a request's `fields` query parameter, if present,
65+
and returns a dictionary of resource type -> sparse fields.
66+
67+
For example, if a request were to reach
68+
`/some-resource/?fields[some-resource]=foo,bar`
69+
then:
70+
`parse_sparse_fields_params(request) == {'some-resource': ['foo', 'bar']}`
71+
"""
72+
sparse_fields = dict()
73+
for qp_name, qp_value in request.query_params.items():
74+
if qp_name.startswith('fields[') and qp_name.endswith(']'):
75+
resource_name_start = qp_name.index('[') + 1
76+
resource_name_end = qp_name.index(']')
77+
resource_name = qp_name[resource_name_start:resource_name_end]
78+
if not resource_name or not qp_value or not all(qp_value.split(',')):
79+
raise JSONAPIException(status_code=400, detail='Incorrect sparse fields request.')
80+
81+
sparse_fields[resource_name] = qp_value.split(',')
82+
return sparse_fields
83+
84+
85+
def filter_sparse_fields(item: dict, sparse_fields) -> dict:
86+
"""
87+
Given a dictionary representation of an item,
88+
mutate in place to drop fields according to a sparse fields request.
89+
90+
https://jsonapi.org/format/#fetching-sparse-fieldsets
91+
"""
92+
# filter `attributes`
93+
item_attributes = item.get('attributes')
94+
if item_attributes:
95+
new_attributes = item_attributes.copy()
96+
for attr_name in item_attributes:
97+
if attr_name not in sparse_fields:
98+
new_attributes.pop(attr_name, None)
99+
if new_attributes:
100+
item['attributes'] = new_attributes
101+
else:
102+
del item['attributes']
103+
# filter `relationships`
104+
item_relationships = item.get('relationships')
105+
if item_relationships:
106+
new_relationships = item_relationships.copy()
107+
for rel_name in item_relationships:
108+
if rel_name not in sparse_fields:
109+
new_relationships.pop(rel_name, None)
110+
if new_relationships:
111+
item['relationships'] = new_relationships
112+
else:
113+
del item['relationships']
114+
return item

tests/test_resource.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1040,3 +1040,209 @@ class TResourceRel(BaseRelationshipResource):
10401040
TResourceRel.register_routes(app=app)
10411041

10421042
assert str(exc.value) == 'Parent resource should be registered first.'
1043+
1044+
1045+
def test_sparse_fields(included_app: Starlette):
1046+
test_client = TestClient(included_app)
1047+
rv = test_client.get(
1048+
'/test-resource/foo'
1049+
'?fields[test-resource]=rel,name'
1050+
)
1051+
assert rv.status_code == 200
1052+
assert rv.json() == {
1053+
'data': {
1054+
'id': 'foo',
1055+
'type': 'test-resource',
1056+
'attributes': {
1057+
'name': 'foo-name',
1058+
},
1059+
'relationships': {
1060+
'rel': {
1061+
'data': {
1062+
'type': 'test-related-resource',
1063+
'id': 'bar',
1064+
}
1065+
}
1066+
}
1067+
}
1068+
}
1069+
1070+
rv = test_client.get(
1071+
'/test-resource/foo'
1072+
'?fields[test-resource]=name'
1073+
)
1074+
assert rv.status_code == 200
1075+
assert rv.json() == {
1076+
'data': {
1077+
'id': 'foo',
1078+
'type': 'test-resource',
1079+
'attributes': {
1080+
'name': 'foo-name',
1081+
}
1082+
}
1083+
}
1084+
1085+
rv = test_client.get(
1086+
'/test-resource/foo'
1087+
'?fields[test-resource]=rel'
1088+
)
1089+
assert rv.status_code == 200
1090+
assert rv.json() == {
1091+
'data': {
1092+
'id': 'foo',
1093+
'type': 'test-resource',
1094+
'relationships': {
1095+
'rel': {
1096+
'data': {
1097+
'type': 'test-related-resource',
1098+
'id': 'bar',
1099+
}
1100+
}
1101+
}
1102+
}
1103+
}
1104+
1105+
1106+
def test_sparse_fields_field_does_not_exist(included_app: Starlette):
1107+
test_client = TestClient(included_app)
1108+
rv = test_client.get(
1109+
'/test-resource/foo'
1110+
'?fields[test-resource]=non-existent'
1111+
)
1112+
assert rv.status_code == 200
1113+
assert rv.json() == {
1114+
'data': {
1115+
'id': 'foo',
1116+
'type': 'test-resource',
1117+
}
1118+
}
1119+
1120+
rv = test_client.get(
1121+
'/test-resource/foo'
1122+
'?fields[test-resource]=non-existent,name'
1123+
)
1124+
assert rv.status_code == 200
1125+
assert rv.json() == {
1126+
'data': {
1127+
'id': 'foo',
1128+
'type': 'test-resource',
1129+
'attributes': {
1130+
'name': 'foo-name',
1131+
}
1132+
}
1133+
}
1134+
1135+
1136+
def test_sparse_fields_incorrect_field(included_app: Starlette):
1137+
test_client = TestClient(included_app)
1138+
rv = test_client.get(
1139+
'/test-resource/foo'
1140+
'?fields[]=bar'
1141+
)
1142+
assert rv.status_code == 400
1143+
assert rv.json() == {
1144+
'errors': [
1145+
{'detail': 'Incorrect sparse fields request.'}
1146+
]
1147+
}
1148+
1149+
rv = test_client.get(
1150+
'/test-resource/foo'
1151+
'?fields[test-resource]='
1152+
)
1153+
assert rv.status_code == 400
1154+
assert rv.json() == {
1155+
'errors': [
1156+
{'detail': 'Incorrect sparse fields request.'}
1157+
]
1158+
}
1159+
1160+
rv = test_client.get(
1161+
'/test-resource/foo'
1162+
'?fields[test-resource]=,,'
1163+
)
1164+
assert rv.status_code == 400
1165+
assert rv.json() == {
1166+
'errors': [
1167+
{'detail': 'Incorrect sparse fields request.'}
1168+
]
1169+
}
1170+
1171+
1172+
def test_sparse_fields_included_data(included_app: Starlette):
1173+
test_client = TestClient(included_app)
1174+
rv = test_client.get(
1175+
'/test-resource/foo'
1176+
'?include=rel'
1177+
'&fields[test-resource]=name'
1178+
)
1179+
assert rv.status_code == 200
1180+
assert rv.json() == {
1181+
'data': {
1182+
'id': 'foo',
1183+
'type': 'test-resource',
1184+
'attributes': {
1185+
'name': 'foo-name',
1186+
}
1187+
},
1188+
'included': [
1189+
{
1190+
'id': 'bar',
1191+
'type': 'test-related-resource',
1192+
'attributes': {
1193+
'description': 'bar-description',
1194+
}
1195+
}
1196+
]
1197+
}
1198+
1199+
rv = test_client.get(
1200+
'/test-resource/foo'
1201+
'?include=rel'
1202+
'&fields[test-resource]=name'
1203+
'&fields[test-related-resource]=nothing'
1204+
)
1205+
assert rv.status_code == 200
1206+
assert rv.json() == {
1207+
'data': {
1208+
'id': 'foo',
1209+
'type': 'test-resource',
1210+
'attributes': {
1211+
'name': 'foo-name',
1212+
}
1213+
},
1214+
'included': [
1215+
{
1216+
'id': 'bar',
1217+
'type': 'test-related-resource',
1218+
}
1219+
]
1220+
}
1221+
1222+
1223+
def test_sparse_fields_many(included_app: Starlette):
1224+
test_client = TestClient(included_app)
1225+
rv = test_client.get(
1226+
'/test-resource/'
1227+
'?fields[test-resource]=name'
1228+
)
1229+
assert rv.status_code == 200
1230+
assert rv.json() == {
1231+
'data': [
1232+
{
1233+
'id': 'foo',
1234+
'type': 'test-resource',
1235+
'attributes': {
1236+
'name': 'foo-name',
1237+
}
1238+
},
1239+
{
1240+
'id': 'foo2',
1241+
'type': 'test-resource',
1242+
'attributes': {
1243+
'name': 'foo2-name',
1244+
}
1245+
},
1246+
1247+
]
1248+
}

0 commit comments

Comments
 (0)