diff --git a/.gitignore b/.gitignore index 6bc899e..0f1a7dd 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,5 @@ ENV/ # IDE settings .vscode/ .idea/ + +tests/.hypothesis diff --git a/docs/changelog.md b/docs/changelog.md index c37838e..375d845 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [UNRELEASED] +### Fixed +- use a pattern to correctly describe the `attr` value in validation errors in all cases. Specifically, this fixes +the incorrect description of the `attr` value for list serializers and list/dict fields. Previously, the `attr` +value was described with an enum having a single value like `INDEX.field`. Now, it shows up as a string with +the pattern `^\d+\.field$`. This improves compatibility with response-schema validation tools such as schemathesis. ## [0.16.0] - 2026-04-29 ### Added diff --git a/docs/settings.md b/docs/settings.md index c23a88c..713face 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -43,16 +43,18 @@ DRF_STANDARDIZED_ERRORS = { "ERROR_SCHEMAS": None, # When there is a validation error in list serializers, the "attr" returned - # will be sth like "0.email", "1.email", "2.email", ... So, to describe - # the error codes linked to the same field in a list serializer, the field - # will appear in the schema with the name "INDEX.email" + # will be sth like "0.email", "1.email", "2.email", ... So, this setting is + # used to represent the error codes linked to the same field during API + # schema generation and its value will be part of the name of the + # corresponding error component. "LIST_INDEX_IN_API_SCHEMA": "INDEX", # When there is a validation error in a DictField with the name "extra_data", # the "attr" returned will be sth like "extra_data.", "extra_data.", # "extra_data.", ... Since the keys of a DictField are not predetermined, - # this setting is used as a common name to be used in the API schema. So, the - # corresponding "attr" value for the previous example will be "extra_data.KEY" + # this setting is used to represent the error codes linked to the same field + # during API schema generation and its value will be part of the name of the + # corresponding error component. "DICT_KEY_IN_API_SCHEMA": "KEY", # should be unique to error components since it is used to identify error diff --git a/drf_standardized_errors/openapi_utils.py b/drf_standardized_errors/openapi_utils.py index a032f0d..81cecbd 100644 --- a/drf_standardized_errors/openapi_utils.py +++ b/drf_standardized_errors/openapi_utils.py @@ -1,11 +1,13 @@ +import re from dataclasses import dataclass, field as dataclass_field -from typing import Any, Dict, List, Optional, Set, Type, Union +from typing import Dict, List, Optional, Set, Type, Union import django import rest_framework from django import forms from django.core.validators import ( DecimalValidator, + RegexValidator, validate_image_file_extension, validate_integer, validate_ipv4_address, @@ -52,10 +54,11 @@ def get_flat_serializer_fields( field = force_instance(field) if is_list_serializer(field): prefix = get_prefix(prefix, field.field_name) + parent_fields = [InputDataField(prefix, field)] if prefix else [] non_field_errors_name = get_prefix(prefix, drf_settings.NON_FIELD_ERRORS_KEY) f = InputDataField(non_field_errors_name, field) prefix = get_prefix(prefix, package_settings.LIST_INDEX_IN_API_SCHEMA) - return [f] + get_flat_serializer_fields(field.child, prefix) + return parent_fields + [f] + get_flat_serializer_fields(field.child, prefix) elif isinstance(field, PolymorphicProxySerializer): if isinstance(field.serializers, dict): return get_flat_serializer_fields(list(field.serializers.values()), prefix) @@ -63,9 +66,14 @@ def get_flat_serializer_fields( return get_flat_serializer_fields(field.serializers, prefix) elif is_serializer(field): prefix = get_prefix(prefix, field.field_name) + parent_fields = [InputDataField(prefix, field)] if prefix else [] non_field_errors_name = get_prefix(prefix, drf_settings.NON_FIELD_ERRORS_KEY) f = InputDataField(non_field_errors_name, field) - return [f] + get_flat_serializer_fields(list(field.fields.values()), prefix) + return ( + parent_fields + + [f] + + get_flat_serializer_fields(list(field.fields.values()), prefix) + ) elif isinstance(field, (list, tuple)): first, *remaining = field return get_flat_serializer_fields(first, prefix) + get_flat_serializer_fields( @@ -429,21 +437,16 @@ class Meta: def get_error_serializer( operation_id: str, attr: Optional[str], error_codes: Set[str] ) -> Type[serializers.Serializer]: - attr_kwargs: Dict[str, Any] = {"choices": [(attr, attr)]} - if not attr: - attr_kwargs["allow_null"] = True + if attr is not None: + attr_regex = _get_attr_regex(attr) + attr_field = serializers.CharField(validators=[RegexValidator(attr_regex)]) + else: + attr_field = serializers.CharField(allow_null=True) error_code_choices = sorted(zip(error_codes, error_codes)) - - camelcase_operation_id = camelize(operation_id) - attr_with_underscores = (attr or "").replace( - package_settings.NESTED_FIELD_SEPARATOR, "_" - ) - camelcase_attr = camelize(attr_with_underscores) - suffix = package_settings.ERROR_COMPONENT_NAME_SUFFIX - component_name = f"{camelcase_operation_id}{camelcase_attr}{suffix}" + component_name = _get_error_component_name(operation_id, attr) class ErrorSerializer(serializers.Serializer): - attr = serializers.ChoiceField(**attr_kwargs) + attr = attr_field code = serializers.ChoiceField(choices=error_code_choices) detail = serializers.CharField() @@ -453,6 +456,58 @@ class Meta: return ErrorSerializer +def _get_attr_regex(attr: str) -> str: + r""" + - For ListSerializers: + - input attr: "INDEX.field1", "INDEX.field2", ... + - regex generated: "^\d+\.field1$", "^\d+\.field2$", ... + - actual field name: "0.field1", "1.field2", ... + - For ListFields: + - input attr: "field.INDEX" + - regex generated: "^field\.\d+$" + - actual field name: "0.field1", "1.field2", ... + - For DictFields: + - input attr: "field.KEY" + - regex generated: r"^field\.[\s\S]*$" + - actual field name: "field.key1", "field.key2", ... + - For other cases + - input attr: "field.nested_field" + - regex generated: "^field\.nested_field$" + - actual field name: "field.nested_field" + + The pattern is anchored with ``^`` and ``$`` so that the ``oneOf`` of error + components remains mutually exclusive. Without anchors, the substring nature of + JSON Schema's ``pattern`` makes a value like ``field1.0`` match both ``field1`` + (the field-level error) and ``field1\.\d+`` (the per-item error), so the value + validates against two ``oneOf`` branches at once. Tools that validate responses + against the schema (e.g. schemathesis) then report a conformance failure because + ``oneOf`` requires exactly one match. See + https://github.com/ghazi-git/drf-standardized-errors/issues/76. + """ + parts = attr.split(package_settings.NESTED_FIELD_SEPARATOR) + regex_parts = [] + for part in parts: + if part == package_settings.LIST_INDEX_IN_API_SCHEMA: + regex_parts.append(r"\d+") + elif part == package_settings.DICT_KEY_IN_API_SCHEMA: + regex_parts.append(r"[\s\S]*") + else: + regex_parts.append(re.escape(part)) + + escaped_separator = re.escape(package_settings.NESTED_FIELD_SEPARATOR) + return f"^{escaped_separator.join(regex_parts)}$" + + +def _get_error_component_name(operation_id: str, attr: Optional[str]) -> str: + camelcase_operation_id = camelize(operation_id) + attr_with_underscores = (attr or "").replace( + package_settings.NESTED_FIELD_SEPARATOR, "_" + ) + camelcase_attr = camelize(attr_with_underscores) + suffix = package_settings.ERROR_COMPONENT_NAME_SUFFIX + return f"{camelcase_operation_id}{camelcase_attr}{suffix}" + + @dataclass class InputDataField: name: str diff --git a/tests/fuzzing_urls.py b/tests/fuzzing_urls.py new file mode 100644 index 0000000..edf362a --- /dev/null +++ b/tests/fuzzing_urls.py @@ -0,0 +1,25 @@ +from django.urls import path +from drf_spectacular.views import SpectacularAPIView + +from .views import ( + DictFieldFuzzingSerializer, + FuzzingView, + ListFieldFuzzingSerializer, + ListSerializerFuzzingSerializer, +) + +urlpatterns = [ + path( + "fuzzing/list_field/", + FuzzingView.as_view(serializer_class=ListFieldFuzzingSerializer), + ), + path( + "fuzzing/list_serializer/", + FuzzingView.as_view(serializer_class=ListSerializerFuzzingSerializer), + ), + path( + "fuzzing/dict_field/", + FuzzingView.as_view(serializer_class=DictFieldFuzzingSerializer), + ), + path("schema/", SpectacularAPIView.as_view(), name="api-schema"), +] diff --git a/tests/settings.py b/tests/settings.py index 742d716..444899d 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -49,3 +49,6 @@ "drf_standardized_errors.openapi_hooks.postprocess_schema_enums" ], } + +STATIC_URL = "/static/" +MEDIA_URL = "/media/" diff --git a/tests/test_api_fuzzing.py b/tests/test_api_fuzzing.py new file mode 100644 index 0000000..04437c5 --- /dev/null +++ b/tests/test_api_fuzzing.py @@ -0,0 +1,68 @@ +from typing import List +from urllib.parse import urljoin + +import pytest +import schemathesis +from hypothesis import settings +from schemathesis import Case + + +@pytest.fixture +def run_test_server(live_server, settings): + settings.ROOT_URLCONF = "tests.fuzzing_urls" + + schema_url = urljoin(live_server.url, "/schema/") + return schemathesis.openapi.from_url(schema_url) + + +schema = schemathesis.pytest.from_fixture("run_test_server") + +list_field_schema = schema.include(path="/fuzzing/list_field/") +list_serializer_schema = schema.include(path="/fuzzing/list_serializer/") +dict_field_schema = schema.include(path="/fuzzing/dict_field/") + + +@schemathesis.hook +def before_add_examples( + context: schemathesis.HookContext, + examples: List[Case], +) -> None: + operation = context.operation + assert operation is not None + + if operation.path == "/fuzzing/list_field/": + case = operation.Case( + body={"field1": [None]}, + media_type="application/json", + ) + examples.append(case) + if operation.path == "/fuzzing/dict_field/": + case = operation.Case( + body={"field1": {"my_int": "non_integer_value"}}, + media_type="application/json", + ) + examples.append(case) + if operation.path == "/fuzzing/list_serializer/": + case = operation.Case( + body={"field1": [{"field2": None}]}, + media_type="application/json", + ) + examples.append(case) + + +@list_field_schema.parametrize() +@settings(max_examples=100) +def test_compliance_to_api_schema_for_list_field(case): + case.call_and_validate() + + +@list_serializer_schema.parametrize() +@settings(max_examples=100) +def test_compliance_to_api_schema_for_list_serializer(case): + case.call_and_validate() + + +@dict_field_schema.parametrize() +@settings(max_examples=100) +def test_compliance_to_api_schema_for_dict_field(case): + case.call_and_validate() diff --git a/tests/test_openapi_utils.py b/tests/test_openapi_utils.py index dd0c523..9d1e6a3 100644 --- a/tests/test_openapi_utils.py +++ b/tests/test_openapi_utils.py @@ -51,15 +51,19 @@ def test_get_flat_serializer_fields(): fields = get_flat_serializer_fields(CustomSerializer(many=True)) expected_fields = { "non_field_errors", + "INDEX", "INDEX.non_field_errors", "INDEX.field1", "INDEX.field2", "INDEX.field2.INDEX", + "INDEX.field3", "INDEX.field3.non_field_errors", "INDEX.field3.nested_field1", "INDEX.field3.nested_field1.KEY", "INDEX.field3.nested_field2", "INDEX.field4.non_field_errors", + "INDEX.field4", + "INDEX.field4.INDEX", "INDEX.field4.INDEX.non_field_errors", "INDEX.field4.INDEX.nested_field1", "INDEX.field4.INDEX.nested_field1.KEY", @@ -73,6 +77,7 @@ def test_get_flat_serializer_fields_with_nested_read_only(): fields = get_flat_serializer_fields(CustomSerializerWithNestedReadOnly(many=True)) expected_fields = { "non_field_errors", + "INDEX", "INDEX.non_field_errors", "INDEX.field1", "INDEX.field2", @@ -659,7 +664,12 @@ def test_updated_error_component_name_suffix(settings): def test_list_index_in_api_schema(): fields = get_flat_serializer_fields(UserSerializer(many=True)) - expected_fields = {"non_field_errors", "INDEX.non_field_errors", "INDEX.name"} + expected_fields = { + "non_field_errors", + "INDEX", + "INDEX.non_field_errors", + "INDEX.name", + } assert {field.name for field in fields} == expected_fields @@ -667,7 +677,12 @@ def test_updated_list_index_in_api_schema(settings): settings.DRF_STANDARDIZED_ERRORS = {"LIST_INDEX_IN_API_SCHEMA": "IDX"} fields = get_flat_serializer_fields(UserSerializer(many=True)) - expected_fields = {"non_field_errors", "IDX.non_field_errors", "IDX.name"} + expected_fields = { + "non_field_errors", + "IDX", + "IDX.non_field_errors", + "IDX.name", + } assert {field.name for field in fields} == expected_fields diff --git a/tests/test_openapi_validation_errors.py b/tests/test_openapi_validation_errors.py index 5d40b6f..9f1dab0 100644 --- a/tests/test_openapi_validation_errors.py +++ b/tests/test_openapi_validation_errors.py @@ -4,7 +4,7 @@ from drf_spectacular.utils import extend_schema from rest_framework import serializers from rest_framework.decorators import action, api_view -from rest_framework.generics import DestroyAPIView, UpdateAPIView +from rest_framework.generics import DestroyAPIView, GenericAPIView, UpdateAPIView from rest_framework.response import Response from rest_framework.versioning import URLPathVersioning from rest_framework.viewsets import ModelViewSet @@ -512,3 +512,45 @@ def test_extra_validation_errors_for_nested_list_serializer_field( schema = generate_view_schema(route, view) error_codes = get_error_codes(schema, "ValidateCreateGroupsINDEXNameErrorComponent") assert "some_error" in error_codes + + +def test_pattern_for_list_serializer_field(viewset_with_nested_serializer): + route = "validate/" + view = viewset_with_nested_serializer.as_view({"post": "create"}) + schema = generate_view_schema(route, view) + attr = schema["components"]["schemas"][ + "ValidateCreateGroupsINDEXNameErrorComponent" + ]["properties"]["attr"] + assert attr["pattern"] == r"^groups\.\d+\.name$" + + +@pytest.fixture +def list_dict_fields_view(): + class SomeSerializer(serializers.Serializer): + field1 = serializers.DictField(child=serializers.IntegerField()) + field2 = serializers.ListField(child=serializers.IntegerField()) + + class SomeView(GenericAPIView): + serializer_class = SomeSerializer + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + return Response(serializer.data) + + return SomeView + + +def test_pattern_for_list_dict_fields(list_dict_fields_view): + route = "validate/" + view = list_dict_fields_view.as_view() + schema = generate_view_schema(route, view) + dict_attr = schema["components"]["schemas"][ + "ValidateCreateField1KEYErrorComponent" + ]["properties"]["attr"] + assert dict_attr["pattern"] == r"^field1\.[\s\S]*$" + + list_attr = schema["components"]["schemas"][ + "ValidateCreateField2INDEXErrorComponent" + ]["properties"]["attr"] + assert list_attr["pattern"] == r"^field2\.\d+$" diff --git a/tests/views.py b/tests/views.py index 46548e2..88c45f8 100644 --- a/tests/views.py +++ b/tests/views.py @@ -2,6 +2,7 @@ from rest_framework import serializers from rest_framework.authentication import BasicAuthentication from rest_framework.generics import GenericAPIView +from rest_framework.parsers import JSONParser from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.throttling import BaseThrottle @@ -73,3 +74,28 @@ class RecursionView(APIView): def get(self, request, *args, **kwargs): errors = [{"field": ["Some Error"]} for _ in range(1, 1000)] raise serializers.ValidationError(errors) + + +class ListFieldFuzzingSerializer(serializers.Serializer): + field1 = serializers.ListField(child=serializers.IntegerField()) + + +class SomeSerializer(serializers.Serializer): + field2 = serializers.IntegerField() + + +class ListSerializerFuzzingSerializer(serializers.Serializer): + field1 = SomeSerializer(many=True) + + +class DictFieldFuzzingSerializer(serializers.Serializer): + field1 = serializers.DictField(child=serializers.IntegerField()) + + +class FuzzingView(GenericAPIView): + parser_classes = [JSONParser] + + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + return Response(data=serializer.data) diff --git a/tox.ini b/tox.ini index de3119c..5045ec8 100644 --- a/tox.ini +++ b/tox.ini @@ -34,7 +34,9 @@ python = [testenv] deps = pytest + pytest-env pytest-django + schemathesis drf-spectacular>=0.29.0 django-filter dj32: Django>=3.2,<4.0 @@ -68,6 +70,8 @@ commands = sphinx-build -d "{toxworkdir}/docs_doctree" docs "{toxworkdir}/docs_o DJANGO_SETTINGS_MODULE = tests.settings testpaths = tests pythonpath = . drf_standardized_errors +env = + DJANGO_LIVE_TEST_SERVER_ADDRESS=127.0.0.1:8000 [coverage:run] branch = True