Skip to content

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's def and writes the reads=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 UrlKwarg or QueryParam list. A drfs reads= is marked and kept as it was, for a Parameter declaring its type to replace it.
  • Turn a PolymorphicServiceSpec into a DiscriminatedAction, split it into one kernel spec per route or action, nor replace OfflineContract, TargetGuard, ArgumentBinding or an input marker (InputRequired, NotClientInput, InputDescription). A use of one is marked, and its import kept.
  • Guess at a view. A success_status or response_finalizer that 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 as drfs.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

  • spec and action_specs take kernel specs. @service_action(spec) and @selector_action(spec) take them too. A kernel spec under an action_specs key no action dispatches is refused at as_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 declares instance_selector_spec (one row) or collection_selector_spec (a set) or many=True, and as_view() refuses one that declares none. A get_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(): not list, retrieve, a @selector_action, nor a write's target lookup. A permission class that calls it, as DRF's DjangoModelPermissions does, reads its model and narrows nothing: on list it reads the list spec's set, and on every other action the view's own queryset, so a view under that class sets queryset, as DRF asks. The view's filter_backends narrow a spec's list (a viewset's, and SelectorListView's) and nothing else, so not retrieve, 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 declare user): left where it was, a PATCH or DELETE reaches another tenant's row with no error. DRF's get_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. A get_queryset() override beside any spec, a retrieve included, is reported at as_view() (django_spec_rest.W001). The reverse holds for an action with no spec: DRF's fallback retrieve and a custom @action reading self.get_queryset() take their rows from the view's queryset, 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 (the retrieve spec, a detail @selector_action's, or a write's instance_selector_spec) declares no parameter named for the lookup capture (lookup_url_kwarg, else lookup_field): add reads=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. On instance_selector_spec, collection_selector_spec and output_selector_spec the kernel never asks them, so the kernel refuses them when the ServiceSpec is built: declare them on the spec being served.
  • A selector view's get_selector_kwargs beside a kernel spec is refused at as_view(), with the per-action get_<action>_selector_kwargs: return those keys from the spec's own kwargs provider, which may declare request and view.
  • A viewset's list with a spec never takes its rows from get_queryset(), nor does its retrieve; the view's filter backends and paginator still apply to the set the spec read. So an eager-loading mixin that adds select_related or prefetch_related in a get_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 an extend_queryset callable, which is handed the selector's declared reads but never its shapes.
  • success_status and response_finalizer are 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's success_status= and response_finalizer=; on an action_specs entry, a get_<action>_success_status or get_<action>_response_finalizer hook, a method declaring what the callable did, or an int for 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,bob and ?st= answer 200.
  • A typed array splits on a comma: ids=1,2 is [1, 2], and ids=1,,3 answers 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, csrfmiddlewaretoken among them. A selector parameter declared only in its signature receives nothing from the query string: declare it in reads.

Bodies and arguments

  • A form body is read as DRF reads one ("on", a blank checkbox, the prefixed spellings v.tenant and tags[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 kernel FormValidator is no serializer, so a form body's boolean (a BooleanField, or a file field's clear checkbox) reads true, false, 1, 0 and, for a form body alone, a checkbox's on; a query string's ?flag=on is refused.
  • A JSON integer field still takes a numeric string. {"start_hour": "10"} is accepted as 10, as drfs accepted it, while {"title": 5} is refused with Expected 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=True subclass of SerializerValidator has a better home: the view. Handing every call site a validator with lenient=True relaxes the spec itself, so its tool schema reads {"title": {}, "n": {}} with no required, on every transport. To have DRF answer a JSON body as drfs did, set lenient_json_body = True on the view, on a viewset, as an as_view() or @service_action keyword, or project-wide with SPEC_REST_LENIENT_JSON_BODY = True; the view's own value beats the setting, and False on a view keeps the strict check under the setting. It covers a many spec's rows too. {"title": 5} for a CharField is 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 a ListField or a nested many=True serializer under the switch a max_length of its own. A many spec'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. is Enter a valid date., A valid integer is required. is Expected an integer., and This field may not be null. is This 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 under lenient_json_body.
  • A PATCH behind a SerializerValidator hands 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(), or if key in data). One written for a whole body reads an unsent key as None and writes it: a 500 where the column is NOT 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, a DataclassSerializer or the kernel's DataclassValidator is validated whole on a PATCH and answers 400 for a missing field. Plain DRF answers a PATCH of {} to a ModelSerializer with 200 and leaves the row as it was, so the 500 is the service's, not DRF's.
  • A JSON null for a file field clears the file where the field declares allow_null, and is refused where it does not. The service's data and parameter hold "", what the model field stores for no file, so a service clears the file however it writes: update_from_input reads "" as no file (and None as keep it), and setattr then save(), create(**data) and qs.update(**data) all store it. The bound serializer keeps DRF's None, so serializer.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 a FormValidator's clear checkbox clears it. A dataclass's field stays None, as declared, so a service writing one keeps the file. A PUT omitting a file field declared FileField(allow_null=True, default=None) is the same as a null: DRF fills the field with its None default, so the file is cleared. A PATCH fills 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, view or serializer that 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_set scope.
  • A kwargs provider key naming a validator field is refused, pointing at input_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's source stays the provider's.
  • A view hook's value outranks the validated value: a key get_service_kwargs returns that the serializer also declares writable reaches the service as the hook's value, so a client cannot overwrite a hook setting owner=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 format over a get_serializer_context() override returning its own.

Answers

  • A bare DispatchError answers 400 with its sentence under detail, where drfs answered 500.
  • An output serializer refusing to render answers 400 as a mapping. A ValidationError raised from to_representation with a list (a message, as DRF makes of a string) is keyed under non_field_errors: {"non_field_errors": ["..."]}, where drfs answered the bare list. A mapping is kept as it is. A page of rows renders as one many=True serializer, so a list refusal there answers the same.
  • A many spec with an output_selector_spec presents 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 list or retrieve a router maps on a viewset with no rows or no serializer leaves Allow and the schema, so a create-only ServiceViewSet documents POST alone and its detail route Allows OPTIONS alone. A list, retrieve or get_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 than AttributeError, KeyError or TypeError, which is a 500, as the OpenAPI page says. Allow names 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's filter_set_parameters: its description wording, a NumberFilter as a decimal string, an OrderingFilter with no explode or style, in declared order, with maxItems 1000, a MultipleChoiceFilter with maxItems, 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 the null body of an allow_none retrieve.
  • The 422 is documented only where a spec declares it. drfs documented the 422 of a plain ServiceError on every spec with an input_serializer, unless document_service_error=False; spec-rest documents it on no spec, a service's or a selector's, unless the spec's metadata says {"document_service_error": True} (SERVICE_ERROR_METADATA_KEY in django_spec_rest.openapi). So a drfs document_service_error=True moves into metadata, False and None are 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' private resolve_service_spec has nothing left to pop; one that wants the 422 everywhere overrides ServiceAutoSchema.documents_service_error to return True (Declaring the 422).

Serializer context

  • One context dict per request. The input serializer, the output serializer and the service's call_context are handed the same dict, so a key one serializer's validate() writes is there for the service and the output serializer, as in a stock ModelViewSet. drfs handed each serializer a copy.
  • A presenter's serializer_context callable receives what drfs' provider did: a list's rows under page, once for the page, and one value under the presenter's value_keyword (result by default; set value_keyword="instance" for a retrieve provider declaring instance). A many service's provider declaring result receives its whole return. page is 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_context hook: declare the context on its SerializerPresenter.

Permissions

  • DRFPermissionCheck takes route_reads= for the arguments that go onto the offline view's kwargs; its url_kwargs= and query_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 DRFPermissionCheck denying a caller the request's authenticators did not recognise is answered DRF's NotAuthenticated, as drfs' view answered it: 401 with the first authenticator's WWW-Authenticate (Basic realm="api" with BasicAuthentication first), or 403 "Authentication credentials were not provided." where that authenticator sends no challenge, as SessionAuthentication does not. A signed-in caller, and any caller of a view with no authenticator, is refused 403 with the class's own message. Off HTTP there is no authenticator, so the refusal is the kernel's NotPermitted.
  • A kernel PermissionCheck asks 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 own NotPermitted, 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 answers False with no arguments refuses every caller sending an incomplete body. DRF's own initial() runs before any of this, so a get_permissions() or get_throttles() override that reads the body answers what that read raises.
  • DRFPermissionCheck can raise DRFNotAuthenticated, not only DRFNotPermitted, when the call carries a real DRF request with authenticators and nobody was recognised. DRFNotAuthenticated is DRF's NotAuthenticated, not a PermissionDenied, so code catching PermissionDenied by name around such a dispatch no longer catches that refusal; catch the kernel's NotPermitted, which both classes are.
  • UNAUTHENTICATED_USER = None is passed as None: the principal an anonymous request hands the kernel is None where the project sets it so, rather than an AnonymousUser.

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" or None reaches an x: int selector 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_fields reads context["request"], or a key only the view adds) skips the kernel's seed check at as_view(); its first request is checked instead.

Known limits

  • Code reading a drfs spec's attributes gets None from a kernel spec, with no error, where it reads them through getattr(spec, "<name>", None). The kernel's spellings: output_serializer is spec.presenter, and its serializer_class where it is a SerializerPresenter (a dataclass declaration's generated DataclassSerializer, not the dataclass); input_serializer is spec.validator, and its serializer where it is a SerializerValidator (a SelectorSpec has no validator); permission_classes is spec.permissions, a tuple of kernel checks, whose DRF classes are a DRFPermissionCheck's permission_classes.
  • A kernel spec under @selector_action does not read the view's get_selector_kwargs, and nothing refuses the override there.
  • A @service_action or @selector_action spec is seed-checked by its viewset's as_view(), so only on a viewset built on this package's bases. On DRF's own GenericViewSet or ViewSet nothing runs the kernel's seed check, and a keyword nothing fills is the action's first request's TypeError.
  • ActionSerializerResolver falls back to the view's serializer_class for an action whose spec has no serializer.
  • A service's ServiceValidationError keyed by a model field is answered under that name, not renamed to the serializer field whose source it is.
  • A kernel dispatch a 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 to parameters().
  • The input serializer is first built with no body and no row, to describe its fields, so one reading self.initial_data or self.instance.<attr> in __init__ or get_fields raises AttributeError on every request, a 500, while as_view() accepts it. Guard the read, as getattr(self, "initial_data", {}) or if 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 many spec's row field named like a dispatcher seed, such as user or result, 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 it source="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__ or get_fields makes run several times. Where their count matters, move them to the service, or to validate() or a validate_<field>(), which only the validating build runs.
  • A row condition among a create spec's affordances is refused only when the view dispatches, with ImproperlyConfigured on the first request, not at as_view(). Declare row conditions only on a spec with an instance_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=.