├── .flake8 ├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── drf_jsonmask ├── __init__.py ├── constants.py ├── decorators.py ├── serializers.py ├── utils.py └── views.py ├── hatch.toml ├── pyproject.toml └── tests ├── __init__.py ├── conftest.py ├── factories.py ├── models.py ├── serializers.py ├── settings.py ├── test_field_pruning.py ├── test_views.py ├── urls.py ├── utils.py └── views.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 119 3 | extend-exclude = 4 | .venv/ 5 | mypy_cache/ 6 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | pull_request: 7 | branches: ["master"] 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | lint: 15 | name: style 16 | runs-on: ubuntu-latest 17 | env: 18 | HATCH_ENV: style 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v3 22 | 23 | - name: Set up Python 24 | uses: actions/setup-python@v4 25 | with: 26 | python-version: "3.12" 27 | 28 | - name: Setup Hatch 29 | run: pipx install hatch 30 | 31 | - name: Run linters 32 | run: | 33 | hatch env run check 34 | 35 | test: 36 | name: Run tests 37 | runs-on: ubuntu-20.04 38 | env: 39 | HATCH_ENV: test 40 | strategy: 41 | fail-fast: false 42 | matrix: 43 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 44 | steps: 45 | - uses: actions/checkout@v3 46 | 47 | - name: Set up Python ${{ matrix.python-version }} 48 | uses: actions/setup-python@v4 49 | with: 50 | python-version: ${{ matrix.python-version }} 51 | 52 | - name: Setup Hatch 53 | run: | 54 | pipx install hatch 55 | 56 | - name: Run tests 57 | run: | 58 | hatch env run -i py=${{ matrix.python-version }} test 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 88 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 89 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 90 | # install all needed dependencies. 91 | #Pipfile.lock 92 | 93 | # celery beat schedule file 94 | celerybeat-schedule 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # Environments 100 | .env 101 | .venv 102 | env/ 103 | venv/ 104 | ENV/ 105 | env.bak/ 106 | venv.bak/ 107 | 108 | # Spyder project settings 109 | .spyderproject 110 | .spyproject 111 | 112 | # Rope project settings 113 | .ropeproject 114 | 115 | # mkdocs documentation 116 | /site 117 | 118 | # mypy 119 | .mypy_cache/ 120 | .dmypy.json 121 | dmypy.json 122 | 123 | # Pyre type checker 124 | .pyre/ 125 | 126 | .idea/ 127 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, Craig Labenz 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/jllorencetti/drf-jsonmask.svg?branch=master)](https://travis-ci.org/jllorencetti/drf-jsonmask.svg) 2 | [![Coverage Status](https://coveralls.io/repos/github/jllorencetti/drf-jsonmask/badge.svg?branch=master)](https://coveralls.io/github/jllorencetti/drf-jsonmask?branch=master) 3 | [![PyPI Version](https://img.shields.io/pypi/v/drf-jsonmask.svg)](https://pypi.org/project/drf-jsonmask) 4 | 5 | --- 6 | 7 | ## Overview 8 | 9 | Forked from https://github.com/zapier/django-rest-framework-jsonmask. 10 | 11 | Implements Google's Partial Response in Django REST Framework. 12 | 13 | ## Requirements 14 | 15 | * Python (3.8, 3.9, 3.10, 3.11) 16 | * Django (4.2, 5.0) 17 | * Django REST framework (3.14) 18 | 19 | ## Installation 20 | 21 | Install using `pip`... 22 | 23 | ```bash 24 | $ pip install drf-jsonmask 25 | ``` 26 | 27 | ## Example 28 | 29 | Most DRF addons that support `?fields=`-style data pruning do so purely at the serializaton layer. Many hydrate full ORM objects, including all of their verbose relationships, and then cut unwanted data immediately before JSON serialization. Any unwanted related data is still fetched from the database and hydrated into Django ORM objects, which severely undermines the usefulness of field pruning. 30 | 31 | `drf_jsonmask` aims to do one better by allowing developers to declaratively augment their queryset in direct relation to individual requests. Under this pattern, you only declare the base queryset and any universal relationships on your ViewSet.queryset, leaving all additional enhancements as runtime opt-ins. 32 | 33 | To use `drf_jsonmask`, first include its ViewSet and Serializer mixins in your code where appropriate. The following examples are taken from the mini-project used in this library's own unit tests. 34 | 35 | ```py 36 | # api/views.py 37 | from drf_jsonmask.views import OptimizedQuerySetMixin 38 | 39 | class TicketViewSet(OptimizedQuerySetMixin, viewsets.ReadOnlyModelViewSet): 40 | 41 | # Normally, for optimal performance, you would apply the `select_related('author')` 42 | # call to the base queryset, but that is no longer desireable for data relationships 43 | # that your frontend may stop asking for. 44 | queryset = Ticket.objects.all() 45 | serializer_class = TicketSerializer 46 | 47 | # Data-predicate declaration is optional, but encouraged. This 48 | # is where the library really shines! 49 | @data_predicate('author') 50 | def load_author(self, queryset): 51 | return queryset.select_related('author') 52 | 53 | 54 | # api/serializers.py 55 | from drf_jsonmask.serializers import FieldsListSerializerMixin 56 | 57 | class TicketSerializer(FieldsListSerializerMixin, serializers.ModelSerializer): 58 | # Aside from the mixin, everything else is exactly like normal 59 | 60 | author = UserSerializer() 61 | 62 | class Meta: 63 | models = my_module.models.Ticket 64 | fields = ('id', 'title', 'body', 'author',) 65 | ``` 66 | 67 | You have now set up your API to skip unnecessary joins (and possibly prefetches), unless the requesting client requires that data. Let's consider a few hypothetical requests and the responses they will each receive. (For brevity, in each of these examples, I will pretend pagination is turned off.) 68 | 69 | ```http 70 | GET /api/tickets/ 71 | 72 | 200 OK 73 | [ 74 | { 75 | "id": 1, 76 | "title": "This is a ticket", 77 | "body": "This is its text", 78 | "author": { 79 | "id": 5, 80 | "username": "HomerSimpson", 81 | } 82 | } 83 | ] 84 | ``` 85 | 86 | Because no `?fields` querystring parameter was provided, author records were still loaded and serialized like normal. 87 | 88 | > Note: `drf_jsonmask` treats all requests that lack any field definition as if all possible data is requested, and thus executes all data predicates. In the above example, `author` data was loaded via `selected_related('author')`, and _not_ N+1 queries. 89 | 90 | --- 91 | 92 | ```http 93 | GET /api/tickets/?fields=id,title,body 94 | 95 | 200 OK 96 | [ 97 | { 98 | "id": 1, 99 | "title": "This is a ticket", 100 | "body": "This is its text" 101 | } 102 | ] 103 | ``` 104 | 105 | In this example, since `author` was not specified, it was not only not returned in the response payload - it was never queried for or serialized in the first place. 106 | 107 | --- 108 | 109 | ```http 110 | GET /api/tickets/?fields=id,title,body,author/username 111 | 112 | 200 OK 113 | [ 114 | { 115 | "id": 1, 116 | "title": "This is a ticket", 117 | "body": "This is its text", 118 | "author": { 119 | "username": "HomerSimpson", 120 | } 121 | } 122 | ] 123 | ``` 124 | 125 | In this example, `author` data was loaded via the `?fields` declaration, but no unwanted keys will appear in the response. 126 | 127 | 128 | #### Nested Relationships 129 | 130 | This is all good and fun, but what if `author` has rarely used but expensive relationships, too? `drf_jsonmask` supports this, via the exact same mechanisms spelled out above, though sometimes a little extra attention to detail can be important. Let's now imagine that `AuthorSerializer` looks like this: 131 | 132 | ```py 133 | class AuthorSerializer(FieldsListSerializerMixin, serializers.ModelSerializer): 134 | 135 | accounts = AccountSerializer(many=True) 136 | 137 | class Meta: 138 | model = settings.AUTH_USER_MODEL 139 | fields = ('id', 'username', 'email', 'photo', 'accounts', ...) 140 | ``` 141 | 142 | Naturally, if `accounts` is sensitive, internal data, you simply might not use _this_ serializer for external API consumption. Of course, that would solve your problem about how to decide whether to serialize `accounts` data -- the supplied serializer would know nothing about that field! But, let's pretend that in our case, `accounts` is safe for public consumption, and _some_ ticketing API calls require it for triaging purposes, whereas others do not. In such a situation, we'll redefine our ViewSet like so: 143 | 144 | ```py 145 | class TicketViewSet(OptimizedQuerySetMixin, viewsets.ReadOnlyModelViewSet): 146 | 147 | queryset = Ticket.objects.all() 148 | serializer_class = TicketSerializer 149 | 150 | @data_predicate('author') 151 | def load_author(self, queryset): 152 | return queryset.select_related('author') 153 | 154 | # Add this extra data_predicate with prefetches `accounts` if and only if 155 | # the requests promises to use that information 156 | @data_predicate('author.accounts') 157 | def load_author_with_accounts(self, queryset): 158 | return queryset.select_related('author').prefetch_related('author__accounts') 159 | ``` 160 | 161 | Now, it is up to the client to decide which of the following options (or anything else imaginable) is most appropriate: 162 | 163 | ```http 164 | # Includes specified local fields plus all author fields and relationships 165 | 166 | GET /api/tickets/?fields=id,title,author 167 | 168 | 200 OK 169 | [ 170 | { 171 | "id": 1, 172 | "title": "This is a ticket", 173 | "author": { 174 | "id": 5, 175 | "username": "HomerSimpson", 176 | "accounts": [ 177 | {"all_fields": "will_be_present"} 178 | ] 179 | } 180 | } 181 | ] 182 | ``` 183 | 184 | or 185 | 186 | 187 | ```http 188 | # Includes specified local fields plus specified author fields and relationships 189 | 190 | GET /api/tickets/?fields=id,title,author(username,photo) 191 | 192 | 200 OK 193 | [ 194 | { 195 | "id": 1, 196 | "title": "This is a ticket", 197 | "author": { 198 | "username": "HomerSimpson", 199 | "photo": "image_url" 200 | } 201 | } 202 | ] 203 | ``` 204 | 205 | or 206 | 207 | ```http 208 | # Includes specified local fields plus specified author fields and relationships plus specified accounts fields and relationships 209 | 210 | GET /api/tickets/?fields=id,title,author(id,accounts(id,type_of,date)) 211 | 212 | 200 OK 213 | [ 214 | { 215 | "id": 1, 216 | "title": "This is a ticket", 217 | "author": { 218 | "id": 5, 219 | "accounts": [ 220 | { 221 | "id": 8001, 222 | "type_of": "business", 223 | "date": "2018-01-01T12:00:00Z" 224 | }, 225 | { 226 | "id": 6500, 227 | "type_of": "trial", 228 | "date": "2017-06-01T12:00:00Z" 229 | } 230 | ] 231 | } 232 | } 233 | ] 234 | ``` 235 | 236 | In short, know that as long as the entire chain of Serializers implements the `FieldsListSerializerMixin`, arbitrarily deep nesting of `?fields` declarations will be honored. However, in practice, because relationships are expensive to hydrate, you will probably want to limit that information and control what data you actually load using the `@data_predicate` decorator on ViewSet methods. 237 | 238 | 239 | ## Testing 240 | 241 | ```bash 242 | $ make tests 243 | ``` 244 | 245 | or keep them running on change: 246 | 247 | ```bash 248 | $ make watch 249 | ``` 250 | 251 | You can also use the excellent [tox](http://tox.readthedocs.org/en/latest/) testing tool to run the tests against all supported versions of Python and Django. Install tox globally, and then simply run: 252 | 253 | ```bash 254 | $ tox 255 | ``` 256 | 257 | ## Documentation 258 | 259 | ```bash 260 | $ make docs 261 | ``` 262 | -------------------------------------------------------------------------------- /drf_jsonmask/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for drf-jsonmask""" 2 | 3 | __project__ = "drf_jsonmask" 4 | __version__ = "0.4.0" 5 | 6 | VERSION = __project__ + '-' + __version__ 7 | -------------------------------------------------------------------------------- /drf_jsonmask/constants.py: -------------------------------------------------------------------------------- 1 | EXCLUDES_NAME = 'excludes' 2 | FIELDS_NAME = 'fields' 3 | -------------------------------------------------------------------------------- /drf_jsonmask/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | 4 | def data_predicate(*field_names): 5 | def _data_predicate(fnc): 6 | fnc._data_function_predicates = field_names 7 | 8 | @wraps(fnc) 9 | def inner(self, *args, **kwargs): 10 | return fnc(self, *args, **kwargs) 11 | 12 | return inner 13 | 14 | return _data_predicate 15 | -------------------------------------------------------------------------------- /drf_jsonmask/serializers.py: -------------------------------------------------------------------------------- 1 | from jsonmask import should_include_variable 2 | 3 | from .utils import collapse_includes_excludes 4 | 5 | 6 | class FieldsListSerializerMixin: 7 | 8 | @property 9 | def _readable_fields(self): 10 | readable_fields = super()._readable_fields 11 | return self.prune_readable_fields(readable_fields) 12 | 13 | def prune_readable_fields(self, readable_fields): 14 | requested_fields = self._context.get('requested_fields') or {} 15 | excluded_fields = self._context.get('excluded_fields') or {} 16 | 17 | if not requested_fields and not excluded_fields: 18 | return readable_fields 19 | 20 | structure, is_negated = collapse_includes_excludes( 21 | requested_fields, excluded_fields, 22 | ) 23 | 24 | pruned_fields = [ 25 | field 26 | for field in readable_fields 27 | if should_include_variable( 28 | field.field_name, structure, is_negated=is_negated, 29 | ) 30 | ] 31 | 32 | for field in pruned_fields: 33 | field._context = self._context.copy() 34 | 35 | field._context['requested_fields'] = requested_fields.copy().get( 36 | field.field_name, 37 | ) 38 | field._context['excluded_fields'] = excluded_fields.copy().get( 39 | field.field_name, 40 | ) 41 | 42 | if hasattr(field, 'child'): 43 | field.child._context = field._context.copy() 44 | 45 | return pruned_fields 46 | -------------------------------------------------------------------------------- /drf_jsonmask/utils.py: -------------------------------------------------------------------------------- 1 | def collapse_includes_excludes(includes, excludes): 2 | """ 3 | :includes: dict Possible parsed `?fields=` data 4 | :excludes: dict Possible parsed `?excludes=` data 5 | 6 | :returns: tuple (dict, bool,) 7 | Where dict is includes or excludes, whichever 8 | was Truthy, and bool is `is_negated` -- aka, 9 | True if `excludes` was the Truthy val 10 | """ 11 | if includes: 12 | return includes, False 13 | return excludes, True 14 | -------------------------------------------------------------------------------- /drf_jsonmask/views.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.utils.functional import cached_property 3 | from jsonmask import parse_fields, should_include_variable 4 | from rest_framework import exceptions 5 | 6 | from . import constants 7 | from .utils import collapse_includes_excludes 8 | 9 | 10 | class OptimizedQuerySetBase(type): 11 | def __new__(cls, name, bases, attrs): 12 | new_cls = super().__new__(cls, name, bases, attrs) 13 | 14 | data_predicates = {} 15 | for base in bases: 16 | if hasattr(base, '_data_predicates'): 17 | data_predicates.update(getattr(base, '_data_predicates')) 18 | 19 | data_predicates.update(new_cls.extract_data_predicates(attrs)) 20 | new_cls._data_predicates = data_predicates 21 | return new_cls 22 | 23 | def extract_data_predicates(cls, attrs): 24 | data_predicates = {} 25 | for key, value in attrs.items(): 26 | if hasattr(value, '_data_function_predicates'): 27 | for data_function_predicate in value._data_function_predicates: 28 | data_predicates[data_function_predicate] = value 29 | return data_predicates 30 | 31 | 32 | class OptimizedQuerySetMixin(metaclass=OptimizedQuerySetBase): 33 | """ 34 | Allows a Google Partial Response query param like to prune results 35 | """ 36 | 37 | def get_serializer_context(self): 38 | context = super().get_serializer_context() 39 | 40 | fields_name = getattr(settings, 'DRF_JSONMASK_FIELDS_NAME', constants.FIELDS_NAME) 41 | excludes_name = getattr(settings, 'DRF_JSONMASK_EXCLUDES_NAME', constants.EXCLUDES_NAME) 42 | 43 | if fields_name in self.request.GET and excludes_name in self.request.GET: 44 | raise exceptions.ParseError( 45 | detail='Cannot provide both "{}" and "{}"'.format(fields_name, excludes_name) 46 | ) 47 | 48 | if fields_name in self.request.GET: 49 | context['requested_fields'] = self.requested_fields 50 | elif excludes_name in self.request.GET: 51 | context['excluded_fields'] = self.excluded_fields 52 | 53 | return context 54 | 55 | @cached_property 56 | def requested_fields(self): 57 | fields_name = getattr(settings, 'DRF_JSONMASK_FIELDS_NAME', constants.FIELDS_NAME) 58 | return parse_fields(self.request.GET.get(fields_name)) 59 | 60 | @cached_property 61 | def excluded_fields(self): 62 | excludes_name = getattr(settings, 'DRF_JSONMASK_EXCLUDES_NAME', constants.EXCLUDES_NAME) 63 | return parse_fields(self.request.GET.get(excludes_name)) 64 | 65 | def optimize_queryset(self, queryset): 66 | if self.requested_fields and self.excluded_fields: 67 | raise exceptions.ParseError('Cannot provide both `fields` and `excludes`') 68 | 69 | if self.requested_fields or self.excluded_fields: 70 | return self.apply_requested_data_functions( 71 | queryset, self.requested_fields, self.excluded_fields 72 | ) 73 | return self.apply_all_data_functions(queryset) 74 | 75 | def apply_requested_data_functions(self, queryset, fields, excludes): 76 | requested_structure, is_negated = collapse_includes_excludes(fields, excludes) 77 | for dotted_path, data_function in self._data_predicates.items(): 78 | if should_include_variable( 79 | dotted_path, requested_structure, is_negated=is_negated 80 | ): 81 | queryset = data_function(self, queryset) 82 | return queryset 83 | 84 | def apply_all_data_functions(self, queryset): 85 | for _, data_function in self._data_predicates.items(): 86 | queryset = data_function(self, queryset) 87 | return queryset 88 | 89 | def get_queryset(self): 90 | queryset = super().get_queryset() 91 | return self.optimize_queryset(queryset) 92 | -------------------------------------------------------------------------------- /hatch.toml: -------------------------------------------------------------------------------- 1 | [envs.default] 2 | dependencies = [ 3 | "coverage[toml]", 4 | "factory-boy", 5 | "jsonmask", 6 | ] 7 | 8 | [envs.style] 9 | detached = true 10 | dependencies = [ 11 | "flake8", 12 | "isort", 13 | ] 14 | [envs.style.scripts] 15 | check = [ 16 | "flake8 .", 17 | "isort --check-only --diff .", 18 | ] 19 | fmt = [ 20 | "isort .", 21 | "check", 22 | ] 23 | 24 | [envs.test.scripts] 25 | test = [ 26 | "coverage run -m django test -v2 --settings=tests.settings", 27 | "coverage report", 28 | ] 29 | 30 | [envs.test.overrides] 31 | matrix.django.dependencies = [ 32 | { value = "django>=4.2,<5.0", if = ["4.2"] }, 33 | { value = "django>=5.0,<5.1", if = ["5.0"] }, 34 | ] 35 | matrix.drf.dependencies = [ 36 | { value = "djangorestframework>=3.14,<3.15", if = ["3.14"] }, 37 | ] 38 | 39 | [[envs.test.matrix]] 40 | python = ["3.8", "3.9", "3.10", "3.11", "3.12"] 41 | django = ["4.2"] 42 | 43 | [[envs.test.matrix]] 44 | python = ["3.10", "3.11", "3.12"] 45 | django = ["5.0"] 46 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "drf-jsonmask" 7 | dynamic = ["version"] 8 | description = "Implements Google's partial response in Django RestFramework (Fork from Zapier's package)" 9 | readme = "README.md" 10 | requires-python = ">=3.8" 11 | authors = [ 12 | { name = "Craig Labenz", email = "craig.labenz@zapier.com" }, 13 | { name = "João Luiz Lorencetti", email = "jll.linux@gmail.com" }, 14 | ] 15 | maintainers = [ 16 | { name = "Christian Hartung", email = "hartungstenio@outlook.com" }, 17 | ] 18 | license = { file = "LICENSE" } 19 | classifiers = [ 20 | "Development Status :: 4 - Beta", 21 | "Environment :: Web Environment", 22 | "Framework :: Django", 23 | "Framework :: Django :: 4.2", 24 | "Framework :: Django :: 5.0", 25 | "Intended Audience :: Developers", 26 | "License :: OSI Approved :: BSD License", 27 | "Operating System :: OS Independent", 28 | "Natural Language :: English", 29 | "Programming Language :: Python :: 3 :: Only", 30 | "Programming Language :: Python :: 3.8", 31 | "Programming Language :: Python :: 3.9", 32 | "Programming Language :: Python :: 3.10", 33 | "Programming Language :: Python :: 3.11", 34 | "Programming Language :: Python :: 3.12", 35 | "Topic :: Internet :: WWW/HTTP", 36 | "Topic :: Software Development :: Libraries :: Python Modules", 37 | ] 38 | dependencies = [ 39 | "jsonmask", 40 | "django>=4.2", 41 | "djangorestframework>=3.14", 42 | ] 43 | 44 | [project.urls] 45 | Repository = "https://github.com/jllorencetti/drf-jsonmask.git" 46 | 47 | [tool.hatch.version] 48 | path = "drf_jsonmask/__init__.py" 49 | 50 | [tool.coverage.run] 51 | source = ["drf_jsonmask"] 52 | branch = true 53 | 54 | [tool.isort] 55 | line_length = 119 56 | combine_as_imports = true 57 | include_trailing_comma = true 58 | multi_line_output = 5 59 | default_section = "THIRDPARTY" 60 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jllorencetti/drf-jsonmask/1d9a004eb9647098c031c6efbebe30a192fb8c72/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | def pytest_configure(): 2 | from django.conf import settings 3 | 4 | settings.configure( 5 | DEBUG_PROPAGATE_EXCEPTIONS=True, 6 | DATABASES={'default': {'ENGINE': 'django.db.backends.sqlite3', 7 | 'NAME': ':memory:'}}, 8 | SITE_ID=1, 9 | SECRET_KEY='not very secret in tests', 10 | USE_I18N=True, 11 | USE_L10N=True, 12 | STATIC_URL='/static/', 13 | ROOT_URLCONF='tests.urls', 14 | TEMPLATE_LOADERS=( 15 | 'django.template.loaders.filesystem.Loader', 16 | 'django.template.loaders.app_directories.Loader', 17 | ), 18 | MIDDLEWARE_CLASSES=( 19 | 'django.middleware.common.CommonMiddleware', 20 | 'django.contrib.sessions.middleware.SessionMiddleware', 21 | 'django.middleware.csrf.CsrfViewMiddleware', 22 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 23 | 'django.contrib.messages.middleware.MessageMiddleware', 24 | ), 25 | INSTALLED_APPS=( 26 | 'django.contrib.auth', 27 | 'django.contrib.contenttypes', 28 | 'django.contrib.sessions', 29 | 'django.contrib.sites', 30 | 'django.contrib.messages', 31 | 'django.contrib.staticfiles', 32 | 33 | 'rest_framework', 34 | 'rest_framework.authtoken', 35 | 'tests', 36 | ), 37 | PASSWORD_HASHERS=( 38 | 'django.contrib.auth.hashers.SHA1PasswordHasher', 39 | 'django.contrib.auth.hashers.PBKDF2PasswordHasher', 40 | 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', 41 | 'django.contrib.auth.hashers.BCryptPasswordHasher', 42 | 'django.contrib.auth.hashers.MD5PasswordHasher', 43 | 'django.contrib.auth.hashers.CryptPasswordHasher', 44 | ), 45 | ) 46 | 47 | try: 48 | import oauth2 # NOQA 49 | import oauth_provider # NOQA 50 | except ImportError: 51 | pass 52 | else: 53 | settings.INSTALLED_APPS += ( 54 | 'oauth_provider', 55 | ) 56 | 57 | try: 58 | import provider # NOQA 59 | except ImportError: 60 | pass 61 | else: 62 | settings.INSTALLED_APPS += ( 63 | 'provider', 64 | 'provider.oauth2', 65 | ) 66 | 67 | # guardian is optional 68 | try: 69 | import guardian # NOQA 70 | except ImportError: 71 | pass 72 | else: 73 | settings.ANONYMOUS_USER_ID = -1 74 | settings.AUTHENTICATION_BACKENDS = ( 75 | 'django.contrib.auth.backends.ModelBackend', 76 | 'guardian.backends.ObjectPermissionBackend', 77 | ) 78 | settings.INSTALLED_APPS += ( 79 | 'guardian', 80 | ) 81 | 82 | try: 83 | import django 84 | django.setup() 85 | except AttributeError: 86 | pass 87 | -------------------------------------------------------------------------------- /tests/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | import factory.fuzzy 3 | from django.conf import settings 4 | from django.utils import timezone 5 | 6 | from .models import Comment, Ticket 7 | 8 | 9 | class UserFactory(factory.django.DjangoModelFactory): 10 | 11 | username = factory.fuzzy.FuzzyText(length=12) 12 | email = factory.LazyAttribute(lambda o: '%s@example.org' % o.username) 13 | last_login = factory.LazyFunction(timezone.now) 14 | is_active = True 15 | 16 | class Meta: 17 | model = settings.AUTH_USER_MODEL 18 | 19 | 20 | class TicketFactory(factory.django.DjangoModelFactory): 21 | 22 | title = factory.fuzzy.FuzzyText(length=64) 23 | body = factory.fuzzy.FuzzyText(length=64) 24 | author = factory.SubFactory(UserFactory) 25 | 26 | class Meta: 27 | model = Ticket 28 | 29 | 30 | class CommentFactory(factory.django.DjangoModelFactory): 31 | 32 | body = factory.fuzzy.FuzzyText(length=64) 33 | author = factory.SubFactory(UserFactory) 34 | ticket = factory.SubFactory(TicketFactory) 35 | 36 | class Meta: 37 | model = Comment 38 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | 4 | 5 | class BaseModel(models.Model): 6 | created_at = models.DateTimeField(auto_now_add=True) 7 | modified_at = models.DateTimeField(auto_now=True) 8 | 9 | class Meta: 10 | abstract = True 11 | 12 | 13 | class Ticket(BaseModel): 14 | title = models.CharField(max_length=255) 15 | body = models.TextField() 16 | author = models.ForeignKey( 17 | settings.AUTH_USER_MODEL, 18 | on_delete=models.deletion.SET_NULL, 19 | related_name='tickets', 20 | # Normally you wouldn't allow this to be null, 21 | # but it'd be unfortunate to lose a bunch of tickets 22 | # if a user account gets deleted 23 | null=True, 24 | blank=True, 25 | default=None, 26 | ) 27 | 28 | def __str__(self): 29 | return self.title 30 | 31 | 32 | class Comment(BaseModel): 33 | ticket = models.ForeignKey(Ticket, on_delete=models.deletion.CASCADE, related_name='comments') 34 | body = models.TextField() 35 | author = models.ForeignKey( 36 | settings.AUTH_USER_MODEL, 37 | on_delete=models.deletion.SET_NULL, 38 | related_name='comments', 39 | # Normally you wouldn't allow this to be null, 40 | # but it'd be unfortunate to lose a bunch of tickets 41 | # if a user account gets deleted 42 | null=True, 43 | blank=True, 44 | default=None, 45 | ) 46 | 47 | def __str__(self): 48 | return self.body[:50] 49 | -------------------------------------------------------------------------------- /tests/serializers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from rest_framework import serializers 3 | 4 | from drf_jsonmask.serializers import FieldsListSerializerMixin 5 | 6 | from .models import Comment, Ticket 7 | 8 | 9 | class UserSerializer(FieldsListSerializerMixin, serializers.ModelSerializer): 10 | 11 | class Meta: 12 | model = get_user_model() 13 | fields = ('username', 'email',) 14 | 15 | 16 | class CommentSerializer(FieldsListSerializerMixin, serializers.ModelSerializer): 17 | 18 | author = UserSerializer() 19 | 20 | class Meta: 21 | model = Comment 22 | fields = ('body', 'author') 23 | 24 | 25 | class TicketSerializer(FieldsListSerializerMixin, serializers.ModelSerializer): 26 | 27 | author = UserSerializer() 28 | comments = CommentSerializer(many=True) 29 | 30 | class Meta: 31 | model = Ticket 32 | fields = ('title', 'body', 'author', 'comments',) 33 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = 'not-secret-anymore' 2 | 3 | TIME_ZONE = 'UTC' 4 | USE_TZ = True 5 | 6 | DATABASES = { 7 | 'default': { 8 | 'ENGINE': 'django.db.backends.sqlite3', 9 | }, 10 | } 11 | 12 | ROOT_URLCONF = 'tests.urls' 13 | 14 | INSTALLED_APPS = [ 15 | 'django.contrib.auth', 16 | 'django.contrib.contenttypes', 17 | 'rest_framework', 18 | 'drf_jsonmask', 19 | 'tests', 20 | ] 21 | 22 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' 23 | -------------------------------------------------------------------------------- /tests/test_field_pruning.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.urls import reverse 3 | 4 | 5 | class TestRawFieldPruning(TestCase): 6 | """ 7 | Tests that endpoints can easily be 8 | """ 9 | 10 | def test_default_rest_framework_behavior(self): 11 | """ 12 | This is more of an example really, showing default behavior 13 | """ 14 | url = reverse('raw-data') 15 | response = self.client.get('%s?fields=a,b' % url) 16 | self.assertEqual(200, response.status_code) 17 | 18 | expected = { 19 | 'a': 'test', 20 | 'b': { 21 | 'nested': 'test', 22 | }, 23 | } 24 | 25 | assert expected == response.json() 26 | 27 | response = self.client.get('%s?fields=d/a/d' % url) 28 | self.assertEqual(200, response.status_code) 29 | 30 | expected = { 31 | 'd': { 32 | 'a': { 33 | 'd': 'value', 34 | }, 35 | }, 36 | } 37 | 38 | assert expected == response.json() 39 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AnonymousUser 2 | from django.test import RequestFactory, TestCase, override_settings 3 | from django.urls import reverse 4 | 5 | from . import factories, views 6 | 7 | 8 | class DataMixin: 9 | 10 | def setUp(self): 11 | super().setUp() 12 | 13 | self.t1 = factories.TicketFactory() 14 | self.t1c1 = factories.CommentFactory( 15 | ticket=self.t1, 16 | ) 17 | self.t1c2 = factories.CommentFactory( 18 | author=self.t1.author, 19 | ticket=self.t1, 20 | ) 21 | 22 | self.t2 = factories.TicketFactory() 23 | self.t2c1 = factories.CommentFactory( 24 | ticket=self.t2, 25 | ) 26 | self.t2c2 = factories.CommentFactory( 27 | author=self.t2.author, 28 | ticket=self.t2, 29 | ) 30 | self.t2c3 = factories.CommentFactory( 31 | author=self.t2c1.author, 32 | ticket=self.t2, 33 | ) 34 | 35 | 36 | class TestViews(DataMixin, TestCase): 37 | 38 | def test_plain(self): 39 | url = reverse('ticket-list') 40 | resp = self.client.get(url) 41 | self.assertEqual(resp.status_code, 200) 42 | 43 | self.assertEqual(resp.json(), [ 44 | { 45 | 'title': self.t1.title, 46 | 'body': self.t1.body, 47 | 'author': { 48 | 'username': self.t1.author.username, 49 | 'email': self.t1.author.email, 50 | }, 51 | 'comments': [ 52 | { 53 | 'body': self.t1c1.body, 54 | 'author': { 55 | 'username': self.t1c1.author.username, 56 | 'email': self.t1c1.author.email, 57 | }, 58 | }, 59 | { 60 | 'body': self.t1c2.body, 61 | 'author': { 62 | 'username': self.t1c2.author.username, 63 | 'email': self.t1c2.author.email, 64 | }, 65 | }, 66 | ], 67 | }, 68 | { 69 | 'title': self.t2.title, 70 | 'body': self.t2.body, 71 | 'author': { 72 | 'username': self.t2.author.username, 73 | 'email': self.t2.author.email, 74 | }, 75 | 'comments': [ 76 | { 77 | 'body': self.t2c1.body, 78 | 'author': { 79 | 'username': self.t2c1.author.username, 80 | 'email': self.t2c1.author.email, 81 | }, 82 | }, 83 | { 84 | 'body': self.t2c2.body, 85 | 'author': { 86 | 'username': self.t2c2.author.username, 87 | 'email': self.t2c2.author.email, 88 | }, 89 | }, 90 | { 91 | 'body': self.t2c3.body, 92 | 'author': { 93 | 'username': self.t2c3.author.username, 94 | 'email': self.t2c3.author.email, 95 | }, 96 | }, 97 | ], 98 | } 99 | ]) 100 | 101 | def test_no_comments(self): 102 | url = reverse('ticket-list') 103 | resp = self.client.get(url + '?excludes=comments') 104 | self.assertEqual(resp.status_code, 200) 105 | 106 | self.assertEqual(resp.json(), [ 107 | { 108 | 'title': self.t1.title, 109 | 'body': self.t1.body, 110 | 'author': { 111 | 'username': self.t1.author.username, 112 | 'email': self.t1.author.email, 113 | }, 114 | }, 115 | { 116 | 'title': self.t2.title, 117 | 'body': self.t2.body, 118 | 'author': { 119 | 'username': self.t2.author.username, 120 | 'email': self.t2.author.email, 121 | }, 122 | } 123 | ]) 124 | 125 | def test_no_comments_via_fields(self): 126 | url = reverse('ticket-list') 127 | resp = self.client.get(url + '?fields=title,body,author') 128 | self.assertEqual(resp.status_code, 200) 129 | 130 | self.assertEqual(resp.json(), [ 131 | { 132 | 'title': self.t1.title, 133 | 'body': self.t1.body, 134 | 'author': { 135 | 'username': self.t1.author.username, 136 | 'email': self.t1.author.email, 137 | }, 138 | }, 139 | { 140 | 'title': self.t2.title, 141 | 'body': self.t2.body, 142 | 'author': { 143 | 'username': self.t2.author.username, 144 | 'email': self.t2.author.email, 145 | }, 146 | } 147 | ]) 148 | 149 | def test_nested(self): 150 | url = reverse('ticket-list') 151 | resp = self.client.get(url + '?fields=title,author/username') 152 | self.assertEqual(resp.status_code, 200) 153 | 154 | self.assertEqual(resp.json(), [ 155 | { 156 | 'title': self.t1.title, 157 | 'author': { 158 | 'username': self.t1.author.username, 159 | }, 160 | }, 161 | { 162 | 'title': self.t2.title, 163 | 'author': { 164 | 'username': self.t2.author.username, 165 | }, 166 | } 167 | ]) 168 | 169 | 170 | class TestPerformance(DataMixin, TestCase): 171 | 172 | def setUp(self): 173 | super().setUp() 174 | 175 | self.rf = RequestFactory() 176 | 177 | def get_viewset(self, request): 178 | view_instance = views.TicketViewSet() 179 | view_instance.request = request 180 | view_instance.request.user = AnonymousUser() 181 | view_instance.kwargs = {} 182 | view_instance.format_kwarg = 'format' 183 | return view_instance 184 | 185 | def test_default_num_queries(self): 186 | 187 | request = self.rf.get( 188 | reverse('ticket-list'), 189 | ) 190 | view_instance = self.get_viewset(request) 191 | queryset = view_instance.get_queryset() 192 | serializer = view_instance.get_serializer(queryset, many=True) 193 | 194 | with self.assertNumQueries(4): 195 | """ 196 | 1. Load Tickets 197 | 3. Prefetch Authors 198 | 3. Prefetch Comments 199 | 4. Prefetch Comment Authors 200 | """ 201 | serializer.data 202 | 203 | def test_pruned_num_queries(self): 204 | 205 | request = self.rf.get( 206 | reverse('ticket-list'), 207 | data={'fields': 'title,body'}, 208 | ) 209 | view_instance = self.get_viewset(request) 210 | queryset = view_instance.get_queryset() 211 | serializer = view_instance.get_serializer(queryset, many=True) 212 | 213 | with self.assertNumQueries(1): 214 | """ 215 | 1. Load Tickets 216 | """ 217 | data = serializer.data 218 | self.assertNotIn('comments', data[0]) 219 | self.assertNotIn('author', data[0]) 220 | 221 | def test_partially_pruned(self): 222 | 223 | request = self.rf.get( 224 | reverse('ticket-list'), 225 | data={'fields': 'title,body,author,comments/body'}, 226 | ) 227 | view_instance = self.get_viewset(request) 228 | queryset = view_instance.get_queryset() 229 | serializer = view_instance.get_serializer(queryset, many=True) 230 | 231 | with self.assertNumQueries(3): 232 | """ 233 | 1. Load Tickets 234 | 3. Prefetch Authors 235 | 3. Prefetch Comments 236 | """ 237 | serializer.data 238 | 239 | 240 | class TestSettings(DataMixin, TestCase): 241 | 242 | @override_settings(DRF_JSONMASK_FIELDS_NAME='asdf') 243 | def test_old_fields_name(self): 244 | url = reverse('ticket-list') 245 | resp = self.client.get(url + '?fields=title,body') 246 | self.assertEqual(resp.status_code, 200) 247 | self.assertIn('author', resp.json()[0]) 248 | self.assertIn('comments', resp.json()[0]) 249 | 250 | @override_settings(DRF_JSONMASK_FIELDS_NAME='asdf') 251 | def test_override_fields_name(self): 252 | url = reverse('ticket-list') 253 | resp = self.client.get(url + '?asdf=title,body') 254 | self.assertEqual(resp.status_code, 200) 255 | self.assertNotIn('author', resp.json()[0]) 256 | self.assertNotIn('comments', resp.json()[0]) 257 | 258 | @override_settings(DRF_JSONMASK_EXCLUDES_NAME='asdf') 259 | def test_old_excludes_name(self): 260 | url = reverse('ticket-list') 261 | resp = self.client.get(url + '?excludes=title,body') 262 | self.assertEqual(resp.status_code, 200) 263 | self.assertIn('title', resp.json()[0]) 264 | self.assertIn('body', resp.json()[0]) 265 | 266 | @override_settings(DRF_JSONMASK_EXCLUDES_NAME='asdf') 267 | def test_override_excludes_name(self): 268 | url = reverse('ticket-list') 269 | resp = self.client.get(url + '?asdf=title,body') 270 | self.assertEqual(resp.status_code, 200) 271 | self.assertNotIn('title', resp.json()[0]) 272 | self.assertNotIn('body', resp.json()[0]) 273 | 274 | @override_settings(DRF_JSONMASK_FIELDS_NAME='asdf', DRF_JSONMASK_EXCLUDES_NAME='lkjh') 275 | def test_override_field_name_and_excludes_name(self): 276 | url = reverse('ticket-list') 277 | resp = self.client.get(url + '?asdf=title,body&lkjh=author,comments') 278 | self.assertEqual(resp.status_code, 400) 279 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include 2 | from django.urls import re_path 3 | from rest_framework import routers 4 | 5 | from . import views 6 | 7 | router = routers.DefaultRouter(trailing_slash=False) 8 | 9 | router.register(r'tickets', views.TicketViewSet) 10 | 11 | urlpatterns = [ 12 | re_path(r'^', include(router.urls)), 13 | re_path(r'^raw/$', views.RawViewSet.as_view(), name='raw-data'), 14 | ] 15 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from jsonmask import apply_json_mask, parse_fields 3 | 4 | from drf_jsonmask import constants 5 | from drf_jsonmask.utils import collapse_includes_excludes 6 | 7 | 8 | def extract_json_mask_from_request(request): 9 | includes, excludes = {}, {} 10 | 11 | excludes_name = getattr( 12 | settings, 'DRF_JSONMASK_EXCLUDES_NAME', constants.EXCLUDES_NAME 13 | ) 14 | fields_name = getattr(settings, 'DRF_JSONMASK_FIELDS_NAME', constants.FIELDS_NAME) 15 | 16 | if fields_name in request.GET: 17 | includes = parse_fields(request.GET[fields_name]) 18 | if excludes_name in request.GET: 19 | excludes = parse_fields(request.GET[excludes_name]) 20 | 21 | if includes and excludes: 22 | raise ValueError( 23 | 'Cannot supply both `%s` and `%s`' 24 | % ( 25 | fields_name, 26 | excludes_name, 27 | ) 28 | ) 29 | 30 | return includes, excludes 31 | 32 | 33 | def apply_json_mask_from_request(data, request): 34 | includes, excludes = extract_json_mask_from_request(request) 35 | json_mask, is_negated = collapse_includes_excludes(includes, excludes) 36 | return apply_json_mask(data, json_mask, is_negated) 37 | -------------------------------------------------------------------------------- /tests/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework import response, views as rest_views, viewsets 2 | 3 | from drf_jsonmask.decorators import data_predicate 4 | from drf_jsonmask.views import OptimizedQuerySetMixin 5 | 6 | from .models import Ticket 7 | from .serializers import TicketSerializer # CommentSerializer,; UserSerializer, 8 | from .utils import apply_json_mask_from_request 9 | 10 | 11 | class OptimizedCommentsViewSetMixin(OptimizedQuerySetMixin): 12 | @data_predicate('comments') 13 | def load_comments(self, queryset): 14 | return queryset.prefetch_related('comments') 15 | 16 | @data_predicate('comments.author') 17 | def load_comment_authors(self, queryset): 18 | return queryset.prefetch_related('comments__author') 19 | 20 | 21 | class TicketViewSet(OptimizedCommentsViewSetMixin, viewsets.ReadOnlyModelViewSet): 22 | queryset = Ticket.objects.all() 23 | serializer_class = TicketSerializer 24 | 25 | @data_predicate('author') 26 | def load_author(self, queryset): 27 | return queryset.prefetch_related('author') 28 | 29 | 30 | class RawViewSet(rest_views.APIView): 31 | def get(self, request, *args, **kwargs): 32 | data = { 33 | 'a': 'test', 34 | 'b': { 35 | 'nested': 'test', 36 | }, 37 | 'c': { 38 | 'a': { 39 | 'b': True, 40 | }, 41 | }, 42 | 'd': { 43 | 'a': { 44 | 'c': 'value', 45 | 'd': 'value', 46 | }, 47 | }, 48 | } 49 | data = apply_json_mask_from_request(data, request) 50 | return response.Response(data=data) 51 | --------------------------------------------------------------------------------