OpenAPI¶
The opt-in drf-spectacular
integration documents each route from its spec.
from django.apps import AppConfig
class ApiConfig(AppConfig):
name = "api"
def ready(self):
from django_spec_rest.openapi import enable_openapi
enable_openapi()
enable_openapi() sets ServiceAutoSchema as the schema of ServiceCreateView,
ServiceUpdateView, ServiceDeleteView, SelectorListView,
SelectorRetrieveView, ServiceViewSet and SelectorViewSet, and of the
action mixins ServiceCreateMixin, ServiceUpdateMixin, ServiceDestroyMixin,
SelectorListMixin and SelectorRetrieveMixin, so every subclass inherits it;
a class setting schema itself keeps its own. Importing
django_spec_rest.openapi does not import drf-spectacular; only this call
does.
A viewset of your own over GenericViewSet, composed of the action mixins,
inherits the schema only where a mixin comes before GenericViewSet in its
bases, as in class NoteViewSet(ServiceCreateMixin, GenericViewSet): DRF's
APIView carries a schema of its own, so in
class NoteViewSet(GenericViewSet, ServiceCreateMixin) that one wins. Where
the mixin does come first, its ServiceAutoSchema also wins over a
DEFAULT_SCHEMA_CLASS of your own, even a subclass of ServiceAutoSchema;
set schema = YourSchema() on the viewset to keep the subclass. A
@service_action or @selector_action on a bare GenericViewSet has no mixin
to inherit it from. On drf-spectacular's AutoSchema either documents nothing
of its specs. Set schema = ServiceAutoSchema() on it (from
django_spec_rest.openapi.service_auto_schema), or point DRF's
DEFAULT_SCHEMA_CLASS at
"django_spec_rest.openapi.service_auto_schema.ServiceAutoSchema", which
documents a view no spec drives as drf-spectacular does.
The component names this schema keeps¶
ValidationErrors, PermissionDenied and NotAuthenticated are the bodies of
a spec's 400, 403 and 401. drf-spectacular keeps the first component it
registers under a name and answers it for every later one, with a warning only,
so a serializer of the project's under one of these names (a
ValidationErrorsSerializer, say) would be documented by the refusal's body,
or the refusal by the serializer's fields. Generating the schema refuses it
with ImproperlyConfigured instead, on a view of this package's or a view of
the project's on drf-spectacular's own AutoSchema, whichever comes first:
the message names the serializer, or the operation (The operation POST
/notes/), component or webhook that refers to it. Name the serializer's
component another way with @extend_schema_serializer(component_name=...).
The package's own 403 and 401 bodies are exported, as PermissionDeniedSerializer
and NotAuthenticatedSerializer from django_spec_rest.openapi, for a response
of yours to declare (see Refusals your project configures).
Declared on any view, a view of the package's or one on drf-spectacular's own
AutoSchema, either is the package's component, in whichever order the views
are resolved. A $ref you write yourself to one of these names, such as
@extend_schema(responses={403: {"$ref": "#/components/schemas/PermissionDenied"}}),
is refused on any view, a view of the package's included: declare the
serializer instead.
A document in which no spec uses one of these names leaves it to the project:
a ValidationErrors component of your own, beside specs that declare no
validator, is documented as yours.
The refusal on a view of the project's is a postprocessing hook,
django_spec_rest.openapi.reserved_components_hook.reserved_components_hook,
which enable_openapi() inserts first in drf-spectacular's
POSTPROCESSING_HOOKS, before your own, once however often it is called. It
runs first because it knows the package's own references by the identity of
their $ref strings; a hook of yours that rebuilds them (a JSON round trip)
then sees a document it has already checked. Anything that replaces that
list after enable_openapi() runs loses it, such as a SpectacularAPIView's
custom_settings setting POSTPROCESSING_HOOKS, or a reload of
drf-spectacular's settings: name the hook in that list by its dotted path, or
call enable_openapi() again after the reload.
What a route documents¶
- The request body is a
SerializerValidator's serializer, under drf-spectacular's component name for the class, built partial where the route validates partially: an update route'sPATCH, or a spec declaringpartial. A@service_actionPATCHruns its spec as declared, so it is validated whole and documented whole; a spec declaringpartialis the way to a partial call there, and the same holds for each variant of a decoratedDiscriminatedAction. Where the spec'sinput_datafills or may fill one of its fields, the body is a component of its own (see below). Amanyspec takes a list of its rows. Any other validator is the kernel'sspec_caller_input_schema, less the target lookup's parameters and the route's captures, which never come from the body: a key the server fills is not offered, and one a provider may fill or decline is offered and not required. Amanyspec's rows are read the same way, by what itsinput_datafills in every row.requestBody.requiredis stated wherever the wire refuses an empty body: every spec the route may run takes a body with a required field. ADiscriminatedActionwith a variant taking no body, or none of whose fields is required, leaves it out. - A
DiscriminatedAction's request is one component, named{View}_{action}_Request, whose schema is ananyOfover the body of each variant that takes one, a body two variants share listed once. It is ananyOfwhere drfs'PolymorphicServiceSpecdocumented aoneOf, because the edge drops a key a variant does not declare: a body one variant takes is also valid for another that declares fewer fields. Where no variant takes a body there is no request body and no component. Each member is the body its variant would be documented with on a plain route of the same method, so a decoratedPATCH's variants are whole and a variant declaringpartialis partial. Its responses are the variants' merged per status: one body where the variants agree, an inlineanyOfwhere they differ, and a 400 always, since the discriminator may refuse. Two operations whose component name is the same but whose members differ warn and document their ownanyOfinline. See One URL, several specs. - A Validator declaring
call_contextis asked under thereststamp, as dispatch asks it, so a field declared only torestis in the body and a field declared only to another transport is not.call_context["action"]is the action the route dispatches the method as in the document and in OPTIONS alike, andNoneon a standalone view, as dispatch stamps it. A document describes the transport and never one caller, socall_context["request"]isNonehere, and a Validator declaringcall_contextguardsrequestbefore reading it. One that reads an attribute of thatNoneraisesAttributeErrorout of generation: a document silently missing a body is worse than a failure you see at once. OPTIONS is asked under the request it answers, so a field keyed on the caller may be offered there and stays out of the document; there, a description that raisesAttributeError,KeyErrororTypeErrorleaves the method out ofactions, and any other exception, a Validator'sValueErrorincluded, is a - A
SerializerValidatorreads none of this. - The response body is a
SerializerPresenter's serializer, or another presenter's output schema, a list where the spec presents rows. A spec with no presenter documents no body (see the status below); a selector spec with none keeps the view'sserializer_class, which renders it. - The status is the view's
success_statuswhere it is anint, over the action's default. On a viewset it is the action's own where one is declared (Per-action answers). A body documented under 204 is documented as the 200 it is served as, with a 204 beside it where the spec can present nothing. A spec that can present nothing under a status the view sets answers that status with no body, so it lists no 204 beside the body. A spec with no presenter answers 204 where its service returnsNoneand the action's default, with the value, where it returns anything else;allow_noneis a declaration and no document can read what a call returns, so the document lists both with no schema:201and204on a create,200and204on a custom action. A destroy lists204alone, its default being the204; one whose service returns a value with no presenter answers an undocumented200, so give it a presenter or declare the response. A status the view sets is the one status. A declared success body replaces both and goes to the first, the default. ADiscriminatedActionlists each status any of its variants answers. - A 400 is documented wherever the spec declares a validator, because that
is the status a validator's refusal gets, whichever kind refused
(
SerializerValidator,DataclassValidatoror another validator raising the kernel'sInvalidArguments). Its body is theValidationErrorscomponent: the errors by name, as DRF sends them, each a list of messages, or an object for a nested field, or for a list its rows' errors keyed by the row's index (amanyspec's rows sit under itsmany_argument); the whole call's messages are undernon_field_errors. The 400 is any of three shapes, because the edge sends all three: that component;{"detail": ...}(theServiceErrorcomponent), for a body that is no object; and a bare list of messages, which is what a service'sServiceValidationErrorover a sentence or a list answers with. AServiceValidationErrorover a mapping of sentences gives a sentence where the component has a field's list, so a leaf of the component is a list of messages or a message. The component's name is this schema's: name a serializer's component another way with@extend_schema_serializer(component_name=...)if it would be namedValidationErrors, since drf-spectacular keeps one schema for both and documents it by the 400's body (or the 400 by its fields). Generating the schema refuses such a serializer withImproperlyConfigured, on any view and whichever is resolved first (see the names this schema keeps). - A 422 is documented where the spec declares it can answer one (see
Declaring the 422), whether or not it has a validator.
It is not a validator's refusal: it is a plain
ServiceErrorraised by the service, a precondition or a provider. Its body is{"detail": ...}, with theschemaanAdditionalInputRequiredcarries beside it when it carries one (an object, not required). - A selector spec documents the same two refusals, since a selector is
refused the same way: a 422 where it declares one (the selector, a
precondition or a provider can raise a plain
ServiceError), and a 400 where the spec takes any parameter, a read, a shape or a filter. The 400's body is the same as a validator's. A route capture is a parameter too: only a converter guarantees its type, and a router's default lookup pattern or astrconverter guarantees none. A key the spec declares nothing for is dropped rather than refused, so it earns no 400 of its own. - A 403 is documented wherever the spec declares a permission check, any
check but
Unrestricted: a check's denial and a principal who cannot act (a deactivated account) are both answered 403, with the refusal's sentence underdetail(thePermissionDeniedcomponent). A spec whose checks are allUnrestricteddocuments none. - A 401 is documented beside it where the view's first authenticator
sends a
WWW-Authenticatechallenge, such as DRF'sBasicAuthenticationorTokenAuthentication. Any of those checks denying (answeringFalseto) a caller nobody authenticated, a kernelPermissionCheckor aDRFPermissionCheckalike, is answered 401 there, underdetail(theNotAuthenticatedcomponent); behindSessionAuthentication, which sends no challenge, or a view with no authenticator, the same denial is the 403. A check that raisesNotPermitteditself is answered as raised, 403, with no challenge. The 403 and the 401 bodies are thePermissionDeniedandNotAuthenticatedcomponents, names this schema keeps as it doesValidationErrors: a serializer of yours that would be the component under either (aPermissionDeniedSerializerof your own, say) is refused withImproperlyConfigured, on any view and whichever is resolved first, until you name its component another way with@extend_schema_serializer(component_name=...). The package's ownPermissionDeniedSerializerandNotAuthenticatedSerializerare the components, and yours to declare. - A
filter_set's query parameters, on a list operation, are the kernel'sfilter_set_parameters. - A route capture's type is drf-spectacular's where it finds one: a
converter or a regex on the path, or the view's model, which it reads off
queryset. A spec viewset with noquerysetgives it no model offlist, so there the capture is typed from the read the operation's spec declares for it (a retrieve's or a detail action's selector, a write's target selector, or the write's ownreads), with the read'shelpas its description. A viewset needs noquerysetfor its schema. A capture no spec declares, or that only a validator's field names (the request body's, not the route's), stays drf-spectacular's string, with its warning. - A nullable or labelled schema is written for the document's OpenAPI
version. The kernel's schemas are JSON Schema, which 3.1 and later is, so
there the document keeps them as the kernel writes them, a route capture's
enumapart (below):{"type": [t, "null"]}, and a labelled choice as ananyOf(aoneOfin a response) of{"const", "title"}. OpenAPI 3.0, drf-spectacular's defaultOAS_VERSION, has no type list and noconst, so under it a nullable schema is written withnullable: true, and a labelled choice as anenumof its values in order, withnulllisted andnullable: truewhere it admits null. Titles are dropped, since 3.0 has no place for them. A choice that listsnullmakes a schemanullableonly where itstypeis not already one that refuses it: a plaintypebeside a null choice stays non-nullable, as the server answers400for a null there. This holds in a request body, a response body, afilter_set's query parameter and the read that types a route capture, nested as deep as they go. A captured segment is always there, so under every version a capture'senumlists nonull(anenumleft with no other value is dropped), and under 3.0 drf-spectacular leavesnullableoff a path parameter itself. Inside a named component (aDiscriminatedAction's{View}_{action}_Request), drf-spectacular's enum hook moves the values to*Enumcomponents, as it does for a serializer's choice field, which can rename a component they collide with. What an author writes injson_schema_extrareaches the document as written, since no kernel adapter writes those keys: under 3.0 it must be valid 3.0 (a{"type": "null"}member or a numericexclusiveMinimumis not). - A viewset method with no spec behind it is left out where
Allowleaves it out: on a route serving some spec, acreate,update,partial_updateordestroythe router maps but the viewset declares no spec for answers 405, so it is no operation. AServiceViewSetwith nodestroyspec documents noDELETE, and one with apartial_updatespec alone documentsPATCHand noPUT. Thelistandretrievehandlers stay documented where their fallback to DRF can run, on a viewset with rows to read (aquerysetorget_queryset()) and a serializer (aserializer_class,get_serializer_class()orget_serializer()). Where it cannot they answer 405 and are left out, with every other method no spec serves, on any route of that viewset: a create-onlyServiceViewSetdocumentsPOSTon its list route and no path for its detail route. A handler the viewset defines itself stays documented, and so does aretrievebeside its ownget_object().
ServiceErrorSerializer, a detail field and the optional schema object, is
the body of that 422 and of the 400's {"detail": ...} alternative, under the
ServiceError component.
Declaring the 422¶
The edge answers any plain ServiceError with a 422, but nothing in a spec
says whether its service, selector, preconditions or providers raise one, so
an operation documents the 422 only where its spec declares it, under the
metadata key "document_service_error" (exported as
django_spec_rest.openapi.SERVICE_ERROR_METADATA_KEY):
from django_service_specs import ServiceSpec
from django_spec_rest.openapi import SERVICE_ERROR_METADATA_KEY
book_room_spec = ServiceSpec(
service=book_room,
validator=...,
metadata={SERVICE_ERROR_METADATA_KEY: True},
)
It is read off the spec the route serves, on every surface that serves it: a
standalone view's spec, an action_specs entry, and a @service_action or
@selector_action. Only that spec's metadata is read, never a target or
re-read selector's nested in it. False, or no key, documents no 422; any
other value is refused with ImproperlyConfigured when the schema is
generated. An AdditionalInputRequired is a ServiceError too: a spec whose
service asks for more input declares the 422 the same way.
To document it on one view whatever its spec says, declare it there with
@extend_schema(responses={422: ServiceErrorSerializer}). To answer for many
operations at once, override ServiceAutoSchema.documents_service_error(spec)
in your schema class: it is asked once per operation with the served spec
(self.view and self.method are the operation's), and returning True
documents the 422 on every spec, as releases before 0.7.0 did:
from django_spec_rest.openapi.service_auto_schema import ServiceAutoSchema
class ApiSchema(ServiceAutoSchema):
def documents_service_error(self, spec):
return True
Your class documents the views it is the schema of: set schema = ApiSchema()
on your views, or on a base class they share. enable_openapi() sets the
package's own ServiceAutoSchema on the package's view classes, which
outranks a DEFAULT_SCHEMA_CLASS naming yours, as the top of this page
says.
Every other @extend_schema argument a project supplies wins, and
@extend_schema(parameters=...) merges over the filter parameters as it does
for a view-level FilterSet. responses= is added to what the spec documents,
as the next section says.
Refusals a service raises¶
Which refusals a service raises is yours to declare: a service may never
raise a ServiceValidationError, and a retrieval with a default never answers
404, so the schema documents only what the spec itself shows. Declare the rest
with drf-spectacular's own @extend_schema(responses=...), using the
schema-only serializers django_spec_rest.openapi exports. Each documents the
body map_service_error sends:
| The service raises | Status | Declare | Body |
|---|---|---|---|
ServiceValidationError |
400 | ServiceValidationErrorSerializer |
a list, or an object by name, whose members are a message, a list or an object of the same shape |
ServiceNotFound |
404 | ServiceNotFoundSerializer |
{"detail": ...} |
ServiceConflict |
409 | ServiceConflictSerializer |
{"detail": ...}, and code where it is an ActionUnavailable |
ActionUnavailable |
409 | ActionUnavailableSerializer |
{"detail": ..., "code": ...} |
any other ServiceError |
422 | the spec's metadata (see Declaring the 422), or ServiceErrorSerializer |
{"detail": ...}, and schema where an AdditionalInputRequired carries one |
from drf_spectacular.utils import OpenApiResponse, extend_schema
from django_spec_rest import ServiceUpdateView
from django_spec_rest.openapi import (
ActionUnavailableSerializer,
ServiceNotFoundSerializer,
)
@extend_schema(
responses={
404: ServiceNotFoundSerializer,
409: OpenApiResponse(ActionUnavailableSerializer, description="The task is closed."),
}
)
class CloseTaskView(ServiceUpdateView):
spec = close_task_spec
A mapping of statuses is added to what the spec documents: the operation
above still documents its success body, its validator's 400, its permission
check's 403 and any 422 its spec declares, with the 404 and 409 beside them. A status you declare
replaces the spec's at that status (404 and "404" are one status, and so is
a (404, "application/json") key), and any 2xx you declare replaces every
success status the spec documents. Anything else passed as responses=, a bare
serializer say, is the success body at the status the route answers with, except
where that is a 204: the edge sends no body with one, so it stays the
no-content 204. Only @extend_schema's own subclass is merged this way. A
ServiceAutoSchema subclass of yours that overrides get_response_serializers
is answered as you wrote it (a status you leave out stays out), and under an
@extend_schema it is what the declared statuses are laid over.
A service raising both ServiceConflict and its ActionUnavailable declares
ServiceConflictSerializer, which names code without requiring it.
ServiceValidationErrorSerializer admits every body the validator's 400
sends, so declaring it on a spec with a validator loses none of them.
The serializers are rendered by ServiceAutoSchema's module, which
enable_openapi() loads, so call it first. An @extend_schema on a view
class builds on the schema the class has when it is decorated: decorated
before enable_openapi() runs (a module imported from your AppConfig, say),
the class keeps drf-spectacular's own AutoSchema and documents nothing of its
spec. Decorating a view method, or an action through extend_schema_view, has
no such order, since drf-spectacular reads the class's schema when it
generates the document.
A serializer body that input_data reaches¶
A field the spec's input_data is annotated to fill is never the caller's,
and one it may fill (a key its TypedDict declares as T | UnsetType) is
not required of the caller, so the body says what OPTIONS says of the same
route. drf-spectacular documents a serializer, so the body is a subclass of
yours with the first kind of field dropped and the second not required,
under a component name of its own. A spec whose input_data reaches none of
the serializer's fields keeps your component as it is.
The name is your component's, then Less and the fields dropped, then
Optional and the fields relaxed. Each list is in sorted order, joined by
_, and the two lists, where both are present, by __. For a Task
component:
input_data fills |
it may fill | Component |
|---|---|---|
owner |
TaskLessOwner |
|
owner |
TaskOptionalOwner |
|
owner, tenant |
note |
TaskLessOwner_Tenant__OptionalNote |
owner_id |
TaskLessOwnerId |
A lower snake_case field is spelled in PascalCase. Any other field name,
such as ownerId, is spelled 0 and its UTF-8 bytes in hex, so that no two
sets of fields over one component spell the same name. drf-spectacular adds
what it adds to your own component: Patched in front for a PATCH or a
partial spec (PatchedTaskLessOwner), and Request behind under
COMPONENT_SPLIT_REQUEST. A name set with
@extend_schema_serializer(component_name=...) is the stem the subclass is
named from.
An input_data provider whose return type does not name its keys (a plain
dict) may fill any field, so it requires none: every field is offered and
none is required (TaskOptionalName_Note_Owner), as dispatch demands none of
the caller and OPTIONS requires none. The serializer still validates what the
provider leaves out, so a call missing a field the provider did not fill is
still refused. Annotate the provider's return with a TypedDict to document
which fields it fills.
Not documented¶
A selector spec's declared reads are not documented as query parameters, nor
the affordance envelope a spec declaring affordances renders, nor the null
body of an allow_none retrieve. Add them with @extend_schema where a
client needs them.
A refusal a service raises is not documented unless you declare it (see above), nor one your project configures (below).
Refusals your project configures¶
The 403 and 401 the schema documents are the ones a spec's own checks answer. Two other sources refuse a caller, and are yours to declare as a service's refusals are:
- The view's own
permission_classesand its authenticators, which DRF asks before the spec: a permission class's denial, and an authenticator refusing credentials it cannot verify. Each is a 403, or a 401 with the challenge where the view's first authenticator sends one. - A principal who cannot act (a deactivated account), refused 403 on every
operation, one whose checks are all
Unrestrictedincluded, which documents no 403. Only an authentication backend of your own that signs such a principal in can be answered so: DRF's own authenticators and Django'sModelBackendnever do.
Each body is DRF's {"detail": ...}, the body of the spec's own 403 and 401.
Declare it with the package's PermissionDeniedSerializer and
NotAuthenticatedSerializer, which are the PermissionDenied and
NotAuthenticated components, on a view of the package's or one of your own:
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.permissions import IsAdminUser
from django_spec_rest import ServiceCreateView
from django_spec_rest.openapi import NotAuthenticatedSerializer, PermissionDeniedSerializer
@extend_schema(
responses={
401: OpenApiResponse(NotAuthenticatedSerializer, description="No credentials were sent."),
403: OpenApiResponse(PermissionDeniedSerializer, description="The caller is not staff."),
}
)
class PublishReportView(ServiceCreateView):
spec = publish_report_spec
permission_classes = [IsAdminUser]
As with a service's refusals, the statuses are added to what the spec documents, and one the spec documents too is replaced by yours. The bodies are the spec's own, so a declared 403 still describes the spec's.