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