Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit 16f3c97

Browse files
n2ygksliverc
authored andcommitted
Add query parameter validation filter (#481)
1 parent 760845a commit 16f3c97

File tree

7 files changed

+130
-22
lines changed

7 files changed

+130
-22
lines changed

‎README.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ override ``settings.REST_FRAMEWORK``
173173
),
174174
'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata',
175175
'DEFAULT_FILTER_BACKENDS': (
176+
'rest_framework_json_api.filters.QueryParameterValidationFilter',
176177
'rest_framework_json_api.filters.OrderingFilter',
177178
'rest_framework_json_api.django_filters.DjangoFilterBackend',
178179
'rest_framework.filters.SearchFilter',

‎docs/usage.md

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ REST_FRAMEWORK = {
3333
),
3434
'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata',
3535
'DEFAULT_FILTER_BACKENDS': (
36+
'rest_framework_json_api.filters.QueryParameterValidationFilter',
3637
'rest_framework_json_api.filters.OrderingFilter',
3738
'rest_framework_json_api.django_filters.DjangoFilterBackend',
3839
'rest_framework.filters.SearchFilter',
@@ -104,9 +105,32 @@ class MyLimitPagination(JsonApiLimitOffsetPagination):
104105

105106
### Filter Backends
106107

107-
Following are descriptions for two JSON:API-specific filter backends and documentation on suggested usage
108+
Following are descriptions of JSON:API-specific filter backends and documentation on suggested usage
108109
for a standard DRF keyword-search filter backend that makes it consistent with JSON:API.
109110

111+
#### `QueryParameterValidationFilter`
112+
`QueryParameterValidationFilter` validates query parameters to be one of the defined JSON:API query parameters
113+
(sort, include, filter, fields, page) and returns a `400 Bad Request` if a non-matching query parameter
114+
is used. This can help the client identify misspelled query parameters, for example.
115+
116+
If you want to change the list of valid query parameters, override the `.query_regex` attribute:
117+
```python
118+
# compiled regex that matches the allowed http://jsonapi.org/format/#query-parameters
119+
# `sort` and `include` stand alone; `filter`, `fields`, and `page` have []'s
120+
query_regex = re.compile(r'^(sort|include)$|^(filter|fields|page)(\[[\w\.\-]+\])?$')
121+
```
122+
For example:
123+
```python
124+
import re
125+
from rest_framework_json_api.filters import QueryValidationFilter
126+
127+
class MyQPValidator(QueryValidationFilter):
128+
query_regex = re.compile(r'^(sort|include|page|page_size)$|^(filter|fields|page)(\[[\w\.\-]+\])?$')
129+
```
130+
131+
If you don't care if non-JSON:API query parameters are allowed (and potentially silently ignored),
132+
simply don't use this filter backend.
133+
110134
#### `OrderingFilter`
111135
`OrderingFilter` implements the [JSON:API `sort`](http://jsonapi.org/format/#fetching-sorting) and uses
112136
DRF's [ordering filter](http://django-rest-framework.readthedocs.io/en/latest/api-guide/filtering/#orderingfilter).
@@ -176,6 +200,7 @@ for `GET http://127.0.0.1:8000/nopage-entries?filter[bad]=1`:
176200
]
177201
}
178202
```
203+
179204
#### `SearchFilter`
180205

181206
To comply with JSON:API query parameter naming standards, DRF's
@@ -186,6 +211,7 @@ adding the `.search_param` attribute to a custom class derived from `SearchFilte
186211
use [`DjangoFilterBackend`](#djangofilterbackend), make sure you set the same values for both classes.
187212

188213

214+
189215
#### Configuring Filter Backends
190216

191217
You can configure the filter backends either by setting the `REST_FRAMEWORK['DEFAULT_FILTER_BACKENDS']` as shown
@@ -200,13 +226,15 @@ from models import MyModel
200226
class MyViewset(ModelViewSet):
201227
queryset = MyModel.objects.all()
202228
serializer_class = MyModelSerializer
203-
filter_backends = (filters.OrderingFilter, django_filters.DjangoFilterBackend,)
229+
filter_backends = (filters.QueryParameterValidationFilter, filters.OrderingFilter,
230+
django_filters.DjangoFilterBackend, SearchFilter)
204231
filterset_fields = {
205232
'id': ('exact', 'lt', 'gt', 'gte', 'lte', 'in'),
206233
'descriptuon': ('icontains', 'iexact', 'contains'),
207234
'tagline': ('icontains', 'iexact', 'contains'),
208235
}
209236
search_fields = ('id', 'description', 'tagline',)
237+
210238
```
211239

212240

‎example/tests/test_filters.py

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -251,13 +251,13 @@ def test_filter_invalid_association_name(self):
251251
def test_filter_empty_association_name(self):
252252
"""
253253
test for filter with missing association name
254+
error texts are different depending on whether QueryParameterValidationFilter is in use.
254255
"""
255256
response = self.client.get(self.url, data={'filter[]': 'foobar'})
256257
self.assertEqual(response.status_code, 400,
257258
msg=response.content.decode("utf-8"))
258259
dja_response = response.json()
259-
self.assertEqual(dja_response['errors'][0]['detail'],
260-
"invalid filter: filter[]")
260+
self.assertEqual(dja_response['errors'][0]['detail'], "invalid query parameter: filter[]")
261261

262262
def test_filter_no_brackets(self):
263263
"""
@@ -268,7 +268,17 @@ def test_filter_no_brackets(self):
268268
msg=response.content.decode("utf-8"))
269269
dja_response = response.json()
270270
self.assertEqual(dja_response['errors'][0]['detail'],
271-
"invalid filter: filter")
271+
"invalid query parameter: filter")
272+
273+
def test_filter_missing_right_bracket(self):
274+
"""
275+
test for filter missing right bracket
276+
"""
277+
response = self.client.get(self.url, data={'filter[headline': 'foobar'})
278+
self.assertEqual(response.status_code, 400, msg=response.content.decode("utf-8"))
279+
dja_response = response.json()
280+
self.assertEqual(dja_response['errors'][0]['detail'],
281+
"invalid query parameter: filter[headline")
272282

273283
def test_filter_no_brackets_rvalue(self):
274284
"""
@@ -279,7 +289,7 @@ def test_filter_no_brackets_rvalue(self):
279289
msg=response.content.decode("utf-8"))
280290
dja_response = response.json()
281291
self.assertEqual(dja_response['errors'][0]['detail'],
282-
"invalid filter: filter")
292+
"invalid query parameter: filter")
283293

284294
def test_filter_no_brackets_equal(self):
285295
"""
@@ -290,7 +300,7 @@ def test_filter_no_brackets_equal(self):
290300
msg=response.content.decode("utf-8"))
291301
dja_response = response.json()
292302
self.assertEqual(dja_response['errors'][0]['detail'],
293-
"invalid filter: filter")
303+
"invalid query parameter: filter")
294304

295305
def test_filter_malformed_left_bracket(self):
296306
"""
@@ -300,19 +310,7 @@ def test_filter_malformed_left_bracket(self):
300310
self.assertEqual(response.status_code, 400,
301311
msg=response.content.decode("utf-8"))
302312
dja_response = response.json()
303-
self.assertEqual(dja_response['errors'][0]['detail'],
304-
"invalid filter: filter[")
305-
306-
def test_filter_missing_right_bracket(self):
307-
"""
308-
test for filter missing right bracket
309-
"""
310-
response = self.client.get(self.url, data={'filter[headline': 'foobar'})
311-
self.assertEqual(response.status_code, 400,
312-
msg=response.content.decode("utf-8"))
313-
dja_response = response.json()
314-
self.assertEqual(dja_response['errors'][0]['detail'],
315-
"invalid filter: filter[headline")
313+
self.assertEqual(dja_response['errors'][0]['detail'], "invalid query parameter: filter[")
316314

317315
def test_filter_missing_rvalue(self):
318316
"""
@@ -331,7 +329,7 @@ def test_filter_missing_rvalue_equal(self):
331329
"""
332330
test for filter with missing value to test against
333331
this should probably be an error rather than ignoring the filter:
334-
"""
332+
"""
335333
response = self.client.get(self.url + '?filter[headline]')
336334
self.assertEqual(response.status_code, 400,
337335
msg=response.content.decode("utf-8"))
@@ -478,3 +476,30 @@ def test_search_multiple_keywords(self):
478476
self.assertEqual(len(dja_response['data']), expected_len)
479477
returned_ids = set([k['id'] for k in dja_response['data']])
480478
self.assertEqual(returned_ids, expected_ids)
479+
480+
def test_param_invalid(self):
481+
"""
482+
Test a "wrong" query parameter
483+
"""
484+
response = self.client.get(self.url, data={'garbage': 'foo'})
485+
self.assertEqual(response.status_code, 400,
486+
msg=response.content.decode("utf-8"))
487+
dja_response = response.json()
488+
self.assertEqual(dja_response['errors'][0]['detail'],
489+
"invalid query parameter: garbage")
490+
491+
def test_param_duplicate(self):
492+
"""
493+
Test a duplicated query parameter:
494+
`?sort=headline&page[size]=3&sort=bodyText` is not allowed.
495+
This is not so obvious when using a data dict....
496+
"""
497+
response = self.client.get(self.url,
498+
data={'sort': ['headline', 'bodyText'],
499+
'page[size]': 3}
500+
)
501+
self.assertEqual(response.status_code, 400,
502+
msg=response.content.decode("utf-8"))
503+
dja_response = response.json()
504+
self.assertEqual(dja_response['errors'][0]['detail'],
505+
"repeated query parameter not allowed: sort")

‎example/views.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import rest_framework.exceptions as exceptions
22
import rest_framework.parsers
33
import rest_framework.renderers
4+
from rest_framework.filters import SearchFilter
45

56
import rest_framework_json_api.metadata
67
import rest_framework_json_api.parsers
78
import rest_framework_json_api.renderers
89
from django_filters import rest_framework as filters
10+
from rest_framework_json_api.django_filters import DjangoFilterBackend
11+
from rest_framework_json_api.filters import OrderingFilter, QueryParameterValidationFilter
912
from rest_framework_json_api.pagination import PageNumberPagination
1013
from rest_framework_json_api.utils import format_drf_errors
1114
from rest_framework_json_api.views import ModelViewSet, RelationshipView
@@ -91,6 +94,10 @@ class NoPagination(PageNumberPagination):
9194

9295
class NonPaginatedEntryViewSet(EntryViewSet):
9396
pagination_class = NoPagination
97+
# override the default filter backends in order to test QueryParameterValidationFilter without
98+
# breaking older usage of non-standard query params like `page_size`.
99+
filter_backends = (QueryParameterValidationFilter, OrderingFilter,
100+
DjangoFilterBackend, SearchFilter)
94101
ordering_fields = ('headline', 'body_text', 'blog__name', 'blog__id')
95102
rels = ('exact', 'iexact',
96103
'contains', 'icontains',

‎rest_framework_json_api/django_filters/backends.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ class DjangoFilterBackend(DjangoFilterBackend):
5252
filter_regex = re.compile(r'^filter(?P<ldelim>\[?)(?P<assoc>[\w\.\-]*)(?P<rdelim>\]?$)')
5353

5454
def _validate_filter(self, keys, filterset_class):
55+
"""
56+
Check that all the filter[key] are valid.
57+
58+
:param keys: list of FilterSet keys
59+
:param filterset_class: :py:class:`django_filters.rest_framework.FilterSet`
60+
:raises ValidationError: if key not in FilterSet keys or no FilterSet.
61+
"""
5562
for k in keys:
5663
if ((not filterset_class) or (k not in filterset_class.base_filters)):
5764
raise ValidationError("invalid filter[{}]".format(k))
@@ -75,6 +82,8 @@ def get_filterset_kwargs(self, request, queryset, view):
7582
"""
7683
Turns filter[<field>]=<value> into <field>=<value> which is what
7784
DjangoFilterBackend expects
85+
86+
:raises ValidationError: for bad filter syntax
7887
"""
7988
filter_keys = []
8089
# rewrite filter[field] query params to make DjangoFilterBackend work.
@@ -83,7 +92,7 @@ def get_filterset_kwargs(self, request, queryset, view):
8392
m = self.filter_regex.match(qp)
8493
if m and (not m.groupdict()['assoc'] or
8594
m.groupdict()['ldelim'] != '[' or m.groupdict()['rdelim'] != ']'):
86-
raise ValidationError("invalid filter: {}".format(qp))
95+
raise ValidationError("invalid query parameter: {}".format(qp))
8796
if m and qp != self.search_param:
8897
if not val:
8998
raise ValidationError("missing {} test value".format(qp))
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
from .sort import OrderingFilter # noqa: F401
2+
from .queryvalidation import QueryParameterValidationFilter # noqa: F401
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import re
2+
3+
from rest_framework.exceptions import ValidationError
4+
from rest_framework.filters import BaseFilterBackend
5+
6+
7+
class QueryParameterValidationFilter(BaseFilterBackend):
8+
"""
9+
A backend filter that performs strict validation of query parameters for
10+
jsonapi spec conformance and raises a 400 error if non-conforming usage is
11+
found.
12+
13+
If you want to add some additional non-standard query parameters,
14+
override :py:attr:`query_regex` adding the new parameters. Make sure to comply with
15+
the rules at http://jsonapi.org/format/#query-parameters.
16+
"""
17+
#: compiled regex that matches the allowed http://jsonapi.org/format/#query-parameters
18+
#: `sort` and `include` stand alone; `filter`, `fields`, and `page` have []'s
19+
query_regex = re.compile(r'^(sort|include)$|^(filter|fields|page)(\[[\w\.\-]+\])?$')
20+
21+
def validate_query_params(self, request):
22+
"""
23+
Validate that query params are in the list of valid query keywords
24+
Raises ValidationError if not.
25+
"""
26+
# TODO: For jsonapi error object conformance, must set jsonapi errors "parameter" for
27+
# the ValidationError. This requires extending DRF/DJA Exceptions.
28+
for qp in request.query_params.keys():
29+
if not self.query_regex.match(qp):
30+
raise ValidationError('invalid query parameter: {}'.format(qp))
31+
if len(request.query_params.getlist(qp)) > 1:
32+
raise ValidationError(
33+
'repeated query parameter not allowed: {}'.format(qp))
34+
35+
def filter_queryset(self, request, queryset, view):
36+
self.validate_query_params(request)
37+
return queryset

0 commit comments

Comments
(0)

AltStyle によって変換されたページ (->オリジナル) /