Dispatch (stable surface)¶
The stable dispatch surface: the primitives an alternate transport
(such as djangorestframework-mcp-server)
builds on instead of re-implementing the "how to call a service / selector"
rules. Every symbol here is importable from the top-level package, follows
semantic versioning, and will not move or change signature within a major
version. Blessed in 0.17, which also removed the private _compat
package — run_service / arun_service now live in services/ and
is_async at the package root; downstreams re-point their imports here
when they bump past 0.17.
Deliberately not part of this surface: dispatch_mutation_for_spec and
dispatch_selector_for_spec. They are view-coupled orchestrators (they take
a view, read its URL kwargs, and walk the get_<action>_*_kwargs hook
chains). The transport-neutral spec dispatcher that composes the leaves
below — dispatch_spec — is documented first.
Transport-neutral dispatch¶
The single execution path a non-HTTP transport drives: hand a spec, the
acting user, and a flat params mapping, get back a DispatchResult to
format for your wire. No view, no request required.
Each primitive comes as a sync / async pair — dispatch_spec /
adispatch_spec, render_spec_output / arender_spec_output. The async
member is not a thin alias: a spec's callables (selector, service, kwargs
provider, extend_queryset, filter_set, serializer-context providers) are
written once for both transports and so are never async def, and rendering
walks the ORM. The async members run all of that in Django's thread-sensitive
executor, which is what keeps SynchronousOnlyOperation out of an async
transport. Await the pair together; don't mix an a-prefixed call with a sync
one on the same result.
dispatch_spec¶
dispatch_spec ¶
dispatch_spec(
spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
*,
user: Any,
params: Mapping[str, Any] | list[Any],
request: Any = None,
view: Any = None,
success_status: int | None = None,
argument_binding: ArgumentBinding = ArgumentBinding.AUTO,
unknown_arguments: UnknownArguments = UnknownArguments.IGNORE,
on_target_resolved: TargetGuard | None = None,
) -> DispatchResult
Execute spec without a DRF view, returning a :class:DispatchResult.
The single transport-neutral execution path: an HTTP view, the MCP server,
or any other caller hands the flat params mapping (the role
request.data / query_params / URL kwargs play on HTTP) plus the
acting user, and gets back the resolved domain value to format for its
wire. Composes the blessed dispatch leaves; no pagination, ordering, or
output rendering happens here (those are transport concerns — render the
result with :func:~rest_framework_services.render_spec_output).
- A :class:
ServiceSpecruns the mutation flow: resolve the target viainstance_selector_spec(fromparams) → validateinput_serializer→ run the service → re-fetch throughoutput_selector_spec→ result. A missing instance yieldskind="not_found". - A :class:
SelectorSpecruns the read flow: invoke the selector → apply queryset shaping (select_related…filter_set, withparamsas the filter data) → forRETRIEVEmaterialize via.first()and honourallow_none/ not-found;LISTreturns the shaped + filtered queryset.
request / view are optional and only forwarded to user callables
that declare them (extend_queryset, the context providers, kwargs);
a pure non-HTTP caller passes neither. The view's kwargs (a route's
captures, seeded by build_offline_context(kwargs=…)) are additionally
spread into the selector / target pools — the off-HTTP counterpart of the
HTTP extra_url_kwargs=view.kwargs, authoritative over params on a
conflict, below the spec.kwargs provider — so a selector reading a URL
kwarg from its extras works off-HTTP. success_status overrides the
mutation status hint (else spec.success_status or 200).
Three caller-side policies tune how the wire maps onto the spec; the defaults reproduce the pre-policy behaviour exactly:
argument_binding(:class:ArgumentBinding) — whether client input lands as a singledatabundle or is spread as individual kwargs, and how it ranks against the author'skwargs.AUTOresolves per spec type (service → bundle, selector → spread).unknown_arguments(:class:UnknownArguments) — strictness aboutparamskeys outside the spec's declared set:IGNORE(drop),REJECT(raise),PASSTHROUGH(forward to the callable).on_target_resolved(:class:TargetGuard) — a hook invoked with the resolved mutation target before the service runs. Pass :func:~rest_framework_services.enforce_permissionsdirectly to enforce object-level permissions;dispatch_specitself stays authz-agnostic.
On a many=True bulk spec unknown_arguments is honoured per list
element (REJECT raises on the first item with an undeclared key,
PASSTHROUGH folds each item's extras into its data, IGNORE drops
them). argument_binding has no meaning there — the service receives the
whole list as one data argument, so there is nothing to spread — and a
non-default binding with many=True raises ValueError rather than
being silently ignored.
adispatch_spec¶
adispatch_spec
async
¶
adispatch_spec(
spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
*,
user: Any,
params: Mapping[str, Any] | list[Any],
request: Any = None,
view: Any = None,
success_status: int | None = None,
argument_binding: ArgumentBinding = ArgumentBinding.AUTO,
unknown_arguments: UnknownArguments = UnknownArguments.IGNORE,
on_target_resolved: TargetGuard | None = None,
) -> DispatchResult
Async :func:~rest_framework_services.dispatch_spec.
Same contract, :class:DispatchResult shape, and argument_binding /
unknown_arguments / on_target_resolved policies; async selectors /
services are awaited and sync ones run in Django's thread-sensitive executor
so the ORM stays safe off the event loop. A LIST result is returned as
the (lazy) shaped queryset — the async transport materializes / paginates it
in a thread, exactly as on the sync path.
That rule covers every callable a spec carries, not just the selector /
service: kwargs providers, extend_queryset, filter_set,
input_serializer_context, a callable success_status, and the
on_target_resolved guard all run in the executor (see
:func:~rest_framework_services.dispatch.utils.arun_off_loop). None of them
can be async def — a spec is written once for both transports — so any
that queries would otherwise raise SynchronousOnlyOperation here and
nowhere else.
As on :func:~rest_framework_services.dispatch_spec, a many=True bulk
spec honours unknown_arguments per list element and rejects a non-default
argument_binding (there is no per-item kwarg to spread into a single
list-payload service call).
render_spec_output¶
render_spec_output ¶
render_spec_output(
spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
value: Any,
*,
many: bool = False,
view: Any = None,
request: Any = None,
extras: Mapping[str, Any] | None = None,
) -> Any
Render value to a JSON-shaped payload using spec's output serializer.
The blessed render step that pairs with
:func:~rest_framework_services.dispatch_spec: every transport renders the
same way instead of re-implementing serializer + context plumbing. Reads
the output serializer from spec.output_serializer (selector) or
spec.output_selector_spec.output_serializer (service); when none is set
the value passes through (list-coerced when many=True so a queryset
evaluates).
The serializer context always carries DRF's baseline — request /
format / view, from
:func:~rest_framework_services.base_serializer_context — so a serializer
that reads self.context["request"] renders identically here and behind
a DRF view. The spec's output_serializer_context provider, if any, is
resolved with view / request plus the resolved-data extras it
declares (page for a list, instance for a retrieve, result for
a mutation) and merged over that baseline.
Pagination is the caller's job — pass the already-sliced page as value
(and the same page object under the matching extras key) so an
id-keyed batched context query reuses the page's result cache.
arender_spec_output¶
arender_spec_output
async
¶
arender_spec_output(
spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
value: Any,
*,
many: bool = False,
view: Any = None,
request: Any = None,
extras: Mapping[str, Any] | None = None,
) -> Any
Async :func:~rest_framework_services.render_spec_output.
Identical arguments, identical result — the whole render runs in Django's thread-sensitive executor instead of on the event loop, which is what an async transport needs. Rendering is full of sync ORM work, none of it optional:
serializer.dataiterates the value. Formany=Truethat evaluates the queryset; per row, every relation a field traverses is another query unless it wasselect_relatedin.- The spec's
output_serializer_contextprovider is user code, and is documented as the place to run one batched query keyed on the page. - The no-serializer path list-coerces, which evaluates a queryset too.
So an async caller that awaited :func:~rest_framework_services.adispatch_spec
— which returns a LIST result as a lazy queryset, deliberately —
cannot render it inline without raising SynchronousOnlyOperation. Pair
the two:
result = await adispatch_spec(spec, user=user, params=params)
payload = await arender_spec_output(spec, result.value, many=result.kind == "list")
A transport that already does its own thread hop around rendering (to
paginate or post-process in the same hop) can keep calling
:func:render_spec_output inside it — this is for the ordinary case, where
the hop shouldn't have to be the caller's problem to remember.
Pagination is still the caller's job; see the sync twin for the extras
contract and the rest of the semantics.
base_serializer_context¶
The DRF baseline (request / format / view) that every serializer gets for
free over HTTP, synthesized for the off-HTTP path. dispatch_spec and
render_spec_output apply it themselves — reach for it directly only when a
transport builds a serializer outside them. See
Customise serializer context.
base_serializer_context ¶
Build the baseline serializer context a spec-driven render starts from.
Over HTTP every serializer DRF builds carries
:meth:~rest_framework.generics.GenericAPIView.get_serializer_context —
{"request", "format", "view"} — so serializers routinely read
self.context["request"] unguarded (build_absolute_uri,
request.user, a permission check in a SerializerMethodField). A
spec's input_serializer_context / output_serializer_context provider
is additive config layered on top of that baseline, not a replacement for
it, so off HTTP — where there is no DRF view to ask — the baseline has to be
synthesized rather than skipped. Without it the same serializer that renders
over HTTP raises KeyError: 'request' when the spec is dispatched from an
MCP tool call, a Pydantic-AI toolset, or a management command.
Two sources, in order:
view.get_serializer_context()when the view has it — a real DRF view (the HTTP bulk path renders through the same helper). It is the view's own documented extension point and may already be overridden, so it wins.- Otherwise DRF's shape, synthesized from the
view/requestthe caller passed: the synthetic pair from :func:~rest_framework_services.build_offline_contextoff HTTP, orNonewhen the caller supplied neither.formatis alwaysNone— content negotiation is an HTTP-only concern.
The keys are always present, mirroring HTTP: a serializer reading
self.context["request"] off HTTP sees the synthetic request (and
None only when the caller passed no request at all), never a
KeyError. Absolute-URI fields additionally need real headers — pass the
ambient http_request to build_offline_context when the transport has
one, as the MCP server does.
The spec's provider is merged over this by
:func:~rest_framework_services.dispatch.utils.resolve_output_context /
:func:~rest_framework_services.dispatch.utils.resolve_input_context, so a
provider keeps the final say on every key — including these three.
DispatchResult¶
DispatchResult
dataclass
¶
What :func:~rest_framework_services.dispatch_spec resolved, pre-wire.
A transport-neutral carrier the caller formats for its own wire (an HTTP
Response, an MCP ToolResult, …). It holds the raw resolved
domain value — never a paginated page or rendered serializer output —
because ordering, pagination, and the response envelope are transport
concerns. Render the value through
:func:~rest_framework_services.render_spec_output.
Fields:
value— the resolved value: a single instance (RETRIEVE/ mutation result), a queryset / iterable (LIST), orNone(a nullable retrieve underallow_none, a missing instance, or a side-effect-only mutation).kind— one of"instance"(a single value, possiblyNone),"list"(a collection to order / paginate / rendermany=True), or"not_found"(a required instance could not be resolved).status— an HTTP-ish status hint the transport may map to its wire: the spec's success status for mutations,200for reads,404for"not_found".
Input policies¶
Three optional, caller-side policies let a transport map its wire onto a spec
without dispatch_spec baking in HTTP's implicit answers. The spec declares
what (its inputs, filters, output shape, permissions); the caller declares
how its flat input becomes callable arguments, how strict to be about
undeclared keys, and how to authorize a resolved target. The defaults reproduce
the pre-policy behaviour exactly, so a caller that passes none is unaffected.
ArgumentBinding¶
How the flat params map onto a dispatched callable's keyword arguments —
bundled as one data payload or spread as individual kwargs, and how the spread
ranks against the spec author's kwargs.
ArgumentBinding ¶
Bases: Enum
How dispatch_spec turns the flat params into a callable's kwargs.
Every dispatched callable's keyword pool always carries the request /
user seeds (and, for a mutation, data / serializer / instance
/ collection). This enum controls the one remaining question a caller
answers about its wire: can client-supplied input land as individual
keyword arguments, and do those override the spec author's kwargs(...)
invariants? It is a trust-boundary decision, not plumbing — which is why it
belongs to the caller rather than the spec.
Members (the value never appears on a wire — pass the member directly):
AUTO— resolve per spec type to reproduce the pre-policy behaviour: :class:ServiceSpec→BUNDLE(a mutation takes its validated payload as onedatabundle), :class:SelectorSpec→SPREAD_AUTHOR_WINS(a read spreads its params so the selector can declare them as parameters). The default.BUNDLE— only the validated payload reaches the callable, asdata=; individual client fields are not spread as kwargs. The safe default for a mutation whosekwargsscopes the write — the client cannot inject arbitrary keyword arguments.SPREAD_AUTHOR_WINS— client fields are spread into the pool as individual kwargs, butspec.kwargs(...)overrides on conflict. An author-scopedtenant_idcannot be reset by the client. (drf-mcp's formerMERGE.)SPREAD_CALLER_WINS— likeSPREAD_AUTHOR_WINSbut the client wins on conflict:spec.kwargs(...)supplies defaults the client may override. (drf-mcp's formerREPLACE.)
Reserved pool seeds (request / user / data / serializer /
instance / collection) are always stripped from the spread in the
SPREAD_* modes, so a client cannot poison transport-controlled state by
naming an argument after one of them.
UnknownArguments¶
How strict dispatch_spec is about params keys outside the spec's declared
set — drop them, reject them, or pass them through to the callable.
UnknownArguments ¶
Bases: Enum
How dispatch_spec treats params keys outside a spec's declared set.
The declared set is derived from the spec without any transport knowledge:
a :class:ServiceSpec's input_serializer fields plus the keys its nested
target selectors consume; a :class:SelectorSpec's selector parameters.
When the set cannot be enumerated — a callable that declares **kwargs, or
a duck-typed filter_set whose fields are opaque to the core — the spec is
treated as open and this policy is a no-op (there is nothing to call
"unknown").
Members (internal knob — the value never appears on a wire):
IGNORE— undeclared keys are dropped (DRF serializers already do this to a mutation's payload, and a selector simply never receives kwargs it doesn't declare). The default, reproducing the pre-policy behaviour.REJECT— an undeclared key raises :exc:~rest_framework.exceptions.ValidationError, the same surface a strict serializer produces. Useful when the caller wants a clean correction signal (e.g. a model calling a tool with a mistyped argument).PASSTHROUGH— undeclared keys survive: they are merged onto the mutation'svalidated_databefore the keyword pool is built, so a callable that declares them (or**kwargs) receives them. The one policy that must live insidedispatch_spec— it needs the seam between validation and pool construction that a wrapper cannot reach.
Callers strip their own transport-only keys (pagination, ordering, output format) before calling, so the declared-set check sees only spec inputs.
TargetGuard¶
The object-permission hook invoked with the resolved mutation target before the
service runs. Its signature matches enforce_permissions, so that primitive is
passed directly — by name, not wrapped in a lambda.
TargetGuard ¶
Bases: Protocol
Invoked with the resolved target before a mutation runs; raise to abort.
dispatch_spec deliberately does not consult permission_classes
(authorization is the caller's job — which is why
:func:~rest_framework_services.enforce_permissions ships separately). But
object-level checks need the resolved instance, which only dispatch_spec
sees, before the service runs. So dispatch_spec exposes this hook
rather than folding authz in: it stays authz-agnostic, only invoking a
caller-supplied guard.
The signature is deliberately identical to
:func:~rest_framework_services.enforce_permissions, so the canonical wiring
is passing that function directly, by name — not wrapping it in a lambda::
dispatch_spec(spec, ..., on_target_resolved=enforce_permissions)
A consumer needing custom logic writes a module-level function with the same
shape and passes it the same way; ty enforces conformance.
dispatch_spec builds the :class:OfflineContext itself (it already holds
user / request / view) and calls guard(spec, context,
instance=target). instance is the resolved row for an update, the
resolved set for a collection (bulk) mutation, and None for a create — so
the guard fires uniformly on every resolved target, running the class-level
check when there is no object.
Authorizing an off-HTTP call¶
dispatch_spec is authz-agnostic by design — it never consults a spec's
permission_classes (on HTTP that is the view's job). An off-HTTP transport
that wants the same authorization a DRF view would apply wires
enforce_permissions in two places:
from rest_framework_services import (
adispatch_spec,
build_offline_context,
enforce_permissions,
)
context = build_offline_context(user)
# 1. Class-level `has_permission`, before any work. Covers create / list-payload
# and every spec that has no resolvable target.
enforce_permissions(spec, context)
# 2. Object-level `has_object_permission`, on the resolved target. Fires on the
# mutation *and selector* paths (update, retrieve, and — class-level only —
# bulk / list collections, which are not per-row authorized).
result = await adispatch_spec(
spec,
user=user,
params=params,
request=context.request,
view=context.view,
on_target_resolved=enforce_permissions,
)
The upfront call is what authorizes a spec with no target (a create, or a
many=True list-payload); the on_target_resolved hook adds object-level checks
once the row (or collection) is resolved. Together they are the canonical wiring
for every spec kind. enforce_permissions is collection-safe: a resolved
queryset runs only the class-level check, never has_object_permission.
build_offline_context¶
build_offline_context ¶
build_offline_context(
user: Any,
params: Mapping[str, Any] | list[Any] | None = None,
*,
http_request: HttpRequest | None = None,
action: str | None = None,
kwargs: Mapping[str, Any] | None = None,
query_params: Mapping[str, Any] | None = None,
host: str | None = None,
) -> OfflineContext
Build the :class:OfflineContext for dispatching a spec outside an HTTP request.
:func:~rest_framework_services.dispatch_spec forwards request / view
to spec callables (kwargs providers, extend_queryset, context
providers) that declare them, and :func:~rest_framework_services.enforce_permissions
needs a request + view to evaluate permission_classes. This synthesizes
both so a spec written for the HTTP transport keeps working when driven from
a Pydantic-AI toolset, the MCP server, a management command, or a task runner.
useris set on the synthetic request (request.user) so callables and permissions that read it behave as on HTTP.paramsseedsrequest.datafor callables that read it. It is not validated here — that is :func:dispatch_spec's job, which takesparamsdirectly and never touchesrequest.data. Pass the same value to both. Seeded into DRF's parsed-data cache directly:paramsis already structured, so there is nothing to parse and a synthetic request has no WSGI stream to read.http_requestis wrapped when supplied (e.g. the MCP server passes its real Django request so headers /METAare available); otherwise an :class:~rest_framework_services.OfflineHttpRequestis created. The method is forced toPOSTbecause mutation callables often branch on it.hostgives the synthesized request an origin, sobuild_absolute_uri— which DRF'sFileField/HyperlinkedIdentityFieldcall whenever arequestis in the serializer context — returns real absolute URLs off the HTTP path. Accepts"example.com","example.com:8000", or a full origin like"https://example.com"(the scheme sets whether links are https). There is no default: only the project knows its public origin, and guessing one — the firstALLOWED_HOSTSentry, say, which is an authorization list and is routinely a wildcard or an internal load-balancer name — would emit confidently-wrong links. Left unset, absolute-URI building degrades to returning the relative URL rather than raising; see :class:~rest_framework_services.OfflineHttpRequest. Ignored whenhttp_requestis supplied — a real request's own headers are authoritative (configure Django'sUSE_X_FORWARDED_HOSTif it sits behind a proxy), which lets a caller pass both unconditionally: the ambient request when there is one, this host when there isn't.query_paramsseeds the request'sGET:class:~django.http.QueryDict— the sourcerequest.query_paramsreads. This is how read-shaping params that aren't spec inputs reach the serializer over the offline path: drf-services' ownSelectorSpec.filter_set(otherwise dead off-HTTP), and any serializer that branches onrequest.query_params(django-restql field selection, custom serializers). Values are stringified as on HTTP; a list / tuple value becomes a multi-valued param (getlist). Defaults to empty → no behaviour change. When bothquery_paramsandhttp_requestare given, this replaces the wrapped request'sGET.action/kwargspopulate the :class:OfflineServiceView.kwargsis the off-HTTP counterpart of a view's URL captures (theparent_pkof a nested route): it is the channel for route-derived values, and :func:dispatch_specspreads it into the selector / target pools exactly where the HTTP path spreadsextra_url_kwargs=view.kwargs— authoritative overparamson a key conflict, below aspec.kwargsprovider. Aspec.kwargsprovider that readsview.kwargs(e.g. to scope by tenant) also sees it here. Pass every route capture a spec depends on; it defaults to{}(no URL context).
Read-shaping over the offline path. Pass query_params= to seed the synthetic
request's GET QueryDict — the source request.query_params reads. That is how
read-shaping params that are not spec inputs reach the serializer off-HTTP:
SelectorSpec.filter_set (when you don't hand filter_data in another way), and
any serializer that branches on request.query_params (django-restql field
selection, custom serializers). It does not make DRF filter_backends
(SearchFilter / OrderingFilter) run — the offline path never calls
filter_queryset; filter_set is the drf-services-native equivalent.
OfflineHttpRequest¶
The synthetic request build_offline_context creates when there is no ambient
one. Relevant when you're deciding what build_absolute_uri() should return off
the HTTP path — see
Absolute URLs off the HTTP path.
OfflineHttpRequest ¶
Bases: HttpRequest
An :class:~django.http.HttpRequest with no ambient host, made safe to use.
Built by :func:~rest_framework_services.build_offline_context when the
caller has no real request to wrap (a Pydantic-AI toolset, a management
command, a task runner). A bare HttpRequest has an empty META,
and Django resolves the host from HTTP_HOST / SERVER_NAME — so
get_host() raises KeyError: 'SERVER_NAME' and takes
build_absolute_uri() down with it. That reaches serializers through the
most ordinary field there is: DRF's FileField.to_representation calls
request.build_absolute_uri(value.url) whenever a request is in the
context.
Two behaviours, decided by whether the caller configured a host
(build_offline_context(host=…), which seeds META — a configured
request is an ordinary one and none of this applies):
- Host configured — nothing here intervenes. Absolute URIs are built by Django exactly as on HTTP.
- No host —
build_absolute_urireturns the location unchanged, i.e. the relative URL. There is no honest absolute URL to return: the process has no idea what origin serves it, and a guess (the firstALLOWED_HOSTSentry, say) would emit confidently-wrong links that look valid. A relative URL is the same thing DRF's own file / hyperlinked fields fall back to when there is no request in the context at all, so this is the shape those serializers already handle.
get_host() still raises without a host — but with a message naming the
fix, rather than a bare KeyError from Django's internals.
build_absolute_uri ¶
Absolute URI when a host is configured; the location itself when not.
Degrading beats raising: the caller is a serializer field rendering a link, and a relative URL is a usable answer that its consumer can resolve against whatever origin it reached the data through.
get_host ¶
The configured host verbatim; a pointed error when there is none.
Deliberately not validated against ALLOWED_HOSTS. That setting
rejects spoofed Host headers from untrusted clients; this value came
from the project's own code, and there is no client. Requiring it to
appear in ALLOWED_HOSTS would break the ordinary case of a worker or
management command that renders links for a site it does not itself
serve — and would add nothing, since a caller who can pass a host can
equally pass one that is on the list.
enforce_permissions¶
enforce_permissions ¶
enforce_permissions(
spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
context: OfflineContext,
*,
instance: Any = None,
) -> None
Enforce spec.permission_classes against an off-HTTP context.
Mirrors what a DRF view does on the HTTP path —
[perm() for perm in spec.permission_classes] then
perm.has_permission(request, view) — but against the synthetic request
and view from :func:build_offline_context. dispatch_spec deliberately
does not consult permission_classes (authorization is the view's job
on HTTP), so an off-HTTP transport must call this itself before dispatching,
or it would skip authorization entirely.
When instance is a Django Model, object-level permissions
(has_object_permission) are also checked — matching the HTTP path's
check_object_permissions against a resolved instance. (drf-mcp's adapter
omits this; off-HTTP parity restores it.) A non-Model instance — most
importantly the collection queryset a bulk / LIST dispatch resolves — runs
only the class-level check, never has_object_permission: object
permissions are a per-row concept (the BULK decision authorizes per-set, not
per-row), and has_object_permission(request, view, <QuerySet>) would
AttributeError or silently mis-authorize. This makes
on_target_resolved=enforce_permissions the safe canonical guard for
every dispatch mode.
spec.permission_classes is None (the default — "inherit the view's
class-level permissions" on HTTP) is a no-op off-HTTP: there is no view
class to inherit from, so the transport owns any default policy. An empty
sequence is likewise a no-op ("no permissions").
Raises :exc:~rest_framework.exceptions.PermissionDenied (403) on the first
failing permission, carrying that permission's message / code when it
declares them — the same surface a DRF view produces.
Permission classes that read DRF APIView attributes beyond request /
action / kwargs (e.g. DjangoModelPermissions, which inspects
view.queryset) are not supported off-HTTP.
Shared¶
resolve_callable_kwargs¶
resolve_callable_kwargs ¶
Pick the subset of pool matching fn's declared parameters.
If fn declares **kwargs, the entire pool is passed.
Otherwise only parameters present in the signature are forwarded.
is_async¶
is_async ¶
Return True if calling fn(...) produces a coroutine.
Handles plain async def functions, functools.partial wrapping
one (via inspect.iscoroutinefunction's built-in unwrapping), and
any callable whose __call__ is a coroutine function.
Service side¶
run_service¶
run_service ¶
Call fn(**kwargs), optionally inside transaction.atomic().
arun_service¶
arun_service
async
¶
Await fn(**kwargs), optionally inside transaction.atomic().
build_input_serializer¶
build_input_serializer ¶
build_input_serializer(
request: Request,
input_serializer: type | None,
*,
partial: bool = False,
extra_data: Mapping[str, Any] | None = None,
context: dict[str, Any] | None = None,
instance: Any = None,
many: bool = False,
) -> Serializer | None
Construct + validate the bound input serializer; None if absent.
input_serializer may be:
- a bare dataclass type — wrapped in a
DataclassSerializeron the fly;validated_datais a dataclass instance; - a
DataclassSerializersubclass — instantiated directly;validated_datais a dataclass instance; - any other
Serializersubclass (e.g.ModelSerializer) — instantiated directly;validated_datais adict.
extra_data (when supplied) is merged on top of request.data
before the serializer instantiates — server-provided keys win on
overlap. This is the seam used by the input_data resolver chain
to lift URL kwargs into serializer input. A form-encoded / multipart
body arrives as a QueryDict ({key: [values]} internally), so the
merge goes through :func:_merge_extra_data to avoid flattening scalars
into one-element lists — see there.
context (when supplied) is forwarded to the serializer's context=
kwarg so DRF-style self.context["request"] / ["view"] lookups
work inside validators and fields.
instance (when supplied) is the resolved mutation target on
update / destroy flows. The serializer is constructed DRF-style —
serializer(instance, data=data, partial=partial) — so
self.instance is populated inside validate() / field validators
and instance-aware validators (e.g. UniqueValidator excluding the
current row) behave as they do under DRF's own update flow.
many (when True) validates data as a list — the bulk
list-payload path; validated_data is then a list of items.
The serializer is returned validated (is_valid(raise_exception=True)
has run) but never saved; the service owns persistence.
build_input_serializer_from_data¶
build_input_serializer_from_data ¶
build_input_serializer_from_data(
data: Any,
input_serializer: type | None,
*,
partial: bool = False,
context: dict[str, Any] | None = None,
instance: Any = None,
many: bool = False,
) -> Serializer | None
Construct + validate the bound input serializer from a raw data dict.
The transport-neutral core of :func:build_input_serializer: it takes the
input data directly instead of reaching into a DRF request.data, so
a non-HTTP caller (dispatch_spec) and the HTTP view path share one
validation implementation. See :func:build_input_serializer for the
input_serializer / partial / context / instance / many
semantics.
validate_input¶
validate_input ¶
validate_input(
request: Request,
input_serializer: type | None,
*,
partial: bool = False,
extra_data: Mapping[str, Any] | None = None,
context: dict[str, Any] | None = None,
instance: Any = None,
) -> Any
Validate request.data against input_serializer; None if absent.
Thin wrapper over :func:build_input_serializer (see there for the
parameter semantics) returning only validated_data — kept for
callers that don't need the bound serializer itself.
resolve_mutation_instance¶
resolve_mutation_instance ¶
Resolve the instance an update / destroy / detail action targets.
Precedence: spec.instance_selector_spec (when set with a selector)
→ the view's get_object() chain (an action_specs["retrieve"]
selector via :class:SelectorRetrieveMixin, else DRF's default
queryset / lookup_field lookup, else a user get_object()
override). Used by the update / destroy viewset mixins, the standalone
update / delete views, and @service_action detail actions so the
precedence lives in one place.
The spec path dispatches through :func:dispatch_selector_for_spec
(the standard selector call shape: {request, user} + the view's
URL kwargs + the selector extras chain, queryset shaping applied,
RETRIEVE materialization via .first()). The nested spec's
allow_none flag is ignored — a mutation against a missing row is
always a 404, so a None resolution raises
:exc:~rest_framework.exceptions.NotFound regardless. Object-level
permissions run against the resolved instance
(view.check_object_permissions), matching DRF's own get_object()
contract.
Returns None for a bulk spec (many=True or a
collection_selector_spec): there is no single instance, and the
get_object() lookup would 404 a body-only bulk endpoint. The bulk path
resolves its target inside :func:dispatch_mutation_for_spec instead.
Selector side¶
run_selector¶
run_selector ¶
Call a selector from sync code, transparently bridging async ones.
arun_selector¶
arun_selector
async
¶
arun_selector(
fn: Callable[..., Any] | Callable[..., Awaitable[Any]], kwargs: dict[str, Any]
) -> Any
Call a selector from async code; sync ones run inline.
is_queryset¶
is_queryset ¶
True for Django QuerySet objects and Manager instances.
These are the queryset-shaping targets: the things the four shaping
fields can be applied to, and the things a RETRIEVE selector / output
selector should be materialized from via .first(). Centralizes the
"is this a queryset?" decision so the selector and mutation dispatch
paths agree on one definition instead of duck-typing on a method name
(hasattr(..., "first")), which would also match an unrelated domain
object that happens to expose first. QuerySet subclasses
(.values(), .values_list(), polymorphic querysets, …) all pass.
apply_queryset_shaping¶
apply_queryset_shaping ¶
apply_queryset_shaping(
qs: Any,
view: Any,
request: Request,
*,
select_related: Any,
prefetch_related: Any,
annotations: Any,
extend_queryset: Any,
filter_set: Any = None,
filter_data: Any = None,
source_label: str,
) -> Any
Apply the five shaping fields to qs.
Declarative fields apply first (in declaration order), then
extend_queryset runs so the user callable always sees the fully
statically-shaped queryset, and finally filter_set narrows it via the
transport-neutral filter_set(data=filter_data, queryset=qs).qs contract
— validated first (see :func:_raise_on_invalid_filter, mirroring
DjangoFilterBackend's 400-on-invalid-filter) — so filtering composes with
shaping and runs before the retrieve .first() materialization the caller
does next. Returns qs unchanged when no shaping is configured.
Raises :exc:ImproperlyConfigured when shaping is configured but
qs is not a Django QuerySet (no annotate method) — loud
failure beats a stale AttributeError deep in DRF rendering.
source_label is included in the error to point at the misuse
("SelectorSpec.selector" vs "ServiceSpec.output_selector_spec.selector").
filter_set defaults to None so existing callers of this blessed
surface keep working unchanged. filter_data is the data the FilterSet
reads (a flat {field: value} mapping); it defaults to None, in
which case the value falls back to request.query_params — the HTTP view
path. A transport-neutral caller (dispatch_spec) passes its own params
here. request is forwarded into the FilterSet when its constructor
declares it (see :func:_filter_set_accepts_request), so a request-scoped
FilterSet sees the same self.request it would behind
DjangoFilterBackend instead of None — real on the HTTP / MCP paths, a
faithful-user / -query_params synthetic off-HTTP; a bare
(data, queryset) stand-in that doesn't declare request is unaffected.