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.
The channel: build_offline_context(kwargs=…)¶
kwargs= is the off-HTTP counterpart of view.kwargs. Everything you pass there
is spread into the selector and target pools exactly as a route capture would be
— authoritative over the spec params, below a spec.kwargs provider.
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, so an unresolvable one raises ImproperlyConfigured on dispatch
rather than quietly letting every argument through. The permissive policies
read an unresolvable annotation as an open **kwargs, as they always have.
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
raise ServiceValidationError
when the key is absent — a caller-visible validation failure every transport
already maps, instead of a bare KeyError that none of them do.
Any channel satisfies the requirement: the caller's params, the kwargs=
channel above, or a spec.kwargs provider. The marker says the value must
arrive, not where from. A provider that declines with UNSET does not satisfy
it — declining removes the key from the pool entirely.
Off-HTTP only, by design
Enforcement lives in dispatch_spec / adispatch_spec. On the HTTP path the
route is the guarantee — a capture the URLconf doesn't declare is a wiring
bug, not caller input. The marker exists precisely because off-HTTP there is
no route to provide that guarantee.
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 because it is no longer a declared
input, UnknownArguments.REJECT treats a caller that supplies it as passing an
unknown argument. Delivery is untouched: the provider still fills it in.
Hiding a key is not what makes it safe
The security property is the SPREAD_AUTHOR_WINS precedence — the author's
provider overrides caller input — not the absence of an advertisement. Two
corollaries survive unchanged: a provider owning a scoping key must always
resolve it and never decline via UNSET (declining lets the caller's value
through, which for a scoping key is a cross-scope read), and opting into
SPREAD_CALLER_WINS on a scoped spec voids the guarantee whether or not the
key is hidden.
Marking a key both InputRequired and NotClientInput raises — the caller cannot
be required to supply a value it is never told about.
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, a transport adapter registers a
UrlKwarg:
from rest_framework_services import UrlKwarg
UrlKwarg("project_pk", type="integer", description="Owning project.", required=True)
The adapter merges its json_schema() into the operation's input schema, pops the
argument out of the caller's arguments, and routes it to
build_offline_context(kwargs=…). required=True is the registered-declaration
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.
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.
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.
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] |
| …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 adapter's
schema merge dedupes to one property (the explicit registration wins), the
registration pops the argument into kwargs=, and the authoritative spread still
delivers it to the selector pool, so both readers see it.