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
+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