From ddcf8fb039b3d72bb99730de67e902996b0ff8ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?I=C3=B1igo=20R=2E?= Date: Sun, 23 Aug 2026 12:33:13 +0200 Subject: [PATCH] FEAT: Modernized to support latest Pythons and DRF JSONAPI --- .gitlab-ci.yml | 52 +++++-- .idea/.gitignore | 8 ++ .idea/enhanced-drf-jsonapi.iml | 15 ++ .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 7 + .idea/modules.xml | 8 ++ .idea/vcs.xml | 6 + CHANGELOG.md | 17 +++ LICENSE | 21 +++ MANIFEST.in | 4 + README.md | 94 ++++++++++--- enhanced_drf_jsonapi/__init__.py | 3 + enhanced_drf_jsonapi/api.py | 130 +++++++++--------- enhanced_drf_jsonapi/exceptions.py | 58 ++++++++ enhanced_drf_jsonapi/pagination.py | 40 +++--- pyproject.toml | 69 ++++++++++ setup.py | 30 +--- tests/conftest.py | 31 +++++ tests/test_api.py | 123 +++++++++++++++++ tests/test_exceptions.py | 67 +++++++++ tests/test_pagination.py | 56 ++++++++ tox.ini | 11 ++ 22 files changed, 718 insertions(+), 138 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/enhanced-drf-jsonapi.iml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 enhanced_drf_jsonapi/exceptions.py create mode 100644 pyproject.toml create mode 100644 tests/conftest.py create mode 100644 tests/test_api.py create mode 100644 tests/test_exceptions.py create mode 100644 tests/test_pagination.py create mode 100644 tox.ini diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8491990..afe9cde 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,14 +1,46 @@ -image: python:3.8 - stages: + - test + - build - publish -pack: - stage: publish - script: - - pip install twine setuptools - - python setup.py sdist bdist_wheel - - python -m twine upload -u iruiz --repository-url https://git.ruiz.wang/api/packages/Public/pypi dist/* +compatibility: + stage: test + parallel: + matrix: + - PYTHON_VERSION: ["3.10", "3.11", "3.12", "3.13", "3.14"] + DRF_JSONAPI_VERSION: ["7.1", "8.1"] + image: "python:${PYTHON_VERSION}" + script: + - python -m pip install --upgrade pip + - python -m pip install -e ".[test]" "djangorestframework-jsonapi~=${DRF_JSONAPI_VERSION}.0" + - python -m pytest - only: - - tags +lint: + stage: test + image: python:3.14 + script: + - python -m pip install -e ".[dev]" + - python -m ruff check . + +build: + stage: build + image: python:3.14 + script: + - python -m pip install "build>=1.3,<2" "twine>=6,<7" + - python -m build + - python -m twine check dist/* + artifacts: + paths: + - dist/ + +publish: + stage: publish + image: python:3.14 + needs: + - job: build + artifacts: true + rules: + - if: $CI_COMMIT_TAG + script: + - python -m pip install "twine>=6,<7" + - TWINE_USERNAME=gitlab-ci-token TWINE_PASSWORD="$CI_JOB_TOKEN" python -m twine upload --repository-url "$CI_API_V4_URL/projects/$CI_PROJECT_ID/packages/pypi" dist/* diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/enhanced-drf-jsonapi.iml b/.idea/enhanced-drf-jsonapi.iml new file mode 100644 index 0000000..99a591b --- /dev/null +++ b/.idea/enhanced-drf-jsonapi.iml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..e7122da --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..a526975 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5976f5b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +## 1.1.0 + +- Support Python 3.10 through 3.14 and DRF JSON:API 7.1 through 8.1. +- Add sanitized exception handling with opaque error identifiers. +- Add explicit conflict exceptions instead of converting every database + integrity failure to HTTP 409. +- Add hardened model-viewset and generic-view base classes. +- Make serializer relationship discovery safe without request context and + avoid duplicate field names. +- Add unit, packaging, lint, and compatibility-matrix verification. + +## 1.0.6 + +- Add PATCH relationship-field handling. +- Update the DRF JSON:API dependency to the 7.1 line. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..148ffd8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Iñigo R. + +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. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..ffce630 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include CHANGELOG.md +include LICENSE +include tox.ini +recursive-include tests *.py diff --git a/README.md b/README.md index 8281a07..bb5a93e 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,84 @@ -# Enhanced Django Rest Framework JSON Api +# Enhanced Django REST framework JSON:API -This is a library aimed to fix some classes in Django Rest Framework and Django Rest Framework JSON Api libraries. +Shared, tested primitives used by several django projects on top of +[`djangorestframework-jsonapi`](https://github.com/django-json-api/django-rest-framework-json-api). -## Contents +## Compatibility -### enhanced_drf_jsonapi module +- Python 3.10–3.14 +- `djangorestframework-jsonapi` 7.1 and 8.1 +- The Django and Django REST framework releases accepted by the selected + `djangorestframework-jsonapi` version -#### API -- ##### class PreloadIncludesMixin - Overwrites the method get_queryset(self, *args, **kwarg) -- ##### class ReasonableModelViewSet - Overwrites the attribute http_method_names +The GitLab test matrix exercises every supported Python/DRF JSON:API +combination. Applications may stay on 7.1 while upgrading independently, then +move to 8.1 without changing imports from this package. -- ##### class ReasonableModelSerializer - Overwrites the method get_field_names(self, declared_fields, info) +## API primitives -#### PAGINATION -- ##### class NgxJsonApiPageNumberPagination - Overwrites the method get_paginated_response(self, data) +```python +from enhanced_drf_jsonapi.api import ( + ReasonableModelSerializer, + ReasonableModelViewSet, + basic_filter, + date_filter, + int_filter, + text_filter, +) +from enhanced_drf_jsonapi.pagination import NgxJsonApiPageNumberPagination +``` -## Build the library -In root directory, run `python setup.py bdist_wheel`. This will create a wheel file in `dist` folder. +- `PreloadIncludesMixin` applies `select_for_includes` and + `prefetch_for_includes` rules for requested JSON:API relationships. +- `ReasonableModelViewSet` exposes the standard resource methods and combines + the JSON:API relationship/prefetch mixins. +- `ReasonableModelSerializer` includes declared relationship fields requested + through GET includes and POST/PATCH payloads. +- `NgxJsonApiPageNumberPagination` preserves the pagination metadata and link + shape expected by `ngx-jsonapi` clients. -## Install -Run this command in the desired python environment `pip install path/to/wheelfile.whl`. +## Hardened exception handling + +Use the handler globally so every DRF view, including plain `APIView` classes, +returns the same JSON:API error shape: + +```python +REST_FRAMEWORK = { + "EXCEPTION_HANDLER": ( + "enhanced_drf_jsonapi.exceptions.hardened_exception_handler" + ), +} +``` + +Alternatively, opt individual classes in: + +```python +from enhanced_drf_jsonapi.api import HardenedGenericAPIView, HardenedModelViewSet +``` + +Expected DRF exceptions retain their status and detail. Unexpected exceptions +are logged with a generated error identifier and returned as a sanitized HTTP +500 response carrying the same identifier in `X-Error-ID`. Internal exception +messages and tracebacks are never included in the response. + +Known application conflicts must be raised deliberately rather than treating +every database integrity failure as a client error: + +```python +from enhanced_drf_jsonapi.exceptions import APIConflictException + +raise APIConflictException() +``` + +## Development + +```bash +python -m pip install -e ".[dev]" +python -m pytest +python -m ruff check . +python -m build +python -m twine check dist/* +``` + +Run all locally available compatibility environments with `tox`. CI runs the +complete Python 3.10–3.14 × DRF JSON:API 7.1/8.1 matrix. diff --git a/enhanced_drf_jsonapi/__init__.py b/enhanced_drf_jsonapi/__init__.py index e69de29..0e38891 100644 --- a/enhanced_drf_jsonapi/__init__.py +++ b/enhanced_drf_jsonapi/__init__.py @@ -0,0 +1,3 @@ +"""Shared extensions for Django REST framework JSON:API.""" + +__version__ = "1.1.0" diff --git a/enhanced_drf_jsonapi/api.py b/enhanced_drf_jsonapi/api.py index cbf965e..7d4ced1 100644 --- a/enhanced_drf_jsonapi/api.py +++ b/enhanced_drf_jsonapi/api.py @@ -1,88 +1,94 @@ +"""Reusable serializers and views for DRF JSON:API applications.""" + from rest_framework import viewsets +from rest_framework.generics import GenericAPIView from rest_framework_json_api import serializers from rest_framework_json_api.utils import get_included_resources from rest_framework_json_api.views import AutoPrefetchMixin, RelatedMixin -basic_filter = ('exact', 'isnull') -text_filter = ('exact', 'contains', 'iexact', 'icontains', 'startswith', 'istartswith', 'endswith', 'iendswith') -date_filter = ('exact', 'gte', 'lte') -int_filter = ('exact', 'gte', 'lte') +from .exceptions import HardenedExceptionHandlingMixin + +basic_filter = ("exact", "isnull") +text_filter = ( + "exact", + "contains", + "iexact", + "icontains", + "startswith", + "istartswith", + "endswith", + "iendswith", +) +date_filter = ("exact", "gte", "lte") +int_filter = ("exact", "gte", "lte") -class PreloadIncludesMixin(object): - """ - This mixin provides a helper attributes to select or prefetch related models - based on the include specified in the URL. - - __all__ can be used to specify a prefetch which should be done regardless of the include - - - .. code:: python - - # When MyViewSet is called with ?include=author it will prefetch author and authorbio - class MyViewSet(viewsets.ModelViewSet): - queryset = Book.objects.all() - prefetch_for_includes = { - '__all__': [], - 'category.section': ['category'] - } - select_for_includes = { - '__all__': [], - 'author': ['author', 'author__authorbio'], - } - """ +class PreloadIncludesMixin: + """Apply configured select/prefetch rules for requested JSON:API includes.""" def get_select_related(self, include): - return getattr(self, 'select_for_includes', {}).get(include, None) + return getattr(self, "select_for_includes", {}).get(include) def get_prefetch_related(self, include): - return getattr(self, 'prefetch_for_includes', {}).get(include, None) + return getattr(self, "prefetch_for_includes", {}).get(include) def get_queryset(self, *args, **kwargs): - qs = super(PreloadIncludesMixin, self).get_queryset(*args, **kwargs) + queryset = super().get_queryset(*args, **kwargs) + includes = [*get_included_resources(self.request), "__all__"] - included_resources = get_included_resources(self.request) - for included in included_resources + ['__all__']: - - select_related = self.get_select_related(included) + for include in includes: + select_related = self.get_select_related(include) if select_related is not None: - qs = qs.select_related(*select_related) + queryset = queryset.select_related(*select_related) - prefetch_related = self.get_prefetch_related(included) - if prefetch_related is not None: - if not isinstance(prefetch_related, list): - qs = qs.prefetch_related(*prefetch_related(self)) - else: - qs = qs.prefetch_related(*prefetch_related) + prefetch_related = self.get_prefetch_related(include) + if prefetch_related is None: + continue + if callable(prefetch_related): + prefetch_related = prefetch_related(self) + queryset = queryset.prefetch_related(*prefetch_related) - return qs + return queryset -class ReasonableModelViewSet(AutoPrefetchMixin, - PreloadIncludesMixin, - RelatedMixin, - viewsets.ModelViewSet): - http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options'] +class ReasonableModelViewSet( + AutoPrefetchMixin, + PreloadIncludesMixin, + RelatedMixin, + viewsets.ModelViewSet, +): + """JSON:API model viewset limited to the standard resource methods.""" + + http_method_names = ["get", "post", "patch", "delete", "head", "options"] + + +class HardenedModelViewSet(HardenedExceptionHandlingMixin, ReasonableModelViewSet): + """ReasonableModelViewSet with sanitized handling for unexpected errors.""" + + +class HardenedGenericAPIView(HardenedExceptionHandlingMixin, GenericAPIView): + """GenericAPIView with sanitized handling for unexpected errors.""" class ReasonableModelSerializer(serializers.ModelSerializer): - """ - Reusable class to extend DJA ModelSerializer and make it more JSONAPI compatible - """ + """Include declared relationship fields requested by JSON:API clients.""" def get_field_names(self, declared_fields, info): - method = self.context['request'].method + fields = list(super().get_field_names(declared_fields, info)) + request = self.context.get("request") + included_serializers = getattr(self, "included_serializers", {}) + if request is None or not included_serializers: + return fields - included_rel_fields = [] - if method == 'GET': - included_rel_fields = [qp for qp in ( - self.context['request'].query_params['include'].split(',') - if 'include' in self.context['request'].query_params else [] - ) if qp in (self.included_serializers if hasattr(self, 'included_serializers') else [])] - elif method == 'POST' or method == 'PATCH': - included_rel_fields = [irel for irel in ( - self.context['request'].data.keys() - ) if irel in (self.included_serializers if hasattr(self, 'included_serializers') else [])] + included_relationships = [] + if request.method == "GET": + included_relationships = request.query_params.get("include", "").split(",") + elif request.method in {"POST", "PATCH"}: + included_relationships = request.data.keys() - fields = super().get_field_names(declared_fields, info) - return fields + included_rel_fields \ No newline at end of file + fields.extend( + relationship + for relationship in included_relationships + if relationship in included_serializers and relationship not in fields + ) + return fields diff --git a/enhanced_drf_jsonapi/exceptions.py b/enhanced_drf_jsonapi/exceptions.py new file mode 100644 index 0000000..d299544 --- /dev/null +++ b/enhanced_drf_jsonapi/exceptions.py @@ -0,0 +1,58 @@ +"""Safe, JSON:API-compatible exception handling.""" + +import logging +import uuid + +from rest_framework.exceptions import APIException +from rest_framework_json_api.exceptions import exception_handler as jsonapi_exception_handler + +logger = logging.getLogger(__name__) + +ERROR_ID_HEADER = "X-Error-ID" + + +class APIConflictException(APIException): + """A deliberately raised, non-sensitive resource conflict.""" + + status_code = 409 + default_detail = "The request conflicts with the current resource state." + default_code = "conflict" + + +class InternalServerError(APIException): + """Sanitized representation of an unexpected server exception.""" + + status_code = 500 + default_detail = "An unexpected server error occurred." + default_code = "internal_error" + + +def hardened_exception_handler(exc, context): + """Render expected errors normally and sanitize unexpected exceptions. + + Unexpected exception details and tracebacks are logged server-side under a + generated error identifier. Only that opaque identifier is returned to the + client, in a response header, so operators can correlate a report safely. + """ + + response = jsonapi_exception_handler(exc, context) + if response is not None: + return response + + error_id = uuid.uuid4().hex + logger.error( + "Unhandled API exception; error_id=%s", + error_id, + exc_info=exc, + ) + + response = jsonapi_exception_handler(InternalServerError(), context) + response[ERROR_ID_HEADER] = error_id + return response + + +class HardenedExceptionHandlingMixin: + """Opt a DRF view into :func:`hardened_exception_handler`.""" + + def get_exception_handler(self): + return hardened_exception_handler diff --git a/enhanced_drf_jsonapi/pagination.py b/enhanced_drf_jsonapi/pagination.py index 2e87bee..765228f 100644 --- a/enhanced_drf_jsonapi/pagination.py +++ b/enhanced_drf_jsonapi/pagination.py @@ -1,37 +1,35 @@ -from collections import OrderedDict +"""Pagination adapters for clients built with ngx-jsonapi.""" from rest_framework.response import Response from rest_framework_json_api.pagination import JsonApiPageNumberPagination class NgxJsonApiPageNumberPagination(JsonApiPageNumberPagination): + """Expose page-number metadata in the shape expected by ngx-jsonapi.""" + def get_paginated_response(self, data): - next = None - previous = None + next_page = None + previous_page = None if self.page.has_next(): - next = self.page.next_page_number() + next_page = self.page.next_page_number() if self.page.has_previous(): - previous = self.page.previous_page_number() + previous_page = self.page.previous_page_number() return Response( { "results": data, - "meta": OrderedDict( - [ - ("page", self.page.number), - ("pages", self.page.paginator.num_pages), - ("total_resources", self.page.paginator.count), - ("resources_per_page", self.page.paginator.per_page), - ] - ), - "links": OrderedDict( - [ - ("first", self.build_link(1)), - ("last", self.build_link(self.page.paginator.num_pages)), - ("next", self.build_link(next)), - ("prev", self.build_link(previous)), - ] - ), + "meta": { + "page": self.page.number, + "pages": self.page.paginator.num_pages, + "total_resources": self.page.paginator.count, + "resources_per_page": self.page.paginator.per_page, + }, + "links": { + "first": self.build_link(1), + "last": self.build_link(self.page.paginator.num_pages), + "next": self.build_link(next_page), + "prev": self.build_link(previous_page), + }, } ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fe28898 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,69 @@ +[build-system] +requires = ["setuptools>=77", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "enhanced-drf-jsonapi" +dynamic = ["version"] +description = "Shared Django REST framework JSON:API primitives and hardened error handling" +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +authors = [{ name = "Iñigo R." }] +classifiers = [ + "Development Status :: 4 - Beta", + "Framework :: Django", + "Framework :: Django :: 5.2", + "Framework :: Django :: 6.0", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +dependencies = [ + "djangorestframework-jsonapi>=7.1,<9", +] + +[project.optional-dependencies] +test = [ + "pytest>=8,<10", + "pytest-cov>=5,<8", +] +dev = [ + "build>=1.3,<2", + "pytest>=8,<10", + "pytest-cov>=5,<8", + "ruff>=0.12,<1", + "tox>=4.30,<5", + "twine>=6,<7", +] + +[project.urls] +Repository = "https://git.ruiz.wang/Public/enhanced-drf-jsonapi" + +[tool.setuptools.packages.find] +include = ["enhanced_drf_jsonapi*"] + +[tool.setuptools.dynamic] +version = { attr = "enhanced_drf_jsonapi.__version__" } + +[tool.pytest.ini_options] +addopts = "--strict-markers --strict-config --cov=enhanced_drf_jsonapi --cov-report=term-missing" +testpaths = ["tests"] + +[tool.coverage.run] +branch = true + +[tool.coverage.report] +fail_under = 95 +show_missing = true + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] diff --git a/setup.py b/setup.py index d2cbf44..2685dfd 100644 --- a/setup.py +++ b/setup.py @@ -1,29 +1,5 @@ -from setuptools import setup, find_packages -import pathlib +"""Compatibility shim for tooling that still invokes setup.py directly.""" -HERE = pathlib.Path(__file__).parent +from setuptools import setup -VERSION = '1.0.5' -PACKAGE_NAME = 'enhanced_drf_jsonapi' - -LICENSE = 'MIT' -DESCRIPTION = 'Patch library for django rest framework json api' -LONG_DESCRIPTION = (HERE / "README.md").read_text(encoding='utf-8') -LONG_DESC_TYPE = "text/markdown" - - -INSTALL_REQUIRES = [ - 'djangorestframework-jsonapi~=7.0.2' - ] - -setup( - name=PACKAGE_NAME, - version=VERSION, - description=DESCRIPTION, - long_description=LONG_DESCRIPTION, - long_description_content_type=LONG_DESC_TYPE, - install_requires=INSTALL_REQUIRES, - license=LICENSE, - packages=find_packages(), - include_package_data=True -) +setup() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..49d70f5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,31 @@ +import django +from django.conf import settings + + +def pytest_configure(): + if not settings.configured: + settings.configure( + SECRET_KEY="test-only-key", + INSTALLED_APPS=[ + "django.contrib.auth", + "django.contrib.contenttypes", + "rest_framework", + "rest_framework_json_api", + ], + DATABASES={ + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + } + }, + REST_FRAMEWORK={ + "DEFAULT_RENDERER_CLASSES": [ + "rest_framework_json_api.renderers.JSONRenderer", + ], + "DEFAULT_PARSER_CLASSES": [ + "rest_framework_json_api.parsers.JSONParser", + ], + }, + USE_TZ=True, + ) + django.setup() diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..72fafc4 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,123 @@ +from types import SimpleNamespace + +from rest_framework_json_api import serializers + +from enhanced_drf_jsonapi.api import ( + HardenedGenericAPIView, + HardenedModelViewSet, + PreloadIncludesMixin, + ReasonableModelSerializer, + ReasonableModelViewSet, +) +from enhanced_drf_jsonapi.exceptions import HardenedExceptionHandlingMixin + + +class QuerySetSpy: + def __init__(self): + self.select_calls = [] + self.prefetch_calls = [] + + def select_related(self, *fields): + self.select_calls.append(fields) + return self + + def prefetch_related(self, *fields): + self.prefetch_calls.append(fields) + return self + + +class QuerySetProvider: + queryset_spy = None + + def get_queryset(self, *args, **kwargs): + return self.queryset_spy + + +class IncludeView(PreloadIncludesMixin, QuerySetProvider): + select_for_includes = { + "__all__": ["owner"], + "author": ["author", "author__profile"], + } + prefetch_for_includes = { + "author": ["author__books"], + "comments": lambda view: [f"comments_for_{view.request.marker}"], + } + + +def test_preload_includes_applies_requested_and_unconditional_rules(monkeypatch): + queryset = QuerySetSpy() + view = IncludeView() + view.queryset_spy = queryset + view.request = SimpleNamespace(marker="request") + monkeypatch.setattr( + "enhanced_drf_jsonapi.api.get_included_resources", + lambda request: ["author", "comments"], + ) + + assert view.get_queryset() is queryset + assert queryset.select_calls == [("author", "author__profile"), ("owner",)] + assert queryset.prefetch_calls == [ + ("author__books",), + ("comments_for_request",), + ] + + +def serializer_fields(monkeypatch, method, *, include="", data=None, configured=True): + monkeypatch.setattr( + serializers.ModelSerializer, + "get_field_names", + lambda self, declared_fields, info: ["id", "name"], + ) + request = SimpleNamespace( + method=method, + query_params={"include": include} if include else {}, + data=data or {}, + ) + serializer = ReasonableModelSerializer(context={"request": request}) + if configured: + serializer.included_serializers = {"owner": object, "comments": object} + return serializer.get_field_names({}, None) + + +def test_serializer_adds_requested_get_relationships_without_duplicates(monkeypatch): + fields = serializer_fields(monkeypatch, "GET", include="owner,comments,missing,owner") + assert fields == ["id", "name", "owner", "comments"] + + +def test_serializer_adds_relationships_from_post_and_patch_payloads(monkeypatch): + assert serializer_fields(monkeypatch, "POST", data={"owner": {}, "other": 1}) == [ + "id", + "name", + "owner", + ] + assert serializer_fields(monkeypatch, "PATCH", data={"comments": []}) == [ + "id", + "name", + "comments", + ] + + +def test_serializer_is_safe_without_request_or_included_serializers(monkeypatch): + monkeypatch.setattr( + serializers.ModelSerializer, + "get_field_names", + lambda self, declared_fields, info: ["id"], + ) + assert ReasonableModelSerializer().get_field_names({}, None) == ["id"] + assert serializer_fields(monkeypatch, "GET", include="owner", configured=False) == [ + "id", + "name", + ] + + +def test_view_classes_preserve_methods_and_offer_opt_in_hardening(): + assert ReasonableModelViewSet.http_method_names == [ + "get", + "post", + "patch", + "delete", + "head", + "options", + ] + assert issubclass(HardenedModelViewSet, HardenedExceptionHandlingMixin) + assert issubclass(HardenedGenericAPIView, HardenedExceptionHandlingMixin) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..b687e38 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,67 @@ +import re + +import pytest +from django.db import IntegrityError +from rest_framework.exceptions import NotFound +from rest_framework.test import APIRequestFactory +from rest_framework.views import APIView + +from enhanced_drf_jsonapi.exceptions import ( + ERROR_ID_HEADER, + APIConflictException, + HardenedExceptionHandlingMixin, + hardened_exception_handler, +) + + +class ExceptionView(HardenedExceptionHandlingMixin, APIView): + authentication_classes = [] + permission_classes = [] + exception = None + + def get(self, request): + raise self.exception + + +def get_response(exception): + view = ExceptionView.as_view(exception=exception) + request = APIRequestFactory().get( + "/failure", + HTTP_ACCEPT="application/vnd.api+json", + ) + response = view(request) + response.render() + return response + + +def test_expected_api_exception_keeps_its_status_without_error_id(): + response = get_response(NotFound("missing")) + assert response.status_code == 404 + assert ERROR_ID_HEADER not in response + assert b"missing" in response.rendered_content + + +def test_deliberate_conflict_is_a_sanitized_409(): + response = get_response(APIConflictException()) + assert response.status_code == 409 + assert ERROR_ID_HEADER not in response + assert b"current resource state" in response.rendered_content + + +@pytest.mark.parametrize( + "exception", + [RuntimeError("database-password=secret"), IntegrityError("private row value")], +) +def test_unexpected_exception_is_logged_and_sanitized(exception, caplog): + with caplog.at_level("ERROR"): + response = get_response(exception) + + assert response.status_code == 500 + assert re.fullmatch(r"[0-9a-f]{32}", response[ERROR_ID_HEADER]) + assert b"unexpected server error" in response.rendered_content + assert str(exception).encode() not in response.rendered_content + assert response[ERROR_ID_HEADER] in caplog.text + + +def test_mixin_selects_the_hardened_handler(): + assert ExceptionView().get_exception_handler() is hardened_exception_handler diff --git a/tests/test_pagination.py b/tests/test_pagination.py new file mode 100644 index 0000000..20af6b7 --- /dev/null +++ b/tests/test_pagination.py @@ -0,0 +1,56 @@ +from types import SimpleNamespace + +from enhanced_drf_jsonapi.pagination import NgxJsonApiPageNumberPagination + + +def make_page(*, number, pages, count, per_page, next_page=None, previous_page=None): + return SimpleNamespace( + number=number, + paginator=SimpleNamespace(num_pages=pages, count=count, per_page=per_page), + has_next=lambda: next_page is not None, + has_previous=lambda: previous_page is not None, + next_page_number=lambda: next_page, + previous_page_number=lambda: previous_page, + ) + + +def test_pagination_response_preserves_ngx_jsonapi_contract(): + pagination = NgxJsonApiPageNumberPagination() + pagination.page = make_page( + number=2, + pages=4, + count=37, + per_page=10, + next_page=3, + previous_page=1, + ) + pagination.build_link = lambda page: None if page is None else f"/items?page={page}" + + response = pagination.get_paginated_response([{"id": "one"}]) + + assert response.data == { + "results": [{"id": "one"}], + "meta": { + "page": 2, + "pages": 4, + "total_resources": 37, + "resources_per_page": 10, + }, + "links": { + "first": "/items?page=1", + "last": "/items?page=4", + "next": "/items?page=3", + "prev": "/items?page=1", + }, + } + + +def test_pagination_uses_null_links_at_the_boundaries(): + pagination = NgxJsonApiPageNumberPagination() + pagination.page = make_page(number=1, pages=1, count=0, per_page=25) + pagination.build_link = lambda page: None if page is None else f"/items?page={page}" + + response = pagination.get_paginated_response([]) + + assert response.data["links"]["next"] is None + assert response.data["links"]["prev"] is None diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..f21983c --- /dev/null +++ b/tox.ini @@ -0,0 +1,11 @@ +[tox] +env_list = py{310,311,312,313,314}-dja{71,81} +skip_missing_interpreters = true + +[testenv] +package = editable +extras = test +deps = + dja71: djangorestframework-jsonapi~=7.1.0 + dja81: djangorestframework-jsonapi~=8.1.0 +commands = python -m pytest