Viewsets¶
Every viewset here takes pool_seeds, the kernel PoolSeeds its specs'
callables are resolved with, as a class attribute or as
as_view(actions, pool_seeds=...): the kernel's DEFAULT_POOL_SEEDS unless
set. as_view() checks each action_specs entry against it, one handed as
as_view(actions, action_specs=...) included, and every action,
a @service_action or @selector_action one included, dispatches with it. A
DRF viewset of your own serving a decorated action reads the same attribute
where it declares one. See
Registering a seed.
The lookup capture¶
A router mounts retrieve, update, partial_update, destroy and every
detail=True action on the detail route, capturing the lookup under
lookup_url_kwarg, else lookup_field (pk by default), and hands
as_view() detail=True. A route capture reaches a spec only as a parameter it
declares, so on a router-mounted detail route as_view() refuses an action
whose row lookup does not declare the capture: the retrieve spec or a detail
@selector_action's spec, or a write's instance_selector_spec, whose own
reads are all the kernel hands it. The refusal names the viewset, the action
and the capture. Declare the read under the capture's name, or point
lookup_url_kwarg at the name the selector reads:
by_id = SelectorSpec(
kind=SelectorKind.RETRIEVE,
selector=lambda *, pk: Invoice.objects.get(pk=pk),
reads=Parameters.of(Parameter("pk", "integer", required=True)),
)
A list action, a detail=False action, and a write over a set
(collection_selector_spec) or a list body (many=True) look up no row and are
not asked. A viewset mounted by hand is not asked, nor is a standalone view:
their captures come from the URLconf, which as_view() cannot see. A singleton
such as path("me/", MeViewSet.as_view({"get": "retrieve"})), whose selector
finds the caller's own row, carries no capture and needs no read; a hand-mounted
route that does carry one must have it declared by the selector itself.
The schema documents a declared capture with the read's type and help where
drf-spectacular finds no model to type it from, as on a viewset with no
queryset (see OpenAPI).
The row-scoping warning¶
A spec's rows are its selector's, so a get_queryset() override does not scope
them, and tenant scoping left in one lets a PATCH or DELETE reach another
tenant's row, and a GET read it, with no error. as_view() reports a viewset
that overrides it beside any spec (a write, a list or a retrieve) with a
GetQuerysetScopeWarning whose text begins with the stable id
django_spec_rest.W001 and names the class:
django_spec_rest.W001: myapp.views.InvoiceViewSet overrides get_queryset() (on InvoiceViewSet) and serves a spec, ...
An override counts on any class below this package's bases and DRF's, a mixin
listed after them included, since its super() runs. A spec counts under
action_specs, an as_view(action_specs=...) included, and under
@service_action or @selector_action, whatever its kind: a viewset serving
only a retrieve spec is reported as one serving a write or a list is. A
viewset with no override, or serving only actions with no spec, is not
reported (those take their rows from get_queryset()), and neither is a
backend in filter_backends, since most are plain list filters
(Moving from djangorestframework-services
says what changes for one that scoped a tenant).
The warning's file:line is the line that called as_view(). A viewset
mounted by hand is reported at your urls.py line. One mounted through a
router is reported at DRF's rest_framework/routers.py, which is where the
router calls as_view(), so read the class the text names rather than the
location.
An override can be right to stay: DRF's DjangoModelPermissions asks
get_queryset() for the model it checks. Once each selector scopes its own
rows, say so on the class:
class InvoiceViewSet(ServiceViewSet):
action_specs = {"list": INVOICES, "destroy": DELETE_INVOICE}
spec_rows_scoped_by_selector = True
def get_queryset(self) -> QuerySet[Invoice]:
return Invoice.objects.all()
The attribute is inherited, so a base class says it once for its own override.
It covers only overrides on the class declaring it and that class's bases: one
a subclass adds, or a mixin listed before it, is reported again, because nobody
has yet said it scopes nothing. A subclass may set it to False.
It is a Python warning at as_view(), not a system check: this package has no
app config for a check to register on, and asks for no INSTALLED_APPS entry,
so SILENCED_SYSTEM_CHECKS does not apply. It prints when the URLconf is
imported, manage.py check included. Under Python's default filter a router
shows it once for a class, however many routes it mounts, and a class mounted
by hand shows it once per line mounting it. No request makes one. A project or a test suite may filter on the id, to fail on it say:
A viewset of DRF's alone, serving @service_action or @selector_action with
no base of this package's, has no such as_view() and is not reported, and
neither is a standalone view.
GetQuerysetScopeWarning ¶
Bases: UserWarning
A viewset overrides get_queryset(), which scopes none of the rows its specs serve.
A UserWarning, because Python hides a DeprecationWarning outside
__main__ and the failure this reports is silent otherwise. Its text
begins with the stable id django_spec_rest.W001.
Per-action answers¶
A kernel spec declares no HTTP answer, so a viewset's success_status and
response_finalizer answer for every action it serves. An action that answers
differently declares its own, and the most specific one answers:
@service_action(spec, success_status=..., response_finalizer=...), for a decorated action;- a
get_<action>_success_statusorget_<action>_response_finalizerhook on the viewset, for any action, anaction_specsentry included; - the viewset's own, an
as_view(success_status=...)keyword over the class attribute; - the action's default: 201 create, 200 update, 204 destroy, 200 a custom action.
class MembershipViewSet(ServiceViewSet):
action_specs = {"create": JOIN_SPEC, "destroy": LEAVE_SPEC}
def get_create_success_status(self, *, result: Membership) -> int:
return 201 if result.is_new else 200
get_destroy_success_status = 200
@service_action(CANCEL_SPEC, detail=True, methods=["post"], success_status=204)
def cancel(self, request: Any, pk: Any = None) -> None:
"""Cancel a pending membership."""
Each takes the shapes the viewset's does. A status is an int or a callable
declaring any of result, instance, request and view; a finalizer is a
callable declaring any of response, result, request, view, instance
and data. A hook method is that callable, its self the view (a
staticmethod takes none), and an int attribute is the status. A hook is named for the action DRF names, as
get_<action>_service_kwargs is: a PATCH reads get_partial_update_...,
never get_update_....
as_view() asks each per-action value what it asks the viewset's, naming the
action: a callable requiring a name the view cannot supply is refused, and so
is a decorator value beside a hook for the same action. That refusal is
this package's viewsets' as_view(): on a viewset of DRF's alone the
decorator's value answers and the hook is never read. The decorator's own
values are asked when it runs. The OpenAPI schema documents the status of the
action, where it is an int; a callable documents the action's default.
One URL, several specs¶
One spec per route or action stays the default. An endpoint that must keep one
URL while the request chooses what runs takes a
DiscriminatedAction: a discriminator
answering a key, and specs mapping each key to the kernel ServiceSpec serving
it. It is an action_specs entry under create, update, partial_update or
destroy, or the spec of a @service_action; as_view() refuses it under any
other action (list, retrieve or a custom one's name), on a SelectorViewSet, and as a
standalone view's spec, each refusal saying where it goes.
INTAKE = DiscriminatedAction(
discriminator=lambda *, data: "draft" if "amount_cents" in data else "blank",
specs={"draft": CREATE_SPEC, "blank": BLANK_SPEC},
)
class IntakeViewSet(ServiceCreateMixin, GenericViewSet):
queryset = Invoice.objects.all()
action_specs = {"create": INTAKE}
(examples/invoices serves this as POST /intake/ and asserts it over HTTP and
in the schema.) The discriminator is a sync callable declaring any of request,
data (the raw request.data), user, view and call_context, or **kwargs,
which is handed all but the last. An answer that is no key of specs is the
author's mistake: a 500 naming the keys.
Each variant is asked what a plain entry of that action is asked (its seeds, its lookup capture, its answers), and the chosen one runs as a plain entry would, its own checks deciding. So:
- A caller every variant refuses is refused before the body is read and before the discriminator runs, the first variant's denial answered: a 401 with the authenticator's challenge where nobody signed in, otherwise a 403. A caller some variant admits reaches the discriminator, and the chosen variant's checks then decide.
- The row is the chosen variant's own target selector's, as for any update or destroy.
- Validation is the variant's. A
PATCHon the router's update route (anupdateorpartial_updateentry) is validated partially, as for a plain spec. A@service_actionPATCHruns the chosen variant as declared, so it is validated whole; a variant declaringpartialis the way to a partial call there. - A callable
success_statusorresponse_finalizeron such an action may declarevariant, the chosen key, to answer per variant (Per-action answers).
It is this transport's alone and never a kernel spec: as_kernel_spec() raises,
and an agent or an MCP client registers each variant on its own. The OpenAPI
schema documents the action as one request component over every variant's body
(see OpenAPI).
Full CRUD¶
ServiceViewSet ¶
Bases: ServiceCreateMixin, ServiceUpdateMixin, ServiceDestroyMixin, SelectorListMixin, SelectorRetrieveMixin, ActionSerializerResolver, GenericViewSet
Router-compatible viewset wiring services and selectors.
Composes
ServiceCreateMixin,
ServiceUpdateMixin,
ServiceDestroyMixin,
SelectorListMixin,
SelectorRetrieveMixin,
and
ActionSerializerResolver
over GenericViewSet. See those classes for the configurable attributes.
SelectorViewSet ¶
Bases: SelectorListMixin, SelectorRetrieveMixin, ActionSerializerResolver, GenericViewSet
Read-only viewset for list + retrieve.
Composes
SelectorListMixin,
SelectorRetrieveMixin,
and
ActionSerializerResolver
over GenericViewSet.
Per-action mixins¶
ServiceCreateMixin ¶
Bases: MutationFlowMixin, _ActionSpecsMixin
Provides the create action; reads its config from action_specs.
Set action_specs["create"] to a
ServiceSpec. When the
"create" key is absent the action raises MethodNotAllowed. A
non-ServiceSpec entry (e.g. a
SelectorSpec) raises
ImproperlyConfigured.
ServiceUpdateMixin ¶
Bases: MutationFlowMixin, _ActionSpecsMixin
Provides update (PUT) and partial_update (PATCH) actions.
The row is the spec's own target selector's, never get_object()'s.
PUT reads its config from
action_specs["update"]; PATCH reads action_specs["partial_update"]
first and falls back to "update" — defining only
"partial_update" yields a PATCH-only endpoint (PUT raises
MethodNotAllowed). When neither key
resolves, both actions raise
MethodNotAllowed. A non-ServiceSpec
entry raises ImproperlyConfigured.
ServiceDestroyMixin ¶
Bases: MutationFlowMixin, _ActionSpecsMixin
Provides the destroy action.
The row is the spec's own target selector's, never get_object()'s. Reads its config from
action_specs["destroy"]; when that key is absent the action raises
MethodNotAllowed. A non-ServiceSpec
entry raises ImproperlyConfigured.
SelectorListMixin ¶
Bases: ListModelMixin, _ActionSpecsMixin
Compose with GenericViewSet.
An absent "list" key falls through to DRF's default get_queryset()
where the viewset has rows and a serializer for it, and otherwise answers 405,
leaving Allow and the schema (test_a_read_with_no_fallback). Any entry that is not a kernel
SelectorSpec
raises ImproperlyConfigured.
A kernel SelectorSpec under "list" runs through the kernel's own
dispatch, as on SelectorListView: the view's filter backends and
paginator apply to the set it read, and the spec's presenter renders
the rows served, with the affordance answers it declares; a spec declaring
neither is rendered by the view's serializer.
get_queryset() answers the list spec's set on list alone, from the
one dispatch list presents. On every other action it is DRF's own, the
viewset's queryset: DRF's DjangoModelPermissions asks it on every
action for the model it checks, and a retrieve or a PATCH beside a
list spec answers as its own spec says, never the list's refusal
(test_selector_list_mixin).
SelectorRetrieveMixin ¶
Bases: RetrieveModelMixin, _ActionSpecsMixin
Compose with GenericViewSet.
An absent "retrieve" key falls through to DRF's default get_object()
where the viewset has rows and a serializer for it, and otherwise answers 405,
leaving Allow and the schema (test_a_read_with_no_fallback). Any entry that is not a kernel
SelectorSpec
raises ImproperlyConfigured.
A kernel SelectorSpec under "retrieve" runs through the kernel's
own dispatch, as on SelectorRetrieveView: the view's object
permission is asked of the row inside it, and the spec's presenter
renders the row, or the view's serializer where the spec declares neither
presenter nor affordances. A miss is a 404, or JSON null under
allow_none. A kernel write spec never asks get_object(): its target
is its own selector's.
get_object ¶
Resolve the target row through the retrieve selector, if one is wired.
filter_backends do not apply here. DRF runs
filter_queryset() inside its own get_object(), so taking that
method over — as this mixin does, and as any hand-written
get_object() override does — is what drops them. A tenant-scoping
backend listed in DEFAULT_FILTER_BACKENDS therefore narrows the
sibling list action and not this lookup, which is what makes the
gap easy to miss. Scope the selector's own queryset, or declare the
rule as SelectorSpec.filter_set — the sanctioned seam, applied by
the dispatcher on both paths.
Action-serializer dispatch¶
ActionSerializerResolver ¶
Bases: _ActionSpecsMixin
Resolve get_serializer_class() from action_specs.
A kernel
ServiceSpec answers
with its input serializer where the view sets no serializer_class. Every
other case falls back to DRF's serializer_class, raising the usual
AssertionError when none is set.
Example:
class InvoiceViewSet(ActionSerializerResolver, GenericViewSet):
action_specs = {
"create": ServiceSpec(service=create_invoice),
}
Custom actions¶
service_action ¶
service_action(
spec: ServiceSpec | DiscriminatedAction,
*,
detail: bool = False,
methods: list[str] | None = None,
url_path: str | None = None,
url_name: str | None = None,
success_status: int | Callable[..., int] | None = None,
response_finalizer: Callable[..., Response | None] | None = None,
**action_kwargs: Any,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]
Wrap a viewset method as a service-backed custom action.
The decorated method's body is not executed — the decorator supplies
the handler. The method exists so that @service_action can attach
DRF @action metadata and pick up the action name from __name__.
Pass a ServiceSpec
for the service wiring. detail, methods, url_path, url_name,
and any extra **action_kwargs are forwarded to DRF's @action, a
lenient_json_body among them, which outranks the viewset's for this
action (test_a_service_action_may_declare_its_own).
The spec runs through the kernel's own dispatch, as on the standalone
mutation views: its permissions are asked there, after the view's own
classes, a detail action's row is its own target selector's (never
get_object()), and the answer is its presenter's. The spec runs as
declared: a PATCH here is validated whole, and documented so, unless the
spec declares partial, which is the way to a partial call
(test_decorated_patch).
success_status and response_finalizer are this action's answer, in
the shapes the viewset's take (MutationFlowMixin), and outrank the
viewset's, an as_view() keyword included. Left None, the action
answers with the viewset's, else 200. They are asked at decoration what
a standalone view's are, and a viewset defining a
get_<action>_success_status or get_<action>_response_finalizer
hook beside one is refused at as_view(): one action, one answer
(test_per_action_answers). The refusal is this package's viewsets'
as_view(); on a viewset of DRF's alone the decorator's value answers
and the hook is never read.
An AdditionalInputRequired
answers 422 with its schema as raised on any viewset, DRF's own
included. The restore runs inside the exception handler the view asks for,
so the configured EXCEPTION_HANDLER runs once, and a handle_exception
override on the viewset still has the last word on the body.
A detail action with no target selector is refused here. The spec's
keywords are checked when its viewset is mounted: as_view() on a
viewset built on this package's mixins asks the kernel's check_seeds
against the viewset's pool_seeds, unless a service-kwargs hook of the
viewset's may fill what it would refuse. On DRF's own GenericViewSet or
ViewSet nothing asks it, and a seed nobody registers is the first
request's TypeError.
A DiscriminatedAction in place of the spec is asked its own shape here
and each of its variants as a spec is, and the request chooses which runs;
a callable success_status or response_finalizer may then declare
variant.
selector_action ¶
selector_action(
spec: SelectorSpec,
*,
methods: list[str] | None = None,
url_path: str | None = None,
url_name: str | None = None,
**action_kwargs: Any,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]
Wrap a viewset method as a selector-backed custom action.
The decorated method's body is not executed — the decorator supplies the
handler. The method exists so the decorator can attach DRF @action
metadata and take the action name from __name__.
spec.kind drives both the dispatch shape and the DRF URL shape, so there
is no separate detail= to keep in sync. SelectorKind.LIST is a
collection action (detail=False) whose rows are a page where the view
paginates; SelectorKind.RETRIEVE is a detail action (detail=True)
whose row is null under allow_none and a 404 otherwise. For a URL
shape that doesn't match the response shape, fall back to DRF's plain
@action and write the dispatch yourself.
A
SelectorSpec
runs through the kernel's own dispatch, as on the standalone selector
views: its permissions are asked there, after the view's own classes,
and the view's object permission of the row a RETRIEVE finds. Its
presenter renders the row or the page's rows, or the viewset's
get_serializer where it declares neither presenter nor affordances.
An
AdditionalInputRequired
answers 422 with its schema as raised on any viewset, DRF's own
included, as on @service_action.
The spec's keywords are checked when its viewset is mounted, not here:
as_view() on a viewset built on this package's mixins asks the
kernel's check_seeds against the viewset's pool_seeds, so a seed
the viewset registers serves and a keyword nothing fills is refused there
(test_a_decorated_action_s_seeds). On DRF's own GenericViewSet or
ViewSet nothing asks it, and such a keyword is the first request's
TypeError.
Anything but a SelectorSpec is refused where it is decorated, saying
which decorator serves it (test_selector_action's TestAtDecoration).