Skip to content

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:

warnings.filterwarnings("error", message=r"django_spec_rest\.W001")

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:

  1. @service_action(spec, success_status=..., response_finalizer=...), for a decorated action;
  2. a get_<action>_success_status or get_<action>_response_finalizer hook on the viewset, for any action, an action_specs entry included;
  3. the viewset's own, an as_view(success_status=...) keyword over the class attribute;
  4. 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 PATCH on the router's update route (an update or partial_update entry) is validated partially, as for a plain spec. A @service_action PATCH runs the chosen variant as declared, so it is validated whole; a variant declaring partial is the way to a partial call there.
  • A callable success_status or response_finalizer on such an action may declare variant, 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

get_object() -> Any

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).