FEAT: Modernized to support latest Pythons and DRF JSONAPI

This commit is contained in:
2026-08-23 12:33:13 +02:00
parent 533789645a
commit ddcf8fb039
22 changed files with 718 additions and 138 deletions
+41 -9
View File
@@ -1,14 +1,46 @@
image: python:3.8
stages: stages:
- test
- build
- publish - publish
pack: compatibility:
stage: publish 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: script:
- pip install twine setuptools - python -m pip install --upgrade pip
- python setup.py sdist bdist_wheel - python -m pip install -e ".[test]" "djangorestframework-jsonapi~=${DRF_JSONAPI_VERSION}.0"
- python -m twine upload -u iruiz --repository-url https://git.ruiz.wang/api/packages/Public/pypi dist/* - python -m pytest
only: lint:
- tags 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/*
+8
View File
@@ -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
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
<excludeFolder url="file://$MODULE_DIR$/venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.13 (enhanced-drf-jsonapi)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
</module>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.11 (enhanced-drf-jsonapi)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13 (enhanced-drf-jsonapi)" project-jdk-type="Python SDK" />
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/enhanced-drf-jsonapi.iml" filepath="$PROJECT_DIR$/.idea/enhanced-drf-jsonapi.iml" />
</modules>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+17
View File
@@ -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.
+21
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
include CHANGELOG.md
include LICENSE
include tox.ini
recursive-include tests *.py
+76 -18
View File
@@ -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.103.14
- `djangorestframework-jsonapi` 7.1 and 8.1
- The Django and Django REST framework releases accepted by the selected
`djangorestframework-jsonapi` version
#### API The GitLab test matrix exercises every supported Python/DRF JSON:API
- ##### class PreloadIncludesMixin combination. Applications may stay on 7.1 while upgrading independently, then
Overwrites the method get_queryset(self, *args, **kwarg) move to 8.1 without changing imports from this package.
- ##### class ReasonableModelViewSet
Overwrites the attribute http_method_names
- ##### class ReasonableModelSerializer ## API primitives
Overwrites the method get_field_names(self, declared_fields, info)
#### PAGINATION ```python
- ##### class NgxJsonApiPageNumberPagination from enhanced_drf_jsonapi.api import (
Overwrites the method get_paginated_response(self, data) ReasonableModelSerializer,
ReasonableModelViewSet,
basic_filter,
date_filter,
int_filter,
text_filter,
)
from enhanced_drf_jsonapi.pagination import NgxJsonApiPageNumberPagination
```
## Build the library - `PreloadIncludesMixin` applies `select_for_includes` and
In root directory, run `python setup.py bdist_wheel`. This will create a wheel file in `dist` folder. `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 ## Hardened exception handling
Run this command in the desired python environment `pip install path/to/wheelfile.whl`.
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.103.14 × DRF JSON:API 7.1/8.1 matrix.
+3
View File
@@ -0,0 +1,3 @@
"""Shared extensions for Django REST framework JSON:API."""
__version__ = "1.1.0"
+66 -60
View File
@@ -1,88 +1,94 @@
"""Reusable serializers and views for DRF JSON:API applications."""
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework.generics import GenericAPIView
from rest_framework_json_api import serializers from rest_framework_json_api import serializers
from rest_framework_json_api.utils import get_included_resources from rest_framework_json_api.utils import get_included_resources
from rest_framework_json_api.views import AutoPrefetchMixin, RelatedMixin from rest_framework_json_api.views import AutoPrefetchMixin, RelatedMixin
basic_filter = ('exact', 'isnull') from .exceptions import HardenedExceptionHandlingMixin
text_filter = ('exact', 'contains', 'iexact', 'icontains', 'startswith', 'istartswith', 'endswith', 'iendswith')
date_filter = ('exact', 'gte', 'lte') basic_filter = ("exact", "isnull")
int_filter = ('exact', 'gte', 'lte') text_filter = (
"exact",
"contains",
"iexact",
"icontains",
"startswith",
"istartswith",
"endswith",
"iendswith",
)
date_filter = ("exact", "gte", "lte")
int_filter = ("exact", "gte", "lte")
class PreloadIncludesMixin(object): class PreloadIncludesMixin:
""" """Apply configured select/prefetch rules for requested JSON:API includes."""
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'],
}
"""
def get_select_related(self, include): 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): 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): 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 include in includes:
for included in included_resources + ['__all__']: select_related = self.get_select_related(include)
select_related = self.get_select_related(included)
if select_related is not None: 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) prefetch_related = self.get_prefetch_related(include)
if prefetch_related is not None: if prefetch_related is None:
if not isinstance(prefetch_related, list): continue
qs = qs.prefetch_related(*prefetch_related(self)) if callable(prefetch_related):
else: prefetch_related = prefetch_related(self)
qs = qs.prefetch_related(*prefetch_related) queryset = queryset.prefetch_related(*prefetch_related)
return qs return queryset
class ReasonableModelViewSet(AutoPrefetchMixin, class ReasonableModelViewSet(
AutoPrefetchMixin,
PreloadIncludesMixin, PreloadIncludesMixin,
RelatedMixin, RelatedMixin,
viewsets.ModelViewSet): viewsets.ModelViewSet,
http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options'] ):
"""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): class ReasonableModelSerializer(serializers.ModelSerializer):
""" """Include declared relationship fields requested by JSON:API clients."""
Reusable class to extend DJA ModelSerializer and make it more JSONAPI compatible
"""
def get_field_names(self, declared_fields, info): 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 = [] included_relationships = []
if method == 'GET': if request.method == "GET":
included_rel_fields = [qp for qp in ( included_relationships = request.query_params.get("include", "").split(",")
self.context['request'].query_params['include'].split(',') elif request.method in {"POST", "PATCH"}:
if 'include' in self.context['request'].query_params else [] included_relationships = request.data.keys()
) 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 [])]
fields = super().get_field_names(declared_fields, info) fields.extend(
return fields + included_rel_fields relationship
for relationship in included_relationships
if relationship in included_serializers and relationship not in fields
)
return fields
+58
View File
@@ -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
+19 -21
View File
@@ -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.response import Response
from rest_framework_json_api.pagination import JsonApiPageNumberPagination from rest_framework_json_api.pagination import JsonApiPageNumberPagination
class NgxJsonApiPageNumberPagination(JsonApiPageNumberPagination): class NgxJsonApiPageNumberPagination(JsonApiPageNumberPagination):
"""Expose page-number metadata in the shape expected by ngx-jsonapi."""
def get_paginated_response(self, data): def get_paginated_response(self, data):
next = None next_page = None
previous = None previous_page = None
if self.page.has_next(): if self.page.has_next():
next = self.page.next_page_number() next_page = self.page.next_page_number()
if self.page.has_previous(): if self.page.has_previous():
previous = self.page.previous_page_number() previous_page = self.page.previous_page_number()
return Response( return Response(
{ {
"results": data, "results": data,
"meta": OrderedDict( "meta": {
[ "page": self.page.number,
("page", self.page.number), "pages": self.page.paginator.num_pages,
("pages", self.page.paginator.num_pages), "total_resources": self.page.paginator.count,
("total_resources", self.page.paginator.count), "resources_per_page": self.page.paginator.per_page,
("resources_per_page", self.page.paginator.per_page), },
] "links": {
), "first": self.build_link(1),
"links": OrderedDict( "last": self.build_link(self.page.paginator.num_pages),
[ "next": self.build_link(next_page),
("first", self.build_link(1)), "prev": self.build_link(previous_page),
("last", self.build_link(self.page.paginator.num_pages)), },
("next", self.build_link(next)),
("prev", self.build_link(previous)),
]
),
} }
) )
+69
View File
@@ -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"]
+3 -27
View File
@@ -1,29 +1,5 @@
from setuptools import setup, find_packages """Compatibility shim for tooling that still invokes setup.py directly."""
import pathlib
HERE = pathlib.Path(__file__).parent from setuptools import setup
VERSION = '1.0.5' setup()
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
)
+31
View File
@@ -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()
+123
View File
@@ -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)
+67
View File
@@ -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
+56
View File
@@ -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
+11
View File
@@ -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