Document bulk errors format in OpenAPI schema
This commit is contained in:
parent
b1d1919db3
commit
66480beeca
|
|
@ -2,6 +2,7 @@ import re
|
||||||
import typing
|
import typing
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from drf_spectacular.contrib.django_filters import DjangoFilterExtension
|
from drf_spectacular.contrib.django_filters import DjangoFilterExtension
|
||||||
from drf_spectacular.extensions import OpenApiSerializerExtension, OpenApiSerializerFieldExtension, _SchemaType
|
from drf_spectacular.extensions import OpenApiSerializerExtension, OpenApiSerializerFieldExtension, _SchemaType
|
||||||
from drf_spectacular.openapi import AutoSchema
|
from drf_spectacular.openapi import AutoSchema
|
||||||
|
|
@ -14,10 +15,10 @@ from drf_spectacular.plumbing import (
|
||||||
get_doc,
|
get_doc,
|
||||||
)
|
)
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import Direction, OpenApiParameter
|
from drf_spectacular.utils import Direction, OpenApiParameter, OpenApiResponse
|
||||||
|
|
||||||
from netbox.api.fields import ChoiceField
|
from netbox.api.fields import ChoiceField
|
||||||
from netbox.api.serializers import WritableNestedSerializer
|
from netbox.api.serializers import BulkOperationErrorSerializer, WritableNestedSerializer
|
||||||
from netbox.api.viewsets import NetBoxModelViewSet
|
from netbox.api.viewsets import NetBoxModelViewSet
|
||||||
|
|
||||||
# see netbox.api.routers.NetBoxRouter
|
# see netbox.api.routers.NetBoxRouter
|
||||||
|
|
@ -182,6 +183,82 @@ class NetBoxAutoSchema(AutoSchema):
|
||||||
|
|
||||||
return response_serializers
|
return response_serializers
|
||||||
|
|
||||||
|
def _get_bulk_error_responses(self, direction) -> typing.Any:
|
||||||
|
"""
|
||||||
|
Return the error responses of the current bulk write action, keyed by status code, or an
|
||||||
|
empty dict if this action is not a bulk write.
|
||||||
|
|
||||||
|
A failed bulk write returns a structured body correlating each failure with the object (or,
|
||||||
|
where no object could be identified, the request position) responsible for it. This is a
|
||||||
|
documented part of the API contract, but drf-spectacular cannot infer it: responses are
|
||||||
|
derived from the request/response serializer alone, which describes only the success case.
|
||||||
|
"""
|
||||||
|
action = getattr(self.view, 'action', None)
|
||||||
|
|
||||||
|
if action in ('bulk_update', 'bulk_partial_update'):
|
||||||
|
return {
|
||||||
|
'400': OpenApiResponse(
|
||||||
|
response=BulkOperationErrorSerializer,
|
||||||
|
description=_(
|
||||||
|
"One or more of the objects specified could not be updated. No objects were "
|
||||||
|
"modified: a bulk update is an all-or-none operation."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
if action == 'bulk_destroy':
|
||||||
|
return {
|
||||||
|
'400': OpenApiResponse(
|
||||||
|
response=BulkOperationErrorSerializer,
|
||||||
|
description=_(
|
||||||
|
"The request was malformed, or one or more of the objects specified could "
|
||||||
|
"not be found. No objects were deleted."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'409': OpenApiResponse(
|
||||||
|
response=BulkOperationErrorSerializer,
|
||||||
|
description=_(
|
||||||
|
"One or more of the objects specified could not be deleted, because a "
|
||||||
|
"dependent object or a protection rule prevents it. No objects were "
|
||||||
|
"deleted: a bulk deletion is an all-or-none operation."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
if action == 'create' and viewset_handles_bulk_create(self.view):
|
||||||
|
# A POST to a list endpoint accepts either a single object or a list of them (see
|
||||||
|
# _get_request_for_media_type()), so its error body takes one of two shapes
|
||||||
|
# accordingly: field-keyed errors for a single object, or the bulk envelope for a list.
|
||||||
|
component = self.resolve_serializer(BulkOperationErrorSerializer, direction)
|
||||||
|
return {
|
||||||
|
'400': OpenApiResponse(
|
||||||
|
response={
|
||||||
|
'oneOf': [
|
||||||
|
build_basic_type(OpenApiTypes.OBJECT),
|
||||||
|
component.ref if component else build_basic_type(OpenApiTypes.OBJECT),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
description=_(
|
||||||
|
"The object could not be created. Where a list was submitted, no objects "
|
||||||
|
"were created: a bulk creation is an all-or-none operation."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _get_response_bodies(self, direction='response') -> typing.Any:
|
||||||
|
responses = super()._get_response_bodies(direction=direction)
|
||||||
|
|
||||||
|
# Document the error responses of the bulk write actions, which cannot be inferred (see
|
||||||
|
# _get_bulk_error_responses). A status code already present -- for instance one declared
|
||||||
|
# via @extend_schema on a custom action -- is left as it is.
|
||||||
|
for code, response in self._get_bulk_error_responses(direction).items():
|
||||||
|
if code not in responses:
|
||||||
|
responses[code] = self._get_response_for_code(response, code, direction=direction)
|
||||||
|
|
||||||
|
return responses
|
||||||
|
|
||||||
def _get_request_for_media_type(self, serializer, direction='request'):
|
def _get_request_for_media_type(self, serializer, direction='request'):
|
||||||
"""
|
"""
|
||||||
Override to generate oneOf schema for serializers that support both
|
Override to generate oneOf schema for serializers that support both
|
||||||
|
|
|
||||||
|
|
@ -107,3 +107,98 @@ class OpenAPISchemaTestCase(TestCase):
|
||||||
self.assertNotIn('oneOf', request_schema, "DELETE should NOT have oneOf")
|
self.assertNotIn('oneOf', request_schema, "DELETE should NOT have oneOf")
|
||||||
self.assertEqual(request_schema['type'], 'array', "DELETE should require array")
|
self.assertEqual(request_schema['type'], 'array', "DELETE should require array")
|
||||||
self.assertIn('items', request_schema, "DELETE array should have items")
|
self.assertIn('items', request_schema, "DELETE array should have items")
|
||||||
|
|
||||||
|
def _get_response_schema(self, path, method, code):
|
||||||
|
"""Return the JSON response schema documented for the given operation and status code."""
|
||||||
|
responses = self.schema['paths'][path][method]['responses']
|
||||||
|
self.assertIn(code, responses, f"{method.upper()} {path} should document a {code} response")
|
||||||
|
return responses[code]['content']['application/json']['schema']
|
||||||
|
|
||||||
|
def test_bulk_error_component_is_defined(self):
|
||||||
|
"""
|
||||||
|
The structured error body returned by a failed bulk operation should be a named component,
|
||||||
|
so that generated clients have a type for it.
|
||||||
|
|
||||||
|
Refs: #20054
|
||||||
|
"""
|
||||||
|
components = self.schema['components']['schemas']
|
||||||
|
|
||||||
|
self.assertIn('BulkOperationError', components)
|
||||||
|
envelope = components['BulkOperationError']
|
||||||
|
self.assertEqual(sorted(envelope['properties']), ['detail', 'errors'])
|
||||||
|
# `errors` is absent where the request could not be attributed to individual entries
|
||||||
|
self.assertEqual(envelope['required'], ['detail'])
|
||||||
|
self.assertEqual(
|
||||||
|
envelope['properties']['errors']['items']['$ref'],
|
||||||
|
'#/components/schemas/BulkOperationEntryError',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('BulkOperationEntryError', components)
|
||||||
|
entry = components['BulkOperationEntryError']
|
||||||
|
# An entry is correlated by `id` or by `index`, so neither is required; `errors` always is
|
||||||
|
self.assertEqual(sorted(entry['properties']), ['errors', 'id', 'index'])
|
||||||
|
self.assertEqual(entry['required'], ['errors'])
|
||||||
|
|
||||||
|
def test_bulk_update_documents_error_response(self):
|
||||||
|
"""
|
||||||
|
Bulk update operations should document the structured 400 response.
|
||||||
|
|
||||||
|
Refs: #20054
|
||||||
|
"""
|
||||||
|
ref = {'$ref': '#/components/schemas/BulkOperationError'}
|
||||||
|
|
||||||
|
for path in ('/api/dcim/sites/', '/api/ipam/prefixes/', '/api/users/users/'):
|
||||||
|
for method in ('put', 'patch'):
|
||||||
|
with self.subTest(path=path, method=method):
|
||||||
|
self.assertEqual(self._get_response_schema(path, method, '400'), ref)
|
||||||
|
|
||||||
|
def test_bulk_delete_documents_error_responses(self):
|
||||||
|
"""
|
||||||
|
Bulk delete operations should document both the 400 (unresolvable request) and the 409
|
||||||
|
(dependency or protection rule) responses.
|
||||||
|
|
||||||
|
Refs: #20054
|
||||||
|
"""
|
||||||
|
ref = {'$ref': '#/components/schemas/BulkOperationError'}
|
||||||
|
|
||||||
|
for path in ('/api/dcim/sites/', '/api/ipam/prefixes/', '/api/users/users/'):
|
||||||
|
with self.subTest(path=path):
|
||||||
|
self.assertEqual(self._get_response_schema(path, 'delete', '400'), ref)
|
||||||
|
self.assertEqual(self._get_response_schema(path, 'delete', '409'), ref)
|
||||||
|
|
||||||
|
def test_create_documents_error_response_for_either_shape(self):
|
||||||
|
"""
|
||||||
|
A POST to a list endpoint accepts either a single object or a list, so its 400 response
|
||||||
|
should document both the field-keyed and the bulk error shapes.
|
||||||
|
|
||||||
|
Refs: #20054
|
||||||
|
"""
|
||||||
|
for path in ('/api/dcim/sites/', '/api/ipam/prefixes/', '/api/users/users/'):
|
||||||
|
with self.subTest(path=path):
|
||||||
|
schema = self._get_response_schema(path, 'post', '400')
|
||||||
|
self.assertEqual(
|
||||||
|
schema['oneOf'],
|
||||||
|
[
|
||||||
|
{'type': 'object', 'additionalProperties': {}},
|
||||||
|
{'$ref': '#/components/schemas/BulkOperationError'},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_detail_operations_omit_bulk_error_response(self):
|
||||||
|
"""
|
||||||
|
The bulk error body applies only to list endpoints; detail endpoints must not advertise it.
|
||||||
|
|
||||||
|
Refs: #20054
|
||||||
|
"""
|
||||||
|
path = '/api/dcim/sites/{id}/'
|
||||||
|
|
||||||
|
for method in ('get', 'put', 'patch', 'delete'):
|
||||||
|
with self.subTest(method=method):
|
||||||
|
responses = self.schema['paths'][path][method]['responses']
|
||||||
|
self.assertNotIn('409', responses)
|
||||||
|
for code, response in responses.items():
|
||||||
|
schema = response.get('content', {}).get('application/json', {}).get('schema', {})
|
||||||
|
self.assertNotEqual(
|
||||||
|
schema.get('$ref'), '#/components/schemas/BulkOperationError',
|
||||||
|
f"{method.upper()} {path} ({code}) should not reference the bulk error body"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
import copy
|
import copy
|
||||||
import functools
|
import functools
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from .features import ChangeLogMessageSerializer
|
from .features import ChangeLogMessageSerializer
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'BulkOperationEntryErrorSerializer',
|
||||||
|
'BulkOperationErrorSerializer',
|
||||||
'BulkOperationSerializer',
|
'BulkOperationSerializer',
|
||||||
'BulkPartialUpdateSchemaMixin',
|
'BulkPartialUpdateSchemaMixin',
|
||||||
'BulkUpdateSchemaMixin',
|
'BulkUpdateSchemaMixin',
|
||||||
|
|
@ -17,6 +20,60 @@ class BulkOperationSerializer(ChangeLogMessageSerializer):
|
||||||
id = serializers.IntegerField()
|
id = serializers.IntegerField()
|
||||||
|
|
||||||
|
|
||||||
|
# The two serializers below are schema-only: they are never used to validate or render data. The
|
||||||
|
# bulk actions in netbox.api.viewsets.mixins assemble these payloads directly; these exist so that
|
||||||
|
# their error responses are a documented part of the OpenAPI schema rather than an untyped body.
|
||||||
|
# Note that a class docstring becomes the component's description in the published schema, so keep
|
||||||
|
# it user-facing.
|
||||||
|
class BulkOperationEntryErrorSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
The failure of a single object within a bulk operation.
|
||||||
|
"""
|
||||||
|
id = serializers.IntegerField(
|
||||||
|
required=False,
|
||||||
|
help_text=_(
|
||||||
|
"The ID of the object which failed. Present once the entry has been matched to an "
|
||||||
|
"object; mutually exclusive with `index`."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
index = serializers.IntegerField(
|
||||||
|
required=False,
|
||||||
|
help_text=_(
|
||||||
|
"The zero-based position of the entry within the submitted list. Used where no object "
|
||||||
|
"has been identified for the entry: always for creations, and for updates and deletions "
|
||||||
|
"where the entry itself could not be interpreted (e.g. a missing or non-numeric `id`). "
|
||||||
|
"Mutually exclusive with `id`."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
errors = serializers.DictField(
|
||||||
|
help_text=_(
|
||||||
|
"The errors for this entry, keyed by field name. Values are ordinarily arrays of "
|
||||||
|
"messages. Errors which do not pertain to a specific field appear under `__all__` "
|
||||||
|
"(model validation, protection rules, restricted tags) or `non_field_errors` (errors "
|
||||||
|
"concerning the shape of the entry itself)."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkOperationErrorSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
The body returned when a bulk operation fails, correlating each failure with the object
|
||||||
|
responsible for it.
|
||||||
|
"""
|
||||||
|
detail = serializers.CharField(
|
||||||
|
help_text=_('A summary of the failure, e.g. "1 of 3 objects failed validation."')
|
||||||
|
)
|
||||||
|
errors = BulkOperationEntryErrorSerializer(
|
||||||
|
many=True,
|
||||||
|
required=False,
|
||||||
|
help_text=_(
|
||||||
|
"One entry per object which failed; objects which would have succeeded are omitted, as "
|
||||||
|
"a bulk operation is all-or-none. Absent where the request could not be attributed to "
|
||||||
|
"individual entries at all (e.g. a request body which is not a list)."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BulkUpdateSchemaMixin:
|
class BulkUpdateSchemaMixin:
|
||||||
def get_fields(self):
|
def get_fields(self):
|
||||||
fields = super().get_fields()
|
fields = super().get_fields()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue