-
Notifications
You must be signed in to change notification settings - Fork 300
WIP: Filter Backends #455
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
WIP: Filter Backends #455
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,8 @@ | ||
-e . | ||
django>=2.0 | ||
django-debug-toolbar | ||
django-polymorphic>=2.0 | ||
django-filter>=2.0 | ||
factory-boy | ||
Faker | ||
isort | ||
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,164 @@ | ||
from rest_framework.exceptions import ValidationError | ||
from rest_framework.filters import BaseFilterBackend, OrderingFilter, SearchFilter | ||
from rest_framework.settings import api_settings | ||
|
||
from django_filters.rest_framework import DjangoFilterBackend | ||
from rest_framework_json_api.utils import format_value | ||
|
||
|
||
class JSONAPIFilterMixin(object): | ||
""" | ||
class to share data among filtering backends | ||
""" | ||
# search_param is used both in SearchFilter and JSONAPIFilterFilter | ||
search_param = api_settings.SEARCH_PARAM | ||
|
||
def __init__(self): | ||
self.filter_keys = [] | ||
|
||
|
||
class JSONAPIQueryValidationFilter(JSONAPIFilterMixin, BaseFilterBackend): | ||
""" | ||
A backend filter that validates query parameters for jsonapi spec conformance and raises a 400 | ||
error rather than silently ignoring unknown parameters or incorrect usage. | ||
|
||
set `allow_duplicate_filters = True` if you are OK with the same filter being repeated. | ||
|
||
TODO: For jsonapi error object compliance, must set jsonapi errors "parameter" for the | ||
ValidationError. This requires extending DRF/DJA Exceptions. | ||
""" | ||
jsonapi_query_keywords = ('sort', 'filter', 'fields', 'page', 'include') | ||
jsonapi_allow_duplicated_filters = False | ||
|
||
def validate_query_params(self, request): | ||
""" | ||
Validate that query params are in the list of valid `jsonapi_query_keywords` | ||
Raises ValidationError if not. | ||
""" | ||
for qp in request.query_params.keys(): | ||
bracket = qp.find('[') | ||
if bracket >= 0: | ||
if qp[-1] != ']': | ||
raise ValidationError( | ||
'invalid query parameter: {}'.format(qp)) | ||
keyword = qp[:bracket] | ||
else: | ||
keyword = qp | ||
if keyword not in self.jsonapi_query_keywords: | ||
raise ValidationError( | ||
'invalid query parameter: {}'.format(keyword)) | ||
if not self.jsonapi_allow_duplicated_filters \ | ||
and len(request.query_params.getlist(qp)) > 1: | ||
raise ValidationError( | ||
'repeated query parameter not allowed: {}'.format(qp)) | ||
|
||
def filter_queryset(self, request, queryset, view): | ||
self.validate_query_params(request) | ||
return queryset | ||
|
||
|
||
class JSONAPIOrderingFilter(JSONAPIFilterMixin, OrderingFilter): | ||
""" | ||
The standard rest_framework.filters.OrderingFilter works mostly fine as is, | ||
but with .ordering_param = 'sort'. | ||
|
||
This implements http://jsonapi.org/format/#fetching-sorting and raises 400 | ||
if any sort field is invalid. | ||
""" | ||
jsonapi_ignore_bad_sort_fields = False | ||
ordering_param = 'sort' | ||
|
||
def remove_invalid_fields(self, queryset, fields, view, request): | ||
""" | ||
override remove_invalid_fields to raise a 400 exception instead of silently removing them. | ||
""" | ||
valid_fields = [item[0] for item | ||
in self.get_valid_fields(queryset, view, {'request': request})] | ||
bad_terms = [term for term | ||
in fields if format_value(term.lstrip('-'), "underscore") not in valid_fields] | ||
if bad_terms and not self.jsonapi_ignore_bad_sort_fields: | ||
raise ValidationError( | ||
'invalid sort parameter{}: {}'.format(('s' if len(bad_terms) > 1 else ''), | ||
','.join(bad_terms))) | ||
return super(JSONAPIOrderingFilter, self).remove_invalid_fields(queryset, | ||
fields, view, request) | ||
|
||
|
||
class JSONAPISearchFilter(JSONAPIFilterMixin, SearchFilter): | ||
""" | ||
The (multi-field) rest_framework.filters.SearchFilter works just fine as is, but with a | ||
defined `filter[NAME]` such as `filter[all]` or `filter[_all_]` or something like that. | ||
|
||
This is not part of the jsonapi standard per-se, other than the requirement to use the `filter` | ||
keyword: This is an optional implementation of a style of filtering in which a single filter | ||
can implement a keyword search across multiple fields of a model as implemented by SearchFilter. | ||
""" | ||
pass | ||
|
||
|
||
class JSONAPIFilterFilter(JSONAPIFilterMixin, DjangoFilterBackend): | ||
""" | ||
Overrides django_filters.rest_framework.DjangoFilterBackend to use `filter[field]` query | ||
parameter. | ||
|
||
This is not part of the jsonapi standard per-se, other than the requirement to use the `filter` | ||
keyword: This is an optional implementation of style of filtering in which each filter is an ORM | ||
expression as implemented by DjangoFilterBackend and seems to be in alignment with an | ||
interpretation of http://jsonapi.org/recommendations/#filtering, including relationship | ||
chaining. | ||
|
||
Filters can be: | ||
- A resource field equality test: | ||
`?filter[foo]=123` | ||
- Apply other relational operators: | ||
`?filter[foo.in]=bar,baz or ?filter[count.ge]=7...` | ||
- Membership in a list of values (OR): | ||
`?filter[foo]=abc,123,zzz (foo in ['abc','123','zzz'])` | ||
- Filters can be combined for intersection (AND): | ||
`?filter[foo]=123&filter[bar]=abc,123,zzz&filter[...]` | ||
- A related resource field for above tests: | ||
`?filter[foo.rel.baz]=123 (where `rel` is the relationship name)` | ||
|
||
It is meaningless to intersect the same filter: ?filter[foo]=123&filter[foo]=abc will | ||
always yield nothing so detect this repeated appearance of the same filter in | ||
JSONAPIQueryValidationFilter and complain there. | ||
""" | ||
|
||
def get_filterset(self, request, queryset, view): | ||
""" | ||
Validate that the `filter[field]` is defined in the filters and raise ValidationError if | ||
it's missing. | ||
|
||
While `filter` syntax and semantics is undefined by the jsonapi 1.0 spec, this behavior is | ||
consistent with the style used for missing query parameters: | ||
http://jsonapi.org/format/#query-parameters. In general, unlike django/DRF, jsonapi | ||
raises 400 rather than ignoring "bad" query parameters. | ||
""" | ||
fs = super(JSONAPIFilterFilter, self).get_filterset(request, queryset, view) | ||
# TODO: change to have option to silently ignore bad filters | ||
for k in self.filter_keys: | ||
if k not in fs.filters: | ||
raise ValidationError("invalid filter[{}]".format(k)) | ||
return fs | ||
|
||
def get_filterset_kwargs(self, request, queryset, view): | ||
""" | ||
Turns filter[<field>]=<value> into <field>=<value> which is what DjangoFilterBackend expects | ||
""" | ||
self.filter_keys = [] | ||
# rewrite `filter[field]` query parameters to make DjangoFilterBackend work. | ||
data = request.query_params.copy() | ||
for qp, val in data.items(): | ||
if qp[:7] == 'filter[' and qp[-1] == ']' and qp != self.search_param: | ||
# convert jsonapi relationship path to Django ORM's __ notation: | ||
key = qp[7:-1].replace('.', '__') | ||
# undo JSON_API_FORMAT_FIELD_NAMES conversion: | ||
key = format_value(key, 'underscore') | ||
data[key] = val | ||
self.filter_keys.append(key) | ||
del data[qp] | ||
return { | ||
'data': data, | ||
'queryset': queryset, | ||
'request': request, | ||
} |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe a regex would be better here such as:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was trying to care about performance as this code executes for every request. Also the match would be something more complicated since it's searching for all keywords, whether or not they have brackets (e.g.
filter[foo], sort, ...
). It seems a regex is overkill and detracts from readability.