URL-derived and required inputs off-HTTP¶
Over HTTP a nested route supplies its captures: /projects/{project_pk}/widgets/
puts project_pk into view.kwargs, and the framework spreads that into every
selector pool. Off-HTTP — under an MCP tool, an agent toolset, a management
command, a task runner — there is no route, so those inputs have to arrive some
other way, and a caller has to be told they exist.
This recipe covers the four pieces: making a URL-derived input discoverable, declaring it required, hiding an input the caller has no business setting, and saying in prose what one is for. It also says which inputs a service with no input serializer declares, and which validator answers a call (who validates what).
The channel: build_offline_context(kwargs=…)¶
kwargs= is the off-HTTP counterpart of view.kwargs. A name you pass there
reaches the selector and the target lookup exactly as a route capture would,
over the spec params, below a spec.kwargs provider, when the spec declares it:
a UrlKwarg in its reads, or a parameter the selector or target lookup reads.
A capture nothing declares, reads or hides is no argument.
A name the target lookup's selector reads that is also a writable input field,
such as a lookup by slug on a spec that renames the slug, is the lookup's
from the capture alone and the field's from the params: the capture finds the
row and the params' value is validated and written. With no capture the params'
value never scopes the lookup, which keeps its own default, or is refused as
missing where it has none. A view hook's or kwargs provider's value for the
name does both.
from rest_framework_services import build_offline_context, dispatch_spec
context = build_offline_context(user, {"status": "active"}, kwargs={"project_pk": 7})
result = dispatch_spec(
spec, user=user, params={"status": "active"}, request=context.request, view=context.view
)
Discoverability: the TypedDict is the declaration¶
A selector that reads its extras through the blessed strict-typing idiom already
declares its input surface, and spec_to_json_schema reflects it — one property
per key, no registration anywhere:
from typing import Annotated
from typing_extensions import Unpack
from rest_framework_services import HttpExtras, implements, ListSelector
class WidgetExtras(HttpExtras[MyUser], total=False):
project_pk: int
@implements(ListSelector[Widget])
def list_widgets(**extras: Unpack[WidgetExtras]) -> list[Widget]:
return Widget.objects.filter(project_id=extras["project_pk"])
The inherited request / user seeds are excluded automatically — they are
transport-controlled, never caller input.
Import the TypedDict at runtime
The declared surface is read from the resolved annotation, so the
TypedDict has to be importable when the callable is dispatched — an import
parked under if TYPE_CHECKING: is not. With
UnknownArguments.REJECT the annotation is the entire basis for refusing an
argument wherever the call reads it, so an unresolvable one raises
ImproperlyConfigured there rather than quietly letting every argument
through. The call reads it on a selector, a top-level one or a target
lookup's, under every ArgumentBinding, and on a service with no
input_serializer under SPREAD_AUTHOR_WINS alone, where its parameters are
the caller's input. It does not raise on a service dispatched under AUTO or
BUNDLE, which reads none of its signature, nor on a service with an input
serializer, whose serializer declares the input, nor beside a target lookup
whose selector takes a bare **kwargs, which leaves the call open: those
calls run. The permissive policies read an unresolvable annotation as an open
**kwargs, as they always have.
Only the **kwargs annotation itself counts here: another parameter, or
another key of the TypedDict, naming something imported under
if TYPE_CHECKING: leaves the surface known, because each annotation is
resolved on its own.
Requiredness: InputRequired¶
list_widgets above cannot run without project_pk, but the schema says the key
is optional, and a caller that omits it gets a KeyError from inside dispatch.
The obvious fix — making the key required in the TypedDict — does not work,
and the reason is worth knowing. Under PEP 692, a required key in
Unpack[...] makes the function reject callers that omit it, so it stops being
assignable to ListSelector / RetrieveSelector / the service Protocols. That is
also why HttpExtras mandates total=False.
Protocol conformance and an honest schema are mutually exclusive through the
TypedDict's own totality.
InputRequired carries the signal in Annotated metadata instead, which has no
effect on the type system:
Two things follow. The key joins the schema's required list, so a
schema-driven caller is told up front. And dispatch_spec / adispatch_spec
refuse a call that lacks it, with a DRF ValidationError keyed by the missing
argument (InvalidArguments, see
the kernel's refusals as DRF's),
which every transport already maps, instead of a bare KeyError that none of
them do: {"project_pk": ["This field is required."]}, code required. The
marker holds the key required whatever its Python default.
Any channel satisfies the requirement: the caller's params, the kwargs=
channel above, or a spec.kwargs provider, typed or not. The marker says the
value must arrive, not where from. A typed provider that declines with UNSET
does not satisfy it: declining removes the key from the pool entirely.
A plain keyword parameter is the other case. get_widget(user, *, pk) cannot run
without pk either, and its signature already says so, because pk has no
default. A transport that passes supplied= to spec_to_json_schema advertises
such a parameter as required without a marker, and drops the names it fills
itself along with the reserved pool seeds, which no caller can send. Without
supplied, only the marker makes a parameter required. See
what a transport supplies.
Dispatch enforces that one without a marker too, once the call's providers,
view hooks and pool seeds have run, since any of them may still fill it. A call
that lacks it then is refused the same way, {"pk": ["This field is
required."]}, with every such name keyed in the one refusal. A name the
declaration itself requires (one marked InputRequired, a required declared
read, a required input serializer field) is refused earlier, before the
permission checks run, so a call missing names of both kinds is answered in two
refusals, the declared names first. A spreading service's target lookup key is
asked for as the service is called, after both. That holds for the
target lookup, the selector under AUTO or SPREAD_AUTHOR_WINS (under
BUNDLE, which a selector view dispatches, only its declared reads are caller
input, and a parameter nothing fills is the selector's own TypeError), and a
service with no input_serializer dispatched
under SPREAD_AUTHOR_WINS (an input_serializer declares its own required
fields, and under AUTO such a service's own parameters are no caller input, so
neither its signature nor a marker on it is enforced; see
a service with no input serializer). A name no caller can send is
never required of one: a pool seed, a key any callable of the call marks
NotClientInput, or a key a typed kwargs provider fills. One of those that
nothing fills is the author's configuration error, raised as the callable's own,
not a refusal naming the key, since no resend could fix it.
A service with no input serializer¶
A ServiceSpec with no input_serializer has nothing but its service's
signature to read the caller's input against. Whether that signature is caller
input is the one question argument_binding still answers, and the default
answer is no:
def close_ticket(
*, instance: Ticket, data: dict, reason: str = "", notify: bool = False
) -> Ticket: ...
spec = ServiceSpec(
service=close_ticket,
instance_selector_spec=SelectorSpec(kind=SelectorKind.RETRIEVE, selector=ticket_by_pk),
)
dispatch_spec(spec, user=user, params={"pk": 7, "reason": "duplicate"})
Under AUTO, which is BUNDLE for a service, pk goes to the lookup and
reason reaches nobody: the service's own parameters are never the caller's,
so a body cannot set a hard or an is_admin the service merely declares. The
service receives data={} and its declared reads by name. Under REJECT,
reason is refused as Unexpected argument(s): 'reason'.
Pass ArgumentBinding.SPREAD_AUTHOR_WINS to make the signature the caller's
input:
dispatch_spec(
spec,
user=user,
params={"pk": 7, "reason": "duplicate"},
argument_binding=ArgumentBinding.SPREAD_AUTHOR_WINS,
)
Now reason reaches the service, a parameter with no default that the caller
leaves out is {"reason": ["This field is required."]}, and notify keeps its
default unless the caller sends it. An author's value, from a view hook or the
spec's kwargs provider, still outranks the caller's on a conflict. data is
{} here too.
| The caller sends... | IGNORE |
REJECT |
|---|---|---|
a key the lookup reads, or a reads entry |
delivered | delivered |
a parameter of the service, under SPREAD_AUTHOR_WINS |
delivered | delivered |
a parameter of the service, under AUTO or BUNDLE |
dropped | refused: Unexpected argument(s): 'reason'. |
| a key nothing declares | dropped | refused: Unexpected argument(s): 'zzz'. |
A target lookup that shapes its rows with a filter_set declares the filter's
names and its selector's parameters, so under AUTO or BUNDLE a key outside
them is refused under REJECT there too; a lookup whose selector takes a bare
**kwargs declares none, and nothing is refused. Under SPREAD_AUTHOR_WINS a
serializer-less service's own parameters join the set: a key that is one of them
is delivered, any other is refused, and a service taking only a bare **kwargs
declares none and drops the key, as below. A service with an input_serializer
joins nothing to the set, so there a key outside the lookup's names and the
serializer's fields is refused under REJECT whether the signature is closed,
takes a bare **extras or names the key as a parameter.
A parameter counts when the caller could fill it by name: a keyword parameter,
or a key of an Unpack[TypedDict] **kwargs. These do not count:
- a pool seed such as
user,dataorinstance, or a seed yourPoolSeedsregisters. The pool's value fills it, never the caller's, and the seed's name is never reported as an unexpected argument; - a positional-only parameter, which dispatch never passes by name;
- a
NotClientInputparameter, including a key a precondition or the target lookup marks, even where the service names it plainly.
A bare **kwargs declares no name. It receives the declared arguments and the
seeds the call carries (data and view among them), never an undeclared key:
under REJECT such a key is refused, except under SPREAD_AUTHOR_WINS, where
a serializer-less open service drops it instead; one with an input_serializer
still refuses it. An annotated **kwargs whose TypedDict
cannot be resolved at runtime makes REJECT raise ImproperlyConfigured where
the call reads that annotation, as the warning above sets out: for this service,
under SPREAD_AUTHOR_WINS, unless its target lookup's selector takes a bare
**kwargs.
The input schema says the same when it is told the binding. Pass
SPREAD_AUTHOR_WINS to spec_to_json_schema, and it lists the service's
parameters:
spec_to_json_schema(spec, argument_binding=ArgumentBinding.SPREAD_AUTHOR_WINS)
# {"type": "object",
# "properties": {"reason": {"type": "string"}, "notify": {"type": "boolean"}}}
The lookup's pk is not in it: a transport merges a target lookup's keys
itself, by reflecting the lookup's SelectorSpec. Without the argument the
schema is the bare {"type": "object"} it always was, which is what dispatch
admits under AUTO. See
a spreading service's own parameters.
A service with an input serializer
The serializer is the input, under every binding. Its validated payload
reaches the service as data, and each validated value also reaches a
parameter the service declares under the same name (the field's source,
not its wire name), so a service can take title by name rather than
reading data["title"]. A seed keeps its own value: a field named user,
such as a HiddenField(default=CurrentUserDefault()), stays in data. A
parameter named like a key any callable of the call marks NotClientInput
is never handed the client's validated value either: the client's value for that field reaches the
service only in data, while a view hook's, the spec's kwargs
provider's or input_data's value for it still reaches the parameter,
as the field validates it.
On a DRF view such a parameter needs a default (title=None), or the
service reads data["title"]: as_view() checks a service's required
parameters before any call, against the keys every call provides, and a
field's value is not one of them, since a PATCH may leave it out. A
required parameter of a field's name is refused with
ImproperlyConfigured when the view is built; dispatch_spec hands it
the validated value. A spec with a kwargs provider, or a view
overriding a get_*_kwargs hook, is not checked there, since its keys
cannot be known before a call.
Hiding provider-owned inputs: NotClientInput¶
Reflection advertises every declared key — including ones a spec.kwargs
provider fills in from request state, which the caller should never set:
class WidgetExtras(HttpExtras[MyUser], total=False):
project_pk: Annotated[int, InputRequired]
team_role: Annotated[str, NotClientInput] # resolved by spec.kwargs
team_role disappears from properties, and the caller's value for it never
reaches the callable. dispatch_spec / adispatch_spec treat it as no argument
the caller may send:
The caller sends team_role, and the policy is... |
What happens |
|---|---|
REJECT |
refused: Unexpected argument(s): 'team_role'. |
IGNORE |
the value is dropped |
The provider still fills it in. So can a view hook (get_service_kwargs,
get_selector_kwargs and their per-action forms), a registered pool seed, the
input_data of a service with an input serializer, the parameter's own
default, or, for a selector or a service's target lookup, a route capture of
that name (view.kwargs, or build_offline_context(kwargs=...) off HTTP);
those are channels the caller does not control. A route capture a target
lookup hides is seeded for the whole call, so a service parameter of the same
name is handed it too. For a target lookup, a registered seed of that name and the
lookup's own provider win over the capture, and an author's value for the name
(a view hook's or an untyped kwargs provider's) takes the capture's place. A
spec whose typed kwargs provider is annotated to fill a name the lookup reads
is refused with ImproperlyConfigured, since that provider's values reach the
service only after the target is resolved: make it untyped, or move the key to
the lookup selector spec's own kwargs.
A key is server-owned for the whole call once any callable in it marks it:
the selector or service, any of spec.preconditions, or the target lookup.
The callables of one call read the same caller input, so a key one of them
hides is gone from the declared set REJECT admits and from the input schema,
wherever else it is a plain parameter. A field the input_serializer declares
under that name is the one exception, because it is not a parameter: it stays
declared and listed, and the caller's value reaches the service only in data,
since a same-named field can mean something else, such as a destination tenant
beside the server's current one. A filter of the selector's filter_set under
that name is the other: the filter reads the caller's value, as every filter
does, and the parameter is handed only the server's (the provider's, a view
hook's or a route capture's) or its own default, so neither ever reads the
other's. The set is public as
server_owned_keys(spec), for a
transport that builds an input schema of its own and must leave out the same
keys.
A hidden parameter with no default that nothing fills is not named as a missing argument either: the caller could not send it, so the call fails as the author's error.
input_data (or a get_input_data view hook) is the spec author's own code,
merged over the caller's input with its keys winning, the spec's over the
hook's, so its value for a hidden key is the server's and is never dropped.
What is left to decide the value
Because the caller's value is gone before anything else is read, a provider
declining with UNSET cannot let it back in: what a declining provider
leaves is the parameter's default. A caller that used to fill the key through
its arguments, such as a task runner or a test, loses the value the same way:
silently where the parameter has a default, and as the author's error where
it has none, which over HTTP is a 500. Move such a value to a provider,
input_data or a pool seed. For a scoping key whose default reads as
"everything", that is still a cross-scope read, so a provider owning a
scoping key must always resolve it. A key the callable does not mark is
the caller's unless the spec's kwargs provider is annotated to fill it:
a key its TypedDict fills is never taken from the caller or a hook,
declined or not, nor through a field whose source it is. A key that is a
field's wire name owns that field's source as well, so with
tenant = CharField(source="org") a provider filling tenant leaves the
caller's value out of both the tenant and the org parameters, and a
hook naming either is dropped. A NotClientInput mark on tenant keeps
the caller's value out of org the same way. An input field
it names stays the serializer's: the caller's value is validated and
reaches the service in data alone, as a NotClientInput key's does,
while an input_data value for that field is the server's and reaches
the parameter.
Marking a key both InputRequired and NotClientInput raises — the caller cannot
be required to supply a value it is never told about.
Annotations that do not resolve at runtime
Each annotation is read on its own, so a parameter or key typed with a name
imported only under if TYPE_CHECKING: costs only itself: the markers on every
other parameter and key are hidden, required and enforced as written. Its own
markers are read with that name standing in for Any, so
Annotated[Owner, NotClientInput] | None is hidden and
Annotated[Owner, InputRequired] is required. Where even that cannot reach a
marker, as in Annotated[models.Owner, NotClientInput] with models imported
for type checking only, the marker is honoured by its name, which fails closed:
an annotation whose text names NotClientInput is hidden, and one naming
InputRequired is required. Read that way, an InputDescription is not
published and a marker placed too deep is not refused, so import the names a
marked annotation uses at runtime where you can. The one thing that stays
unknown is a **kwargs: Unpack[Extras] whose TypedDict does not resolve:
nothing says which keys it has, so none is hidden and REJECT refuses to run
on it wherever the call reads that annotation (see the warning about
importing the TypedDict at runtime, above, for where that is).
view is never caller input¶
dispatch_spec seeds view on every call: a selector, service or provider
declaring it receives the view the transport passed, or None off HTTP. A
caller's view is never an argument (under REJECT it is refused as
Unexpected argument(s): 'view'., under IGNORE dropped; a caller's
request, and its serializer, which the input serializer binds, are dropped
under either, like any other seed's name), and a kwargs provider or a route
capture named view cannot outrank the seed. A spec's kwargs provider
returning a view key raises ImproperlyConfigured, as it does for any name
the call seeds, a view
hook's view is dropped for the seed, and registering view (or request) in
pool_seeds raises ImproperlyConfigured, since the edge seeds both.
Describing an input: InputDescription¶
project_pk now reaches a schema-driven caller as {"type": "integer"} and a
name. A model calling the tool has to guess what a project id is for here, and
the type says nothing about it.
A serializer field would carry help_text. A TypedDict key has no field, and
neither of the two markers above can carry text — both are singletons, so
InputRequiredType() returns the one instance and has nowhere to put per-key
prose. InputDescription is the third marker, and it is a value rather than a
singleton for exactly that reason:
class WidgetExtras(HttpExtras[MyUser], total=False):
project_pk: Annotated[
int, InputRequired, InputDescription("The project whose widgets to list.")
]
The text lands on the property as description — the same key the serializer
path fills from help_text and the filter path from a filter's — so one
project's inputs read the same way whichever side of a spec they arrive on. Order
inside Annotated does not matter, and metadata belonging to other libraries is
left alone.
The alternative was declaring the same input twice: once in the TypedDict the
callable actually reads, and once in a serializer written only so one transport
could describe it. Two declarations of one input drift, and the one that drifts
is the one nothing executes.
Two refusals
Two InputDescription markers on one input raise — a schema publishes one
description, and choosing between them would be an arbitrary rule to
remember. So does an InputDescription beside NotClientInput: that marker
drops the key from the schema, so the sentence has no caller left to reach.
Neither is a contradiction the way InputRequired + NotClientInput is —
they are declarations that would decide nothing, which is the thing this
package refuses to do quietly.
To describe the operation rather than one of its inputs, see a spec-level title and description.
Values the callable never declares: UrlKwarg¶
Reflection covers keys the callable's own signature declares. It cannot cover a
value read only by a spec.kwargs provider off view.kwargs, because that value
appears in no signature at all — nor a closed-surface spec whose route capture
must still be caller-suppliable.
For those, the spec declares a
UrlKwarg in its own
reads:
from rest_framework_services import UrlKwarg
list_widgets_spec = SelectorSpec(
kind=SelectorKind.LIST,
selector=list_widgets,
reads=[UrlKwarg("project_pk", type="integer", description="Owning project.", required=True)],
)
Each entry is a parameter of the compiled spec, so it is an argument a caller
sends, the schema lists (bar a QueryParam that is a shape, which
spec_to_json_schema does not list) and REJECT admits (see
what a spec declares for a caller off HTTP).
Where the value comes from depends on the view the call carries:
- A live DRF view reads a declared
UrlKwargfrom its route alone, or the parameter's default where the route has none. A body value of that name is neither the argument nor written ontoview.kwargs: the view's permission classes have already read the route, and a value they never saw must not reach the service as though the URL had carried it. - Off HTTP the caller sends it as an argument. On an
OfflineServiceView, which carries no route of its own, the value is also written ontoview.kwargs, so a provider or a permission class reading the route finds it, and one the caller did not send is read fromview.kwargs.
A target lookup's reads take the same channels the same way, so a
SelectorSpec(reads=[QueryParam("q")]) used as an instance_selector_spec reads
q from a live view's query string alone, and each is coerced to the type it
declares, as the spec's own are. A name the spec's reads declare too is read
by the spec's declaration. A read named like a writable field of
the input serializer is refused when the spec compiles, because the body's value
of that name is the field's, except a UrlKwarg the target lookup reads, which
renames a row by the key that finds it: the route's capture scopes the lookup,
and off HTTP the caller's value for the name is the field's alone, never written
onto view.kwargs.
A service parameter named like a read is handed its value by name. On a DRF
view it needs a default, as one named like an input field does: as_view()
checks a service's required parameters before any call, and a request may
carry no query value, so a required parameter of a read's name is refused
when the view is built. Off HTTP, dispatch_spec hands it the argument. A
selector's parameter is not refused: a selector takes its arguments from its
signature, so a required one makes the read a required argument, and a request
without it is answered 400.
The example project declares reads=[QueryParam("status")] on its list
spec and asserts the selector is handed it both ways, beside an off-HTTP
dispatch_spec(partial=True) call that validates only the fields it sends.
Route captures and query values are coerced to the type the entry declares. required=True is the declared
counterpart of the InputRequired marker: both land the name in the schema's
required list, and they differ only in where the key is declared. A read of
the name of a parameter that has no default, of the selector, a target lookup,
the service or a precondition, is required without required=True, since the
callable cannot run otherwise; only a default on the read, or on the
parameter, makes it optional in the compiled parameters and the kernel's input
schema (spec_input_schema), which report a default and do not fill it in. A
parameter marked InputRequired stays required under a read even with a None
default, except for a target lookup's keys on the DRF edge, which the edge
relaxes because it never runs the lookup for a row a view hands in. A transport
adapter that keeps its own registration and routes the value to
build_offline_context(kwargs=...) itself, as earlier releases needed, still
reaches a provider reading view.kwargs; the value is an argument only where
the spec declares or reads the name.
QueryParam is the sibling for read-shaping values that belong on
request.query_params (django-restql field selection, a serializer that branches
on the query string). It has no required flag on purpose — omitting a
read-shaping param is legitimate by construction. It follows the same split: a
live view reads it from the request's query string alone and never writes a body
value there, while off HTTP the caller sends it as an argument, which joins an
offline request's request.query_params when the query string lacks it.
A QueryParam that no callable of the call takes by name is a shape: it
shapes what is rendered rather than what is run, so the kernel hands it to the
presenter alone (which callables count).
When the kernel's dispatch presents the result, the presenter builds an
offline request for the principal whose query string is the call's shapes, so a
serializer reading fields off request.query_params finds what the caller
sent without anything being put on a request by hand:
from django_service_specs import dispatch, kernel_spec, present
from rest_framework.permissions import AllowAny
class AuthorSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()
def to_representation(self, instance):
data = super().to_representation(instance)
wanted = self.context["request"].query_params.get("fields")
if wanted:
data = {name: data[name] for name in wanted.split(",") if name in data}
return data
authors = SelectorSpec(
kind=SelectorKind.LIST,
selector=list_authors, # takes no `fields`, so it is a shape
output_serializer=AuthorSerializer,
permission_classes=[AllowAny],
reads=[QueryParam("fields", description="Comma-separated fields to return.")],
)
spec = kernel_spec(authors)
result = dispatch(spec, principal=user, arguments={"fields": "name"})
present(spec, result) # [{"name": "Ada"}]
The drfs edge renders in a separate step, with the request and view the
caller hands render_spec_output, so there the query string is the one the
request carries: the offline request build_offline_context(query_params=)
seeds, and onto which dispatch_spec puts a declared QueryParam the string
lacks when the view is an offline one. A DRF Request the call carries is left
as it is on the kernel's route: it has a query string of its own.
Both types live here rather than in each adapter so the same declaration means the
same thing on every transport. Adapters validate a registration set with
validate_channel_names, which
always includes RESERVED_POOL_SEEDS and takes the transport's own reserved
pagination names on top.
The request and view a call carries¶
Over HTTP the permission classes and the serializers meet the request and view
the framework built. Off HTTP the call may carry them too, in either of two
places, and each consumer reads them in the same order: the request or view
the call registered as a seed, else the entry of that name in its
call_context, with None in either place counting as no value. A seed outranks
the context, so a transport's own registered object is never replaced by what a
caller put in the dict. dispatch_spec registers the request and view it
was handed, so one passed there is the seed and wins; with none passed, an entry
the caller put in call_context is read. A transport that runs the kernel's
dispatch over kernel_spec(spec) registers none of drfs' edge seeds, and its context
holds the request a run arrived on and, perhaps, a view built for the operation
(the call's context). A seed
it does register fills a selector's, a target lookup's or a spread service's
signature parameter of that name, since the compile marks every such read
reflected, while a declared read of that name is still refused.
What a carried request or view becomes differs by consumer, and by what it is:
| The call carries... | DRFPermissionCheck asks the classes |
The serializer context holds |
|---|---|---|
a DRF Request and a view |
both, as they are | both, as they are |
a DRF Request alone |
the request, beside an offline view | the request, beside None for the view |
| a view alone | the view, beside an offline request for the principal | the view, beside None for the request |
a Django HttpRequest DRF has not wrapped |
a DRF Request around a copy of it, beside the carried view, else an offline view |
the same wrapped request, beside the carried view, else an offline view built around it |
| neither | an offline request and view for the principal | an offline request and view for the principal |
A permission class is never handed None for a request or a view, which is why
it fills the half the call lacks. A serializer is not asked to, and pairing a
carried request with a view built around another one would describe a call that
never happened, so the serializer context keeps one carried half as it is. A
view that answers get_serializer_context supplies the serializer context
whole, as it does on HTTP.
A carried Django request is wrapped as build_offline_context(http_request=)
wraps one: a copy of it, so the request the run arrived on is never written to,
with its headers and session shared, its user the principal, and its query
string replaced. The query string is the arrived-on endpoint's, not this
operation's, so it is not kept. The serializer's is the call's shapes, and
empty where the call sent none. A permission class's is the value of each
declared QueryParam the call sent, or its default, with the request's own
left alone only when the permission check is asked with no arguments at all.
The offline view's action is the call context's action, where that is a
non-empty string, so a permission class or a serializer branching on
view.action reads the operation's name rather than None. A view the call
carries keeps its own action.
Which one do I reach for?¶
| The value is… | Use |
|---|---|
read by the callable from its own **extras |
nothing — reflection covers it |
| …and the spec can't run without it | Annotated[T, InputRequired] |
| …and the caller must never set it | Annotated[T, NotClientInput], filled by a provider, a pool seed, input_data or the default, or for a selector or a target lookup a route capture |
| …and the name alone doesn't say what it is for | Annotated[T, InputDescription("…")] |
read only by a spec.kwargs provider off view.kwargs |
UrlKwarg(..., required=…) |
read off request.query_params to shape output |
QueryParam(...) |
A key can be both reflected and UrlKwarg-registered — a project_pk the
selector reads and a scoping provider reads off view.kwargs. The
reads entry replaces the reflected parameter of the same name. Over HTTP both
readers see the route's value; off HTTP dispatch_spec delivers the caller's
argument to the selector and writes it onto the OfflineServiceView's
view.kwargs, so both readers see it there too.
Who validates what¶
One spec, two validators, chosen by the view the call carries:
| The call carries... | Validated by | So |
|---|---|---|
a live DRF view (any view that is not an OfflineServiceView) |
the input serializer alone, from the body the request parsed | DRF's coercion and wording: "true", 1 and "on" for a boolean, a number for a string, form bodies, every field's refusal at once |
an OfflineServiceView, or no view |
the kernel's typed check of every declared argument, then the serializer | JSON types only: "true" for a boolean or 123 for a string is refused, a Python date or Decimal is refused, a list past 1000 items is refused, and the serializer runs only once every argument passes, so its own refusals (a max_length, a validate()) wait behind a type refusal |
An agent transport sends typed JSON and wants a refusal it can show a model, so
it calls dispatch_spec with an OfflineServiceView (from
build_offline_context) or none. A browser or a form sends what DRF has always
accepted, so the views keep answering it as DRF does. A many call's list of
rows is bounded off HTTP and on an OfflineServiceView only: 1000 rows unless
the ListSerializer sets max_length or the project sets
SERVICE_SPECS_MAX_ARRAY_LENGTH. Over a live view the ListSerializer alone
bounds a bulk body.