Repository: dbrgn/drf-dynamic-fields Branch: master Commit: 2ddff1d6d54f Files: 19 Total size: 24.5 KB Directory structure: gitextract_glgoqxjx/ ├── .github/ │ └── workflows/ │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.rst ├── RELEASING.md ├── drf_dynamic_fields/ │ └── __init__.py ├── manage.py ├── runtests.py ├── setup.cfg ├── setup.py └── tests/ ├── __init__.py ├── models.py ├── serializers.py ├── settings.py ├── test_mixins.py ├── test_requests.py ├── urls.py └── views.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/ci.yml ================================================ on: push: branches: - master pull_request: name: CI jobs: test: runs-on: ubuntu-latest strategy: matrix: deps: - { python: '3.7', django: '~=2.2.0', drf: '~=3.10.0' } - { python: '3.7', django: '~=3.2.0', drf: '~=3.11.0' } - { python: '3.8', django: '~=3.2.0', drf: '~=3.12.0' } - { python: '3.10', django: '~=3.2.0', drf: '~=3.12.0' } - { python: '3.9', django: '~=4.0.0', drf: '~=3.13.0' } - { python: '3.10', django: '~=4.0.0', drf: '~=3.13.0' } fail-fast: false name: Python ${{ matrix.deps.python }} / Django ${{ matrix.deps.django }} / DRF ${{ matrix.deps.drf }} steps: - uses: actions/checkout@v2 - name: Setup python ${{ matrix.deps.python }} uses: actions/setup-python@v1 with: python-version: ${{ matrix.deps.python }} - name: Upgrade pip run: pip install -U pip - name: Install dependencies run: pip install "django${{ matrix.deps.django }}" "djangorestframework${{ matrix.deps.drf }}" - name: Run tests run: python runtests.py check-formatting: runs-on: ubuntu-latest name: Check code formatting steps: - uses: actions/checkout@v2 - name: Black Code Formatter uses: lgeiger/black-action@master with: args: "drf_dynamic_fields tests runtests.py --check --diff" ================================================ FILE: .gitignore ================================================ .cache/ .coverage *.swp *.pyc __pycache__ dist/ *.egg-info/ build/ .tox/ ================================================ FILE: CHANGELOG.md ================================================ # Changelog This project follows semantic versioning. Possible log types: - `[added]` for new features. - `[changed]` for changes in existing functionality. - `[deprecated]` for once-stable features removed in upcoming releases. - `[removed]` for deprecated features removed in this release. - `[fixed]` for any bug fixes. - `[security]` to invite users to upgrade in case of vulnerabilities. ## [Unreleased] ## [0.4.0] - 2022-04-05 - [added] Cache `fields` property for improved performance (#33, #35) - [fixed] Fix warning message typo (#34) - [changed] Python 2 support dropped - [changed] Django <2.2 support dropped ## [0.3.1] - 2019-03-01 - [added] Allow suppressing context warnings (#27) - [changed] Explicitly list supported versions in README - [deprecated] Python 2 support will be dropped in version 0.4 ## [0.3.0] - 2018-03-03 - [changed] Do not apply filter to nested serializers (#14) ## [0.2.0] - 2017-04-07 - [added] Add `omit` option to exclude fields (#11) - [fixed] Make it work properly with nested serializers (#8, #10) ## [0.1.1] - 2016-10-16 - [fixed] Make it work in an unit test environment (#2) ## [0.1.0] - 2016-09-30 - Initial release [Unreleased]: https://github.com/dbrgn/drf-dynamic-fields/compare/v0.4.0...HEAD [0.3.1]: https://github.com/dbrgn/drf-dynamic-fields/compare/v0.3.1...v0.4.0 [0.3.1]: https://github.com/dbrgn/drf-dynamic-fields/compare/v0.3.0...v0.3.1 [0.3.0]: https://github.com/dbrgn/drf-dynamic-fields/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/dbrgn/drf-dynamic-fields/compare/v0.1.1...v0.2.0 [0.1.1]: https://github.com/dbrgn/drf-dynamic-fields/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/dbrgn/drf-dynamic-fields/releases/tag/v0.1.0 ================================================ FILE: LICENSE ================================================ Copyright (c) 2014--2016 Danilo Bargen and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.rst ================================================ Dynamic Serializer Fields for Django REST Framework =================================================== .. image:: https://secure.travis-ci.org/dbrgn/drf-dynamic-fields.png?branch=master :alt: Build status :target: http://travis-ci.org/dbrgn/drf-dynamic-fields .. image:: https://img.shields.io/pypi/v/drf-dynamic-fields.svg :alt: PyPI Version :target: https://pypi.python.org/pypi/drf-dynamic-fields .. image:: https://img.shields.io/pypi/dm/drf-dynamic-fields.svg?maxAge=3600 :alt: PyPI Downloads :target: https://pypi.python.org/pypi/drf-dynamic-fields .. image:: https://img.shields.io/github/license/mashape/apistatus.svg?maxAge=2592000 :alt: License is MIT :target: https://github.com/dbrgn/drf-dynamic-fields/blob/master/LICENSE This package provides a mixin that allows the user to dynamically select only a subset of fields per resource. Official version support: - Django 2.2 LTS, 3.2 LTS, 4.0 - Supported REST Framework versions: 3.8, 3.9 - Python 3.7+ Scope ----- This library is about filtering fields based on individual requests. It is deliberately kept simple and we do not plan to add new features (including support for nested fields). Feel free to contribute improvements, code simplifications and bugfixes though! (See also: `#18 `__) If you need more advanced filtering features, maybe `drf-flex-fields `_ could be something for you. Installing ---------- :: pip install drf-dynamic-fields What It Does ------------ Example serializer: .. sourcecode:: python class IdentitySerializer(DynamicFieldsMixin, serializers.HyperlinkedModelSerializer): class Meta: model = models.Identity fields = ('id', 'url', 'type', 'data') A regular request returns all fields: ``GET /identities`` .. sourcecode:: json [ { "id": 1, "url": "http://localhost:8000/api/identities/1/", "type": 5, "data": "John Doe" }, ... ] A query with the `fields` parameter on the other hand returns only a subset of the fields: ``GET /identities/?fields=id,data`` .. sourcecode:: json [ { "id": 1, "data": "John Doe" }, ... ] And a query with the `omit` parameter excludes specified fields. ``GET /identities/?omit=data`` .. sourcecode:: json [ { "id": 1, "url": "http://localhost:8000/api/identities/1/", "type": 5 }, ... ] You can use both `fields` and `omit` in the same request! ``GET /identities/?omit=data,fields=data,id`` .. sourcecode:: json [ { "id": 1 }, ... ] Though why you would want to do something like that is beyond this author. It also works on single objects! ``GET /identities/1/?fields=id,data`` .. sourcecode:: json { "id": 1, "data": "John Doe" } Usage ----- When defining a serializer, use the ``DynamicFieldsMixin``: .. sourcecode:: python from drf_dynamic_fields import DynamicFieldsMixin class IdentitySerializer(DynamicFieldsMixin, serializers.ModelSerializer): class Meta: model = models.Identity fields = ('id', 'url', 'type', 'data') The mixin needs access to the ``request`` object. Some DRF classes like the ``ModelViewSet`` set that by default, but if you handle serializers yourself, pass in the request through the context: .. sourcecode:: python events = Event.objects.all() serializer = EventSerializer(events, many=True, context={'request': request}) Warnings -------- If the request context does not have access to the request, a warning is emitted:: UserWarning: Context does not have access to request. First, make sure that you are passing the request to the serializer context (see "Usage" section). There are some cases (e.g. nested serializers) where you cannot get rid of the warning that way (see `issue 27 `_). In that case, you can silence the warning through ``settings.py``: .. sourcecode:: python DRF_DYNAMIC_FIELDS = { 'SUPPRESS_CONTEXT_WARNING': True, } Testing ------- To run tests, install Django and DRF and then run ``runtests.py``: $ python runtests.py Credits ------- - The implementation is based on `this `__ StackOverflow answer. Thanks ``YAtOff``! - The GitHub users ``X17`` and ``rawbeans`` provided improvements on `my gist `__ that were incorporated into this library. Thanks! - For other contributors, please see `Github contributor stats `__. License ------- MIT license, see ``LICENSE`` file. ================================================ FILE: RELEASING.md ================================================ # Release process Signing key: https://dbrgn.ch/F2F3A5FA.asc Used variables: export VERSION={VERSION} export GPG=F2F3A5FA Update version number in setup.py and CHANGELOG.md: vim -p setup.py CHANGELOG.md Do a signed commit and signed tag of the release: git add setup.py CHANGELOG.md git commit -S${GPG} -m "Release v${VERSION}" git tag -u ${GPG} -m "Release v${VERSION}" v${VERSION} Build source and binary distributions: python3 setup.py sdist python3 setup.py bdist_wheel Sign files: gpg --detach-sign -u ${GPG} -a dist/drf_dynamic_fields-${VERSION}.tar.gz gpg --detach-sign -u ${GPG} -a dist/drf_dynamic_fields-${VERSION}-py2.py3-none-any.whl Upload package to PyPI: twine3 upload dist/drf_dynamic_fields-${VERSION}* git push git push --tags ================================================ FILE: drf_dynamic_fields/__init__.py ================================================ """ Mixin to dynamically select only a subset of fields per DRF resource. """ import warnings from django.conf import settings from django.utils.functional import cached_property class DynamicFieldsMixin(object): """ A serializer mixin that takes an additional `fields` argument that controls which fields should be displayed. """ @cached_property def fields(self): """ Filters the fields according to the `fields` query parameter. A blank `fields` parameter (?fields) will remove all fields. Not passing `fields` will pass all fields individual fields are comma separated (?fields=id,name,url,email). """ fields = super(DynamicFieldsMixin, self).fields if not hasattr(self, "_context"): # We are being called before a request cycle return fields # Only filter if this is the root serializer, or if the parent is the # root serializer with many=True is_root = self.root == self parent_is_list_root = self.parent == self.root and getattr( self.parent, "many", False ) if not (is_root or parent_is_list_root): return fields try: request = self.context["request"] except KeyError: conf = getattr(settings, "DRF_DYNAMIC_FIELDS", {}) if not conf.get("SUPPRESS_CONTEXT_WARNING", False) is True: warnings.warn( "Context does not have access to request. " "See README for more information." ) return fields # NOTE: drf test framework builds a request object where the query # parameters are found under the GET attribute. params = getattr(request, "query_params", getattr(request, "GET", None)) if params is None: warnings.warn("Request object does not contain query parameters") try: filter_fields = params.get("fields", None).split(",") except AttributeError: filter_fields = None try: omit_fields = params.get("omit", None).split(",") except AttributeError: omit_fields = [] # Drop any fields that are not specified in the `fields` argument. existing = set(fields.keys()) if filter_fields is None: # no fields param given, don't filter. allowed = existing else: allowed = set(filter(None, filter_fields)) # omit fields in the `omit` argument. omitted = set(filter(None, omit_fields)) for field in existing: if field not in allowed: fields.pop(field, None) if field in omitted: fields.pop(field, None) return fields ================================================ FILE: manage.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- """ Django manage.py, only used for testing. """ import os import sys if __name__ == "__main__": os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") from django.core.management import execute_from_command_line execute_from_command_line(sys.argv) ================================================ FILE: runtests.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 """ Run tests with python runtests.py Taken from the django cookiecutter project. """ import os import sys import django from django.conf import settings from django.test.utils import get_runner def run_tests(*test_args): if not test_args: test_args = ["tests"] os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" django.setup() test_runner = get_runner(settings)() failures = test_runner.run_tests(test_args) sys.exit(bool(failures)) if __name__ == "__main__": run_tests(*sys.argv[1:]) ================================================ FILE: setup.cfg ================================================ [bdist_wheel] universal=1 ================================================ FILE: setup.py ================================================ from setuptools import setup readme = open('README.rst').read() setup(name='drf_dynamic_fields', version='0.4.0', description='Dynamically return subset of Django REST Framework serializer fields', author='Danilo Bargen', author_email='mail@dbrgn.ch', url='https://github.com/dbrgn/drf-dynamic-fields', packages=['drf_dynamic_fields'], zip_safe=True, include_package_data=True, license='MIT', keywords='drf restframework rest_framework django_rest_framework serializers', long_description=readme, classifiers=[ 'Development Status :: 5 - Production/Stable', 'License :: OSI Approved :: MIT License', 'Programming Language :: Python :: 3', 'Framework :: Django', 'Environment :: Web Environment', ], ) ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/models.py ================================================ """ Some models for the tests. We are modelling a school. """ from django.db import models class Teacher(models.Model): name = models.CharField(max_length=30) age = models.IntegerField() class School(models.Model): """Schools just have teachers, no students.""" name = models.CharField(max_length=30) teachers = models.ManyToManyField(Teacher) ================================================ FILE: tests/serializers.py ================================================ """ For the tests. """ from rest_framework import serializers from drf_dynamic_fields import DynamicFieldsMixin from .models import Teacher, School class TeacherSerializer(DynamicFieldsMixin, serializers.ModelSerializer): """ The request_info field is to highlight the issue accessing request during a nested serializer. """ request_info = serializers.SerializerMethodField() class Meta: model = Teacher fields = ("id", "request_info", "age", "name") def get_request_info(self, teacher): """ a meaningless method that attempts to access the request object. """ request = self.context["request"] return request.build_absolute_uri("/api/v1/teacher/{}".format(teacher.pk)) class SchoolSerializer(DynamicFieldsMixin, serializers.ModelSerializer): """ Interesting enough serializer because the TeacherSerializer will use ListSerializer due to the `many=True` """ teachers = TeacherSerializer(many=True, read_only=True) class Meta: model = School fields = ("id", "teachers", "name") ================================================ FILE: tests/settings.py ================================================ # -*- coding: utf-8 """ Settings for test. """ import django DEBUG = True USE_TZ = True # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = "**************************************************" DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:", } } ROOT_URLCONF = "tests.urls" INSTALLED_APPS = [ "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sites", "drf_dynamic_fields", "tests", ] SITE_ID = 1 MIDDLEWARE = () ================================================ FILE: tests/test_mixins.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- """ test_drf-dynamic-fields ----------- Tests for `drf-dynamic-fields` mixins """ from collections import OrderedDict from django.test import TestCase, RequestFactory from .serializers import SchoolSerializer, TeacherSerializer from .models import Teacher, School class TestDynamicFieldsMixin(TestCase): """ Test case for the DynamicFieldsMixin """ def test_removes_fields(self): """ Does it actually remove fields? """ rf = RequestFactory() request = rf.get("/api/v1/schools/1/?fields=id") serializer = TeacherSerializer(context={"request": request}) self.assertEqual(set(serializer.fields.keys()), set(("id",))) def test_fields_left_alone(self): """ What if no fields param is passed? It should not touch the fields. """ rf = RequestFactory() request = rf.get("/api/v1/schools/1/") serializer = TeacherSerializer(context={"request": request}) self.assertEqual( set(serializer.fields.keys()), set(("id", "request_info", "age", "name")) ) def test_fields_all_gone(self): """ If we pass a blank fields list, then no fields should return. """ rf = RequestFactory() request = rf.get("/api/v1/schools/1/?fields") serializer = TeacherSerializer(context={"request": request}) self.assertEqual(set(serializer.fields.keys()), set()) def test_ordinary_serializer(self): """ Check the full JSON output of the serializer. """ rf = RequestFactory() request = rf.get("/api/v1/schools/1/?fields=id,age") teacher = Teacher.objects.create(name="Susan", age=34) serializer = TeacherSerializer(teacher, context={"request": request}) self.assertEqual(serializer.data, {"id": teacher.id, "age": teacher.age}) def test_omit(self): """ Check a basic usage of omit. """ rf = RequestFactory() request = rf.get("/api/v1/schools/1/?omit=request_info") serializer = TeacherSerializer(context={"request": request}) self.assertEqual(set(serializer.fields.keys()), set(("id", "name", "age"))) def test_omit_and_fields_used(self): """ Can they be used together. """ rf = RequestFactory() request = rf.get("/api/v1/schools/1/?fields=id,request_info&omit=request_info") serializer = TeacherSerializer(context={"request": request}) self.assertEqual(set(serializer.fields.keys()), set(("id",))) def test_omit_everything(self): """ Can remove it all tediously. """ rf = RequestFactory() request = rf.get("/api/v1/schools/1/?omit=id,request_info,age,name") serializer = TeacherSerializer(context={"request": request}) self.assertEqual(set(serializer.fields.keys()), set()) def test_omit_nothing(self): """ Blank omit doesn't affect anything. """ rf = RequestFactory() request = rf.get("/api/v1/schools/1/?omit") serializer = TeacherSerializer(context={"request": request}) self.assertEqual( set(serializer.fields.keys()), set(("id", "request_info", "name", "age")) ) def test_omit_non_existant_field(self): rf = RequestFactory() request = rf.get("/api/v1/schools/1/?omit=pretend") serializer = TeacherSerializer(context={"request": request}) self.assertEqual( set(serializer.fields.keys()), set(("id", "request_info", "name", "age")) ) def test_as_nested_serializer(self): """ Nested serializers are not filtered. """ rf = RequestFactory() request = rf.get("/api/v1/schools/1/?fields=teachers") school = School.objects.create(name="Python Heights High") teachers = [ Teacher.objects.create(name="Shane", age=45), Teacher.objects.create(name="Kaz", age=29), ] school.teachers.add(*teachers) serializer = SchoolSerializer(school, context={"request": request}) request_info = "http://testserver/api/v1/teacher/{}" self.assertEqual( serializer.data, { "teachers": [ OrderedDict( [ ("id", teachers[0].id), ("request_info", request_info.format(teachers[0].id)), ("age", teachers[0].age), ("name", teachers[0].name), ] ), OrderedDict( [ ("id", teachers[1].id), ("request_info", request_info.format(teachers[1].id)), ("age", teachers[1].age), ("name", teachers[1].name), ] ), ], }, ) def test_serializer_reuse_with_changing_request(self): """ `fields` is a cached property. Changing the request on an already instantiated serializer will not result in a changed fields attribute. This was a deliberate choice we have made in favor of speeding up access to the slow `fields` attribute. """ rf = RequestFactory() request = rf.get("/api/v1/schools/1/?fields=id") serializer = TeacherSerializer(context={"request": request}) self.assertEqual(set(serializer.fields.keys()), {"id"}) # now change the request on this instantiated serializer. request2 = rf.get("/api/v1/schools/1/?fields=id,name") serializer.context["request"] = request2 self.assertEqual(set(serializer.fields.keys()), {"id"}) ================================================ FILE: tests/test_requests.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- """ test_drf-dynamic-fields ------------ Test for the full request cycle using dynamic fields mixns """ from collections import OrderedDict from django.test import TestCase, RequestFactory from rest_framework.reverse import reverse from .serializers import SchoolSerializer, TeacherSerializer from .models import Teacher, School class TestDynamicFieldsViews(TestCase): """ Testing using dynamic fields in request framework views. """ def setUp(self): """ Create some teachers and schools. """ teachers = [("Craig", 34), ("Kaz", 29), ("Sun", 62)] schools = ["Python Heights High", "Ruby Consolidated", "Java Coffee School"] t = [Teacher.objects.create(name=name, age=age) for name, age in teachers] for name in schools: s = School.objects.create(name=name) s.teachers.add(*t) def test_teacher_basic(self): response = self.client.get(reverse("teacher-list")) for teacher in response.data: self.assertEqual(teacher.keys(), {"id", "request_info", "age", "name"}) def test_teacher_fields(self): response = self.client.get(reverse("teacher-list"), {"fields": "id,age"}) for teacher in response.data: self.assertEqual(teacher.keys(), {"id", "age"}) def test_teacher_omit(self): response = self.client.get(reverse("teacher-list"), {"omit": "id,age"}) for teacher in response.data: self.assertEqual(teacher.keys(), {"request_info", "name"}) def test_nested_teacher_fields(self): response = self.client.get(reverse("school-list"), {"fields": "name,teachers"}) for school in response.data: self.assertEqual(school.keys(), {"teachers", "name"}) self.assertEqual( school["teachers"][0].keys(), {"id", "request_info", "age", "name"} ) ================================================ FILE: tests/urls.py ================================================ # -*- coding: utf-8 from rest_framework import routers from .views import SchoolViewSet, TeacherViewSet router = routers.SimpleRouter() router.register("teachers", TeacherViewSet) router.register("schools", SchoolViewSet) urlpatterns = router.urls ================================================ FILE: tests/views.py ================================================ from rest_framework.viewsets import ModelViewSet from .models import School, Teacher from .serializers import SchoolSerializer, TeacherSerializer class TeacherViewSet(ModelViewSet): queryset = Teacher.objects.all() serializer_class = TeacherSerializer class SchoolViewSet(ModelViewSet): queryset = School.objects.all() serializer_class = SchoolSerializer