Skip to content

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 TypedDictdoes 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:

class WidgetExtras(HttpExtras[MyUser], total=False):
    project_pk: Annotated[int, InputRequired]

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.