Moving from djangorestframework-services¶
django-spec-rest is djangorestframework-services (drfs) moved onto the
kernel, django-service-specs.
Its views, viewsets and decorators keep their names; what they serve is now
the kernel's own ServiceSpec and SelectorSpec, and DRF enters a spec
through three seats: a SerializerValidator, a SerializerPresenter and a
DRFPermissionCheck. drfs' own declaration types are gone, and with them
every name that re-exported a kernel object.
This page lists what a project moving from drfs 0.62.0 rewrites, and what its clients see change. drfs' history is in its archived changelog.
Installing¶
| drfs | django-spec-rest |
|---|---|
pip install djangorestframework-services |
pip install django-spec-rest |
import rest_framework_services |
import django_spec_rest |
"rest_framework_services" in INSTALLED_APPS |
nothing: the package has no models, templates or app config, so remove the entry |
the startserviceapp management command |
none |
djangorestframework-services[spectacular] |
django-spec-rest[spectacular] |
The package reads one setting of its own, SPEC_REST_LENIENT_JSON_BODY, which
drfs had no counterpart of: it is off unless the project sets it, and it asks
for a JSON body to be answered by the serializer alone (see lenient_json_body
under Bodies and arguments).
The codemod¶
The repository carries a codemod,
tools/drfs_codemod,
that does the mechanical part of this page in place. It is not in the wheel:
run it from a clone, with libcst installed, over the project's own modules.
It rewrites each drfs import by the module it names, following the
import map, so a kernel name spelled like a drfs one is never
taken for it. It turns each drfs ServiceSpec and SelectorSpec into the
kernel's, moving each field onto its seat as below: the
serializers and their contexts, permission_classes, an output selector's
serializer and context, document_service_error and field_audiences. A
spec's success_status and response_finalizer move to the views that serve
it, as Views and viewsets places them, for a spec bound to
a module-level name; one built inline in a view is marked. A view in the same
module takes them first. Otherwise all the files of a run are read together and
the answer follows the spec's import into views/ or viewsets/, bringing the
import a name in it needs, when that is certain: exactly one module serves the
spec, importing it by its own name, and both files are in the run. A module
that only passes the spec to the .register(...) of a SpecRegistry it builds
is not a second server and does not block the move. An absolute import is
found under --root, the directory such imports start from, usually the parent of
the top package; a wrong root finds no import, so every spec reads as having no
view in the run. A second run changes nothing.
An answer stays with its note, and says why, where moving it could change what a
request answers: a class of the run that is based on the view and binds its own
spec or action_specs would inherit it; a name the answer needs could mean
something else in the view, from a star import there, a name the class binds, or
another spec's answer in the same view that brings in a different object of that
name; a spec reaches a second view through a star import of a package that
re-exports it. A subclass the run cannot resolve (a base made by a call, imported
inside a function, or from outside the run) is not seen. Files are written all or
none while they are staged (a failure there changes nothing), and keep their
mode and line ends; the codemod's README says exactly what a failure while moving
them into place can leave.
Every site it leaves gets a comment beginning # drfs-codemod: above its
statement, giving the reason, and a line of its report, path:line: reason.
A site stays in the report until its comment is deleted. --check writes
nothing, reports the same moves and sites, and exits 1 while a file would still
change. A file that cannot be parsed stops the run with exit 2 before anything is
written, and a write that fails stops it with exit 2, naming the file.
What it does not do:
- Declare reads. drfs handed a selector the route captures and query
values its signature named; the kernel hands it only the
reads=its spec declares. The codemod reads the selector'sdefand writes thereads=Parameters.of(...)it would take into the marker, but applies none, since a key a provider or the server filled would become the caller's to send. - Translate a
UrlKwargorQueryParamlist. A drfsreads=is marked and kept as it was, for aParameterdeclaring its type to replace it. - Turn a
PolymorphicServiceSpecinto aDiscriminatedAction, split it into one kernel spec per route or action, nor replaceOfflineContract,TargetGuard,ArgumentBindingor an input marker (InputRequired,NotClientInput,InputDescription). A use of one is marked, and its import kept. - Guess at a view. A
success_statusorresponse_finalizerthat no view in the run serves is marked on the spec, with its value: set it on that view. So is one whose spec two modules import, or one module under an alias, by a star import or inside a function; and one whose value will not resolve in the view, such as a lambda or a call naming anything, or a name imported relatively. The marker's last clause says which. A view that already declares the answer, or binds the value's name to something else, is marked too, and the answer stays on the spec's note. The codemod's README has the full rule. - Follow a module import of drfs.
import rest_framework_services(or a submodule) is marked, and a spec built through it, such asdrfs.ServiceSpec(...), is left whole. - Change what a serializer accepts. It translates the serializer the project declared, so an empty PATCH answers as that serializer did: a partial serializer lets it through to the service and the model, not the kernel's 400 (a PATCH hands the service the keys sent).
- Format. What it writes keeps the layout of what it replaced, and a keyword it adds may make a line long; run the project's formatter after it.
The import map¶
The package root exports 32 names, none of
them the kernel's. A name drfs re-exported from the kernel is imported from
django_service_specs.
| drfs spelling | Becomes |
|---|---|
rest_framework_services.<path> for a view, viewset, mixin, decorator or type this package keeps |
django_spec_rest.<path> |
ServiceSpec, SelectorSpec, SelectorKind |
django_service_specs: the kernel's own, declared as below |
any other kernel name drfs re-exported (ServiceError and its subclasses, Affordance, UNSET, ChangeResult, the relation declarations, PoolSeeds, FieldMarking, FieldAudience, SpecRegistry, create_from_input, update_from_input, apply_input, ...) |
django_service_specs, from its root |
can_present_nothing, server_owned_keys, unguarded_specs, resolve_callable_kwargs, audience_projection_for_spec, enforce_affordances, unmet_operation_affordance and their a-prefixed async twins |
the kernel's functions of the same names, from django_service_specs |
dispatch_spec, adispatch_spec |
the kernel's dispatch, adispatch |
render_spec_output, render_for_audience and their async twins |
the kernel's present, present_for_audience |
enforce_permissions |
the kernel's authorize |
call_service, call_selector, apply_queryset_shaping and their async twins |
the kernel's run_service, run_selector, shape_queryset |
spec_to_json_schema, output_to_json_schema, serializer_to_json_schema |
the kernel's spec_input_schema and spec_output_schema |
filterset_to_json_schema |
the kernel's filter_set_parameters |
InvalidArguments, NotPermitted, PrincipalUnavailable from service_specs |
DRFInvalidArguments, DRFNotPermitted, DRFPrincipalUnavailable, each still the kernel's class and DRF's (DRF seats) |
SerializerValidator, SerializerPresenter, DRFPermissionCheck |
the same names, under django_spec_rest.service_specs.<module> |
capability_manifest, build_audience_projection, build_input_serializer, validate_input |
none: each read a drfs spec |
Declaring a spec¶
drfs' ServiceSpec, SelectorSpec, PolymorphicServiceSpec, UrlKwarg,
QueryParam, ArgumentBinding, OfflineContract, TargetGuard and the input
markers (InputRequired, NotClientInput, InputDescription) are gone;
PolymorphicServiceSpec returns as DiscriminatedAction,
the others have no same-named counterpart. A spec is the kernel's, and each
drfs field maps onto one of its seats:
| drfs field | Kernel spec |
|---|---|
input_serializer=S |
validator=SerializerValidator(S) |
input_serializer_context= |
SerializerValidator(S, serializer_context=...) |
output_serializer=S |
presenter=SerializerPresenter(S) |
output_serializer_context= |
SerializerPresenter(S, serializer_context=...) |
permission_classes=[...] |
permissions=(DRFPermissionCheck([...]),); a spec that admits everyone says so with the kernel's Unrestricted() |
reads=[UrlKwarg("pk")], reads=[QueryParam("status")] |
reads=Parameters.of(Parameter("pk", "integer", required=True)): the edge reads a declared read from the route first, then the query string |
a QueryParam no callable names |
shapes=Parameters.of(...) |
NotClientInput, a hidden lookup key |
a typed kwargs provider filling the key, which the kernel then keeps out of the caller's arguments (server_owned_keys) |
InputRequired, InputDescription("...") on a key the selector reads |
reads=Parameters.of(Parameter(..., required=True, help="...")), as worked below |
PolymorphicServiceSpec |
one kernel spec per route or action stays the default. For an endpoint that must keep one URL, an action_specs entry (or @service_action) of DiscriminatedAction(discriminator=, specs=), which is this package's alone: agents and MCP register each variant on its own. Its {View}_{action}_Request OpenAPI component comes back under that name, as an anyOf where drfs' was a oneOf: a body two variants accept is valid for both. A @service_action PATCH over it runs the chosen variant as declared, so it is validated whole and documented whole; a variant declaring partial is the way to a partial call. permission_strategy= is gone: the chosen variant's checks always decide. A callable success_status or response_finalizer on such an action may declare variant, the chosen key, to answer per variant |
TargetGuard |
the kernel's preconditions and object permission, which see the row |
OfflineContract.field_audiences |
SerializerPresenter(S, field_audiences={...}), layered over the serializer's own markings at the top level; a name the serializer does not output is refused when the output is read. HTTP names no audience, so a REST response is unchanged |
OfflineContract.url_kwargs |
the selector's reads=Parameters.of(Parameter("pk", "integer", required=True)). A capture the model must never see is a server-supplied argument instead: tool_server_arguments on django-spec-agents' toolset, server_arguments= on a django-spec-mcp tool binding |
OfflineContract.query_params |
shapes=Parameters.of(...) on the spec for a query that shapes what the presenter renders; a query the selector reads is a reads= Parameter, as above. A default that applies only off HTTP is read from the call's transport |
ArgumentBinding |
none: a call has one argument namespace, described below |
JsonSchemaRegistry keeps its fields rules alone. Its filters and
python_types rules are gone: a filter_set is described by the kernel's
filter_set_parameters, and each read is a Parameter declaring its own type,
so a project passing either to extend() or the constructor drops it.
The example project's views declare the seats they use.
One argument namespace, in place of ArgumentBinding¶
drfs' ArgumentBinding chose whether a callable's own parameters were the
caller's input. The kernel has no such choice: the arguments a caller may send
are the spec's reads= and shapes=, its Validator's parameters and, on a
selector, its filter_set's names, and the set is closed. A name that is none of them is refused under
UnknownArguments.REJECT and dropped under IGNORE, whatever the callable's
signature names.
| drfs | Kernel |
|---|---|
BUNDLE, and AUTO on a service |
what the kernel always does: a service's own parameters, with no Validator in front of them, are never the caller's, and it receives data={} and its declared reads |
SPREAD_AUTHOR_WINS, and AUTO on a selector |
declare each parameter the caller may send as a Parameter in the selector's reads=, or on the service's Validator. A value the spec's kwargs provider fills for a declared name still replaces the caller's, so an author-scoped tenant_id cannot be reset by the client |
SPREAD_CALLER_WINS |
retired in drfs too. To let the caller's value win, leave the key out of the kwargs provider |
A selector dispatched under drfs' AUTO took the caller's value for every
parameter its signature named, so those parameters become the reads= the
codemod writes into its markers.
A default for off-HTTP calls¶
A default that should apply only when a call does not come over HTTP, such as
a query an agent's tool call leaves out, reads which transport built the call's
context. A presenter's serializer_context callable declares call_context
and compares what the transport stamped under the "transport" key:
from django_service_specs import call_transport
# The transports that serve HTTP: this package's views, and django-spec-html's.
OVER_HTTP = ("rest", "html")
def off_http_defaults(*, call_context):
if call_transport(call_context) not in OVER_HTTP:
return {"default_query": "..."}
return {}
Naming the HTTP transports rather than the others is what makes the default hold for every one that is not:
call_transport(call_context) |
Served by | Over HTTP | The example's default |
|---|---|---|---|
"rest" |
this package's views and viewsets (REST_TRANSPORT) |
yes | not applied |
"html" |
django-spec-html |
yes | not applied |
"agent" |
django-spec-agents' toolset |
no | applied |
"mcp" |
django-spec-mcp |
no | applied |
"cli" |
django-spec-cli' management commands |
no | applied |
None |
a call no transport stamped, such as the kernel's own dispatch |
no | applied |
A transport added later counts as off HTTP, so one that serves HTTP is added to
the tuple. Comparing the other way (in ("agent", "mcp")) would leave out
"cli" and None, and every off-HTTP transport added later.
call_transport is in the kernel from 0.28.0, and a transport stamps its own
name from the release that adopts the key. A view of this package stamps
"rest" from 0.10.0 (REST_TRANSPORT).
A nested route's captures¶
A drfs selector on a nested route such as
projects/<project_pk>/tasks/<pk>/ took its captures from an
Unpack[TypedDict] signature, marked so a schema-driven caller was told it
must send them:
class TaskRoute(HttpExtras[User], total=False):
project_pk: Annotated[int, InputRequired, InputDescription("The project the task belongs to.")]
pk: Annotated[int, InputRequired, InputDescription("The task to read.")]
def get_task(**route: Unpack[TaskRoute]) -> Task:
return Task.objects.get(project_id=route["project_pk"], pk=route["pk"])
On the kernel each capture is a declared read, its marker a keyword of its
Parameter (required=True, and help= for the description), and the
selector takes them by name:
from django_service_specs import Parameter, Parameters, SelectorKind, SelectorSpec, Unrestricted
from django_spec_rest.service_specs.serializer_presenter import SerializerPresenter
def get_task(*, project_pk: int, pk: int) -> Task:
return Task.objects.get(project_id=project_pk, pk=pk)
task_detail = SelectorSpec(
kind=SelectorKind.RETRIEVE,
selector=get_task,
permissions=(Unrestricted(),),
reads=Parameters.of(
Parameter("project_pk", "integer", required=True, help="The project the task belongs to."),
Parameter("pk", "integer", required=True, help="The task to read."),
),
presenter=SerializerPresenter(TaskSerializer),
)
The edge reads each from the route. The same declaration is what an agent is
told: the kernel's spec_caller_input_schema, which describes a spec's tool to
an agent, gives both keys as required and carries each help as its
description, so the job the markers did for a schema-driven caller carries
over:
{
"type": "object",
"properties": {
"project_pk": {"type": "integer", "description": "The project the task belongs to."},
"pk": {"type": "integer", "description": "The task to read."}
},
"additionalProperties": false,
"required": ["project_pk", "pk"]
}
Views and viewsets¶
specandaction_specstake kernel specs.@service_action(spec)and@selector_action(spec)take them too. A kernel spec under anaction_specskey no action dispatches is refused atas_view(), since its permissions would guard nothing: serve it with a decorator.- A write's target is its spec's own selector, never
get_object(). An update or delete spec declaresinstance_selector_spec(one row) orcollection_selector_spec(a set) ormany=True, andas_view()refuses one that declares none. Aget_object()override no longer chooses a write's row. - A spec's rows are scoped by its selector alone. No action a spec serves
takes its rows from the view's
get_queryset(): notlist,retrieve, a@selector_action, nor a write's target lookup. A permission class that calls it, as DRF'sDjangoModelPermissionsdoes, reads its model and narrows nothing: onlistit reads the list spec's set, and on every other action the view's ownqueryset, so a view under that class setsqueryset, as DRF asks. The view'sfilter_backendsnarrow a spec'slist(a viewset's, andSelectorListView's) and nothing else, so notretrieve, a@selector_action's rows, or the row an update or delete reaches. A project that scoped rows to a tenant through either moves that scoping into each selector's own queryset (a selector may declareuser): left where it was, aPATCHorDELETEreaches another tenant's row with no error. DRF'sget_object()ran the backends, so a backend that scoped a tenant's rows there no longer does, and nothing reports it, since most backends are plain list filters. Aget_queryset()override beside any spec, a retrieve included, is reported atas_view()(django_spec_rest.W001). The reverse holds for an action with no spec: DRF's fallbackretrieveand a custom@actionreadingself.get_queryset()take their rows from the view'squeryset, never from the list selector, so scoping kept in the list selector alone does not reach them. - A route capture is read only where a spec declares it. drfs handed a
selector the captures its signature took (
InputRequired,Unpack[...UrlExtras]); the kernel hands a spec only its declared parameters. On a router-mounted detail route,as_view()refuses an action whose row lookup (theretrievespec, a detail@selector_action's, or a write'sinstance_selector_spec) declares no parameter named for the lookup capture (lookup_url_kwarg, elselookup_field): addreads=Parameters.of(Parameter("<capture>", ..., required=True))to that selector. A viewset mounted by hand and a standalone view are not checked, since their captures are the URLconf's: declare each capture the route carries yourself (The lookup capture). - A nested selector declares no
permissions. Oninstance_selector_spec,collection_selector_specandoutput_selector_specthe kernel never asks them, so the kernel refuses them when theServiceSpecis built: declare them on the spec being served. - A selector view's
get_selector_kwargsbeside a kernel spec is refused atas_view(), with the per-actionget_<action>_selector_kwargs: return those keys from the spec's ownkwargsprovider, which may declarerequestandview. - A viewset's
listwith a spec never takes its rows fromget_queryset(), nor does itsretrieve; the view's filter backends and paginator still apply to the set the spec read. So an eager-loading mixin that addsselect_relatedorprefetch_relatedin aget_queryset()override no longer loads the rows the list serves, and the list goes N+1 with no error. Declare the loading on the selector spec:SelectorSpec(select_related=..., prefetch_related=...), or anextend_querysetcallable, which is handed the selector's declared reads but never itsshapes. success_statusandresponse_finalizerare the view's or the action's, since a kernel spec declares no HTTP answer. A drfs spec's value moves to the route that served it: on a standalone view or a viewset whose actions all answer alike, the class attribute; on a@service_action, the decorator'ssuccess_status=andresponse_finalizer=; on anaction_specsentry, aget_<action>_success_statusorget_<action>_response_finalizerhook, a method declaring what the callable did, or anintfor a status (Per-action answers).
What a client sees¶
The query string¶
The query string is read by the kernel's flat reader:
- A blank filter or a blank declared read is absent.
?status=answers what no parameter answers, where drfs answered 400. - A multiple-choice filter splits on a comma and takes a blank:
?st=ann,boband?st=answer 200. - A typed array splits on a comma:
ids=1,2is[1, 2], andids=1,,3answers 400 at the empty element. - An array is read no further than one past its cap, and refused with the same 400.
- A key no parameter declares is dropped,
csrfmiddlewaretokenamong them. A selector parameter declared only in its signature receives nothing from the query string: declare it inreads.
Bodies and arguments¶
- A form body is read as DRF reads one (
"on", a blank checkbox, the prefixed spellingsv.tenantandtags[0]of a nested or list field), and a JSON body is checked against the spec's declared types as sent:"true"is no boolean in JSON. A kernelFormValidatoris no serializer, so a form body's boolean (aBooleanField, or a file field's clear checkbox) readstrue,false,1,0and, for a form body alone, a checkbox'son; a query string's?flag=onis refused. - A JSON integer field still takes a numeric string.
{"start_hour": "10"}is accepted as10, as drfs accepted it, while{"title": 5}is refused withExpected a string.: "as sent" is a check of the kind of value, and the kernel reads a numeric string as a number but not a number as a string. - A
lenient=Truesubclass ofSerializerValidatorhas a better home: the view. Handing every call site a validator withlenient=Truerelaxes the spec itself, so its tool schema reads{"title": {}, "n": {}}with norequired, on every transport. To have DRF answer a JSON body as drfs did, setlenient_json_body = Trueon the view, on a viewset, as anas_view()or@service_actionkeyword, or project-wide withSPEC_REST_LENIENT_JSON_BODY = True; the view's own value beats the setting, andFalseon a view keeps the strict check under the setting. It covers amanyspec's rows too.{"title": 5}for aCharFieldis then"5", as the serializer takes it, and every field's refusal is DRF's (a body that is no object is still refused by the package:{"detail": "The body must be an object; got list."}), while the spec's schema, its tool schema and every other transport stay typed. The kernel's checks of the declared types are what it turns off, its array cap (SERVICE_SPECS_MAX_ARRAY_LENGTH) on a field included: give aListFieldor a nestedmany=Trueserializer under the switch amax_lengthof its own. Amanyspec's list of rows keeps its cap. - A JSON field's refusal is worded by the kernel, not DRF.
Date has wrong format. Use one of these formats instead: YYYY-MM-DD.isEnter a valid date.,A valid integer is required.isExpected an integer., andThis field may not be null.isThis field cannot be null.. A client matching on a message must match the new one; the keys and the 400 are unchanged. A form body keeps DRF's wording, since the serializer answers it, and so does a JSON body underlenient_json_body. - A PATCH behind a
SerializerValidatorhands the service only the keys the client sent. The serializer is partial on a PATCH, as DRF's is, so{}, or a body naming one field of several, validates and reaches the service with only those keys. A service behind it writes only what was sent (serializer.save(), orif key in data). One written for a whole body reads an unsent key asNoneand writes it: a 500 where the column isNOT NULL, and a silent null under a 200 where it is not. Serve such a service on PUT alone, or behind a validator that is not partial: a dataclass declaration, aDataclassSerializeror the kernel'sDataclassValidatoris validated whole on a PATCH and answers 400 for a missing field. Plain DRF answers a PATCH of{}to aModelSerializerwith 200 and leaves the row as it was, so the 500 is the service's, not DRF's. - A JSON
nullfor a file field clears the file where the field declaresallow_null, and is refused where it does not. The service'sdataand parameter hold"", what the model field stores for no file, so a service clears the file however it writes:update_from_inputreads""as no file (andNoneas keep it), andsetattrthensave(),create(**data)andqs.update(**data)all store it. The bound serializer keeps DRF'sNone, soserializer.save()clears it too. A service reading the value itself must read""as "no file". A form body is unchanged: DRF's blank for the field keeps the file, and aFormValidator's clear checkbox clears it. A dataclass's field staysNone, as declared, so a service writing one keeps the file. APUTomitting a file field declaredFileField(allow_null=True, default=None)is the same as anull: DRF fills the field with itsNonedefault, so the file is cleared. APATCHfills no default, so it keeps the file. - A body that is no object answers
{"detail": "The body must be an object; got list."}, where drfs answered{"non_field_errors": ["Invalid data. Expected a dictionary, but got list."]}. Both are a 400. - A body key named
request,vieworserializerthat no field declares is dropped, as any undeclared key is; it never reaches the seed of that name. - A target selector's filter and its reads come from the route and the query
string, never the body. On an update or delete a body value cannot choose
the row or set the lookup's
filter_setscope. - A
kwargsprovider key naming a validator field is refused, pointing atinput_data=: a typed provider's when the view is built, an untyped one's on the request whose answer names one. A key named like a field'ssourcestays the provider's. - A view hook's value outranks the validated value: a key
get_service_kwargsreturns that the serializer also declares writable reaches the service as the hook's value, so a client cannot overwrite a hook settingowner=request.user. - A provider value named like a filter reaches the filter, over the query string's: the kernel has one argument namespace.
- The route's format suffix wins for
formatover aget_serializer_context()override returning its own.
Answers¶
- A bare
DispatchErroranswers 400 with its sentence underdetail, where drfs answered 500. - An output serializer refusing to render answers 400 as a mapping. A
ValidationErrorraised fromto_representationwith a list (a message, as DRF makes of a string) is keyed undernon_field_errors:{"non_field_errors": ["..."]}, where drfs answered the bare list. A mapping is kept as it is. A page of rows renders as onemany=Trueserializer, so a list refusal there answers the same. - A
manyspec with anoutput_selector_specpresents what the re-read returns, where drfs presented the service's rows. - Bulk and row refusals are worded by the kernel.
- A read with no spec and nothing to fall back to answers 405, not DRF's
assertion as a 500. A
listorretrievea router maps on a viewset with no rows or no serializer leavesAllowand the schema, so a create-onlyServiceViewSetdocumentsPOSTalone and its detail routeAllowsOPTIONSalone. Alist,retrieveorget_object()of the project's own keeps its route, and a read that can fall back answers as it did. - OPTIONS runs no stage of a spec (no selector, provider, service or
affordance condition) and does not answer 500 for want of what DRF's metadata
reads. The exception is a Validator whose
parameters()raises something other thanAttributeError,KeyErrororTypeError, which is a 500, as the OpenAPI page says.Allownames only the methods that can answer: a handler of this package's with no spec is left out, on its 405 too, and a handler of the project's stays, undescribed where DRF's metadata cannot describe it for want of a serializer or rows.actions[METHOD]is a JSON Schema object rather than DRF's per-field dictionary, and leaves out the keys the server fills, the route's captures and the target lookup's parameters; a key a provider may fill is described and not required. A mixed viewset's methods that no kernel spec serves are no longer described. - The browsable API's PUT and PATCH forms are drawn empty, not prefilled from the row: a spec's input need not have the row's fields.
- OpenAPI describes a
filter_set's parameters by the kernel'sfilter_set_parameters: its description wording, aNumberFilteras a decimal string, anOrderingFilterwith noexplodeorstyle, in declared order, withmaxItems1000, aMultipleChoiceFilterwithmaxItems, and an array as a repeated key. A validator's refusal is documented as the 400 it answers. A selector's reads are not documented as query parameters, nor the affordance envelope, nor thenullbody of anallow_noneretrieve. - The 422 is documented only where a spec declares it. drfs documented the
422 of a plain
ServiceErroron every spec with aninput_serializer, unlessdocument_service_error=False; spec-rest documents it on no spec, a service's or a selector's, unless the spec'smetadatasays{"document_service_error": True}(SERVICE_ERROR_METADATA_KEYindjango_spec_rest.openapi). So a drfsdocument_service_error=Truemoves intometadata,FalseandNoneare dropped, and a schema generated from the same views loses its 422s until the specs that raise one declare it. A schema subclass that popped the 422 through drfs' privateresolve_service_spechas nothing left to pop; one that wants the 422 everywhere overridesServiceAutoSchema.documents_service_errorto returnTrue(Declaring the 422).
Serializer context¶
- One context dict per request. The input serializer, the output serializer
and the service's
call_contextare handed the same dict, so a key one serializer'svalidate()writes is there for the service and the output serializer, as in a stockModelViewSet. drfs handed each serializer a copy. - A presenter's
serializer_contextcallable receives what drfs' provider did: a list's rows underpage, once for the page, and one value under the presenter'svalue_keyword(resultby default; setvalue_keyword="instance"for a retrieve provider declaringinstance). Amanyservice's provider declaringresultreceives its whole return.pageis always a list of the rows, never a queryset: as drfs did on a paginated view, and unlike drfs on an unpaginated one, where it handed the selector's queryset. A provider calling a queryset method on it (page.values_list(...),page.filter(...)) must change to work over the list, for example[row.pk for row in page](Serializer context). - A kernel selector gets no
get_<action>_output_serializer_contexthook: declare the context on itsSerializerPresenter.
Permissions¶
DRFPermissionChecktakesroute_reads=for the arguments that go onto the offline view'skwargs; itsurl_kwargs=andquery_params=are gone, and the offline request's query string is empty.- An adapted DRF class still asks a caller nobody authenticated to sign in.
A DRF class in
DRFPermissionCheckdenying a caller the request's authenticators did not recognise is answered DRF'sNotAuthenticated, as drfs' view answered it: 401 with the first authenticator'sWWW-Authenticate(Basic realm="api"withBasicAuthenticationfirst), or 403 "Authentication credentials were not provided." where that authenticator sends no challenge, asSessionAuthenticationdoes not. A signed-in caller, and any caller of a view with no authenticator, is refused 403 with the class's ownmessage. Off HTTP there is no authenticator, so the refusal is the kernel'sNotPermitted. - A kernel
PermissionCheckasks a caller nobody authenticated to sign in too: 401 with the challenge, or 403 where the first authenticator sends none, and 403 with its message to anyone else. A service's ownNotPermitted, and an inner dispatch its check refused, stay 403 with their message for every caller. - A refusal comes before the body is read, as drfs' view asked its
permission classes in
initial(). The spec's checks are asked before its arguments' shape is checked, so a caller they refuse gets the 401 or 403 above whatever it sends: an empty body, a missing field, a value of the wrong type, a query string the read cannot parse, a body DRF cannot parse, a media type it does not accept or more form fields than Django reads answer the same as a well-formed request, never the 400 that lists the fields. A request whose body cannot be read, or whose body or query string fails its shape, is judged with no arguments, the question the OPTIONS answer asks before it describes a method, so OPTIONS shows a method's schema exactly to the callers whose incomplete request is told what it lacks. A check that reads the call's arguments and cannot answer without them keeps the kernel's order: the caller is told the 400 first, and the check is asked once the shape passes. A check that answersFalsewith no arguments refuses every caller sending an incomplete body. DRF's owninitial()runs before any of this, so aget_permissions()orget_throttles()override that reads the body answers what that read raises. DRFPermissionCheckcan raiseDRFNotAuthenticated, not onlyDRFNotPermitted, when the call carries a real DRF request with authenticators and nobody was recognised.DRFNotAuthenticatedis DRF'sNotAuthenticated, not aPermissionDenied, so code catchingPermissionDeniedby name around such a dispatch no longer catches that refusal; catch the kernel'sNotPermitted, which both classes are.UNAUTHENTICATED_USER = Noneis passed asNone: the principal an anonymous request hands the kernel isNonewhere the project sets it so, rather than anAnonymousUser.
Answers kept as they are¶
- The target lookup runs before the shape check refuses a body key, so a lookup's selector and provider are called for a body the check then refuses.
- A provider's
"abc"orNonereaches anx: intselector parameter as returned: a provider is server code, and its values are not coerced. - A serializer that cannot be described when the view is built (its
get_fieldsreadscontext["request"], or a key only the view adds) skips the kernel's seed check atas_view(); its first request is checked instead.
Known limits¶
- Code reading a drfs spec's attributes gets
Nonefrom a kernel spec, with no error, where it reads them throughgetattr(spec, "<name>", None). The kernel's spellings:output_serializerisspec.presenter, and itsserializer_classwhere it is aSerializerPresenter(a dataclass declaration's generatedDataclassSerializer, not the dataclass);input_serializerisspec.validator, and itsserializerwhere it is aSerializerValidator(aSelectorSpechas novalidator);permission_classesisspec.permissions, a tuple of kernel checks, whose DRF classes are aDRFPermissionCheck'spermission_classes. - A kernel spec under
@selector_actiondoes not read the view'sget_selector_kwargs, and nothing refuses the override there. - A
@service_actionor@selector_actionspec is seed-checked by its viewset'sas_view(), so only on a viewset built on this package's bases. On DRF's ownGenericViewSetorViewSetnothing runs the kernel's seed check, and a keyword nothing fills is the action's first request'sTypeError. ActionSerializerResolverfalls back to the view'sserializer_classfor an action whose spec has no serializer.- A service's
ServiceValidationErrorkeyed by a model field is answered under that name, not renamed to the serializer field whosesourceit is. - A kernel
dispatcha service makes of the view's own spec for another principal validates under that principal's own context, but its declared parameter set is still the one described under the view's request, until the kernel passes the asking call toparameters(). - The input serializer is first built with no body and no row, to describe its
fields, so one reading
self.initial_dataorself.instance.<attr>in__init__orget_fieldsraisesAttributeErroron every request, a 500, whileas_view()accepts it. Guard the read, asgetattr(self, "initial_data", {})orif self.instance is not None:: the serializer that validates is bound to the body and the row, so a field the guarded read adds there is required and validated. - A
manyspec's row field named like a dispatcher seed, such asuserorresult, is dropped from each row: refused as missing where it is required, and validated without the value the client sent where it is optional. Name the field otherwise and give itsource="user"where the row must still carry that key. - The input serializer is built several times per call, to describe its
fields as well as to validate, so the queries its
__init__orget_fieldsmakes run several times. Where their count matters, move them to the service, or tovalidate()or avalidate_<field>(), which only the validating build runs. - A row condition among a create spec's
affordancesis refused only when the view dispatches, withImproperlyConfiguredon the first request, not atas_view(). Declare row conditions only on a spec with aninstance_selector_spec.
Authenticating MCP calls through DRF¶
django-spec-mcp serves the same
kernel specs to agents. Neither package ships a bridge between DRF's
authentication and the MCP transport; a project that wants its DRF
authentication classes to admit MCP calls writes a backend of its own. The
transport calls authenticate(request) with the Django request and expects a
TokenInfo, or None for a 401:
from django.http import HttpRequest
from django_spec_mcp import TokenInfo
from django_spec_mcp.auth import bearer_challenge
from django_spec_mcp.auth.types.protected_resource_metadata import ProtectedResourceMetadata
from rest_framework.authentication import TokenAuthentication
from rest_framework.exceptions import APIException
from rest_framework.request import Request
class DRFAuthBackend:
"""Admits an MCP call that the project's DRF authentication classes admit."""
authentication_classes = (TokenAuthentication,)
resource_url = "https://example.com/mcp/"
def authenticate(self, request: HttpRequest) -> TokenInfo | None:
drf_request = Request(
request, authenticators=[cls() for cls in self.authentication_classes]
)
try:
user = drf_request.user
except APIException:
# An invalid credential: DRF raises AuthenticationFailed.
return None
if drf_request.successful_authenticator is None:
return None
return TokenInfo(user=user, raw=drf_request.auth)
def protected_resource_metadata(self) -> ProtectedResourceMetadata:
return ProtectedResourceMetadata(resource=self.resource_url, authorization_servers=[])
def www_authenticate_challenge(self, *, scopes=None, error=None) -> str:
return bearer_challenge(
resource_metadata_url=f"{self.resource_url}.well-known/oauth-protected-resource",
scopes=scopes,
error=error,
)
Prefer a header-borne credential class (TokenAuthentication, a JWT class):
SessionAuthentication enforces CSRF on the transport's POST. Scopes are the
transport's to check, with its own scopes=.