Skip to content

JSON Schema

View-free JSON Schema generation. These helpers turn a ServiceSpec / SelectorSpec (or a bare DRF serializer / dataclass) into a JSON Schema dict, with no view, request, or drf-spectacular dependency — what an alternate transport (a Pydantic-AI toolset, the MCP server) builds tool definitions from. Distinct from the OpenAPI adapter, which produces DRF serializer classes for DRF's own OpenAPI generators.

serializer_to_json_schema

serializer_to_json_schema

serializer_to_json_schema(
    serializer: type | None,
    *,
    partial: bool = False,
    registry: JsonSchemaRegistry = DEFAULT_JSON_SCHEMA_REGISTRY,
    max_depth: int | None = None,
) -> dict[str, Any]

Build a JSON Schema object for an input serializer / dataclass / None.

Accepts a DRF Serializer subclass, a bare @dataclass type (the convention drf-services services use for data), or None (the operation takes no input). Always returns an object — {"type": "object"} is the convention for "no declared fields", so an alternate transport can still describe the tool.

partial=True drops the required list — mirroring spec.partial, where the validator accepts omitted fields, so advertising them as required would make schema-strict consumers reject calls the service accepts.

registry supplies consumer rules for custom field / Python types — see JsonSchemaRegistry.

max_depth bounds how many serializer levels are described, truncating deeper ones to {"type": "object"}; None, the default, describes them all, which is what every caller got before the option existed. The root is level 1, so max_depth=1 publishes the top-level fields and truncates every nested serializer. It is a size knob and nothing more: a self-referential serializer is truncated after a fixed number of appearances whatever this says, because the alternative is a RecursionError raised while a transport declares its tools. Whichever of the two is tighter wins, so this still yields exactly the levels it names. A dataclass input has no nested-serializer walk, so the bound does not reach that branch.

Truncation is flat and self-contained — never $defs / $ref, which most MCP clients reject outright.

output_to_json_schema

output_to_json_schema

output_to_json_schema(
    output_serializer: type | None,
    *,
    kind: SelectorKind | None = None,
    paginate: bool = False,
    projection: AudienceProjection | None = None,
    handle_description: str | None = None,
    registry: JsonSchemaRegistry = DEFAULT_JSON_SCHEMA_REGISTRY,
    max_depth: int | None = None,
    affordances: Mapping[str, AffordanceDeclarer] | None = None,
    allow_none: bool = False,
) -> dict[str, Any] | None

Build a JSON Schema for an output serializer, or None when undeclared.

Returns None when there is no output_serializer — callers shouldn't fabricate a misleading shape — and when the output is a BaseSerializer subclass that is not a Serializer, which renders but declares no fields. Anything that is neither a BaseSerializer subclass nor a dataclass type raises TypeError: None would read as "no output declared" for a declaration whose output cannot be rendered at all. A transport building schemas lazily, per listing request, meets that error there, so build them where the spec is registered. kind / paginate make the schema match what dispatch actually returns:

  • kind=None / RETRIEVE — the bare item schema.
  • kind=LIST, paginate=False — {type: array, items: <item>}.
  • kind=LIST, paginate=True — the pagination envelope {items, page, totalPages, hasNext}.

projection applies the serializer's field markings, mirroring what project_payload does to the payload — hidden fields dropped, choices re-declared in their display values, and a formatted field re-declared as the type its ValueFormatter produces. It lands on the item, wherever the item sits for this kind — the array wrapper and the pagination envelope are this function's own shapes and belong to no serializer, so a projection walking them would look for markings that cannot exist and silently annotate nothing.

handle_description is passed through to annotate_output_schema as the fallback wording for an unlabelled handle. It defaults to nothing: what a reader should do with an identifier depends on the reader, and the transport is what knows.

registry supplies consumer rules for custom field / Python types — see JsonSchemaRegistry.

max_depth bounds how many serializer levels the item describes, truncating deeper ones to {"type": "object"}; None, the default, describes them all. The item is level 1, and the array wrapper and the pagination envelope are this function's own shapes, so they cost no level. Independently of this bound, a serializer that nests itself is truncated after a fixed number of appearances rather than recursing until the process dies; where the two disagree the tighter wins, so this still yields exactly the levels it names. Truncation is flat and self-contained — never $defs / $ref, which most MCP clients reject outright.

affordances is the rendering selector spec's affordances mapping, and declares the affordances object every rendered item then carries: per name, a required available boolean, plus the code -- enumerated from the declaration -- and reason a refused answer adds, with or without a projection, since the agent audience reads the same answers a browser does. Pass it wherever the payload is rendered by render_spec_output or render_for_audience from a spec that declares them; spec_to_json_schema does so itself.

allow_none states the null an allow_none RETRIEVE presents for a miss: for kind=None / RETRIEVE the item's "type" becomes ["object", "null"], every other keyword kept beside it. It is added last, after the projection and the affordances property, because those describe the row and the null is the absence of one. A list never presents None, so for kind=LIST it changes nothing, paged or not. Off by default, which is the schema every caller got before it existed: this function takes a serializer and a kind, never the spec, so the caller says whether a miss is presented. spec_to_json_schema passes can_present_nothing(spec) for a SelectorSpec and a ServiceSpec alike, so a SelectorSpec's allow_none reaches it only through that answer. A transport whose protocol needs an object at the root -- an MCP tool's outputSchema -- leaves it off and states the empty case in its own terms.

A miss that is served as null

An allow_none RETRIEVE presents None when its selector finds nothing, so its schema has to admit null as well as the row. allow_none=True states it as a type list, leaving every other keyword in place:

output_to_json_schema(InvoiceSerializer, kind=SelectorKind.RETRIEVE, allow_none=True)
# {"type": ["object", "null"], "properties": {...}, "required": [...]}

The function takes a serializer, not a spec, so the caller says whether a miss is served. spec_to_json_schema(spec, phase="output") asks can_present_nothing of the spec and passes its answer. A list never presents None, so kind=LIST is unchanged whatever allow_none says. The default is False, which is the schema every caller got before the parameter existed. A protocol that needs an object at the root, such as an MCP tool's outputSchema, can leave it off and describe the empty result in its own terms.

can_present_nothing

can_present_nothing

can_present_nothing(spec: ServiceSpec | SelectorSpec | SpecSource) -> bool

Whether a successful dispatch of spec may present None as its result.

django-service-specs' can_present_nothing, asked of the compiled spec, so the output schema and every transport advertising one of its own give the same answer:

  • A SelectorSpec: a RETRIEVE declaring allow_none. A LIST presents its rows, [] for none, and never None.
  • A ServiceSpec with an output_selector_spec: a RETRIEVE re-read, whatever allow_none says, because a re-read finding nothing after the service has run is None rather than not-found. A LIST re-read never presents None.
  • Any other ServiceSpec answers its own allow_none.

many is not read. A SelectorSpec with no selector is answered from its declaration, as its output schema is, since answering needs no selector to run.

When a service presents nothing

A single-row ServiceSpec presents None in two ways, and its output schema admits null for both:

  • Its output re-read finds no row. An output_selector_spec with a selector re-reads the row the service wrote, and dispatch materializes that re-read with .first(), so a selector that filters the row out yields None. This needs no declaration.
  • The service returns None and declares it. With no re-read, dispatch presents the service's own return. allow_none=True on the ServiceSpec says that return may be None:
ServiceSpec(
    service=touch_tasks,  # returns None
    allow_none=True,
    output_selector_spec=SelectorSpec(kind=SelectorKind.RETRIEVE, output_serializer=TaskOut),
)
# spec_to_json_schema(spec, phase="output")["type"] == ["object", "null"]

Without the declaration the schema stays strict, and an undeclared None is not refused in this release: dispatch presents it, against a schema that does not admit it. allow_none on the nested output_selector_spec is still not read, and a result that is a list (a LIST output declaration, re-read or not) is declared never None whatever either flag says. A many=True service answers its own allow_none: its output selector only presents its rows and is never run, so a RETRIEVE one is no re-read that may find nothing. A callable returning None there anyway is the author's error. Dispatch refuses it only where a LIST declaration has nothing to re-read; a many=True service, a LIST re-read and a LIST SelectorSpec's selector have their None presented, which a transport validating structured output will reject.

The capability manifest's output_schema is the same schema. A transport advertising an output schema of its own should ask can_present_nothing(spec) rather than derive the answer again, so every route states the same null.

filterset_to_json_schema

filterset_to_json_schema

filterset_to_json_schema(
    filter_set_class: Any,
    *,
    registry: JsonSchemaRegistry = DEFAULT_JSON_SCHEMA_REGISTRY,
) -> dict[str, dict[str, Any]]

Map a django-filter FilterSet class to JSON Schema properties.

Returns a dict shaped like the "properties" key of a JSON Schema object, ready to merge into a spec's input schema — which is what spec_to_json_schema does for a SelectorSpec carrying a filter_set. Every filter is optional: a filter narrows the queryset but is never required to call the operation, so no name is added to a required array.

registry.filters rules are tried first, so a consumer can map a custom filter type or override a built-in; common filter classes get accurate mappings otherwise, and anything unrecognised falls back to {} (JSON Schema "any value") rather than breaking generation. Requires the [filter] extra (django-filter), raising a clear ImportError when it is absent — only ever when a filter_set is actually introspected.

These rules describe drfs' schema alone. A spec's filter_set compiles to Parameters the kernel builds itself, from each filter's form field, so drfs builds none of them and a spec's json_schema_registry has nowhere to apply its filters rules on the compiled spec: the kernel's schema of a filter_set reads as the kernel reads it, whatever is registered here (test_a_filters_rule_reaches_drfs_schema_and_not_the_compiled_spec).

What a filter publishes about itself

A filter carries more than a type. Its label becomes title, its help_text becomes description, and a ChoiceFilter's labels ride along with their constants as {"const": ..., "title": ...} — the same shape the serializer path has always produced, so one project's constants are described one way whichever side of a spec they arrive on. Labels that only restate their value are dropped.

Where the argument's own name does not give the lookup away, it is stated:

class ArticleFilter(django_filters.FilterSet):
    min_views = django_filters.NumberFilter(field_name="views", lookup_expr="gte")

publishes min_views as {"type": "number", "description": "Matchesviewswith thegtelookup."}. A filter whose name, field and lookup already agree — name matching name for equality — says nothing extra, and your own help_text always wins over the derived wording.

spec_to_json_schema

spec_to_json_schema

spec_to_json_schema(
    spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
    *,
    phase: Literal["input", "output"] = "input",
    registry: JsonSchemaRegistry | None = None,
    max_depth: int | None = None,
    supplied: frozenset[str] | None = None,
    argument_binding: ArgumentBinding = ArgumentBinding.AUTO,
) -> dict[str, Any] | None

Derive a JSON Schema from a spec, reading the right serializer off it.

The convenience an alternate transport (a Pydantic-AI toolset, the MCP server) calls instead of reaching into spec internals itself. registry supplies consumer rules for custom field / filter / Python types — see JsonSchemaRegistry. Passed, it describes every part of the schema, the empty DEFAULT_JSON_SCHEMA_REGISTRY included (test_a_passed_registry_wins_over_the_spec_s, test_a_passed_registry_wins_over_a_nested_spec_s). Left out, the spec's own json_schema_registry describes it (test_a_service_spec_s_input_reads_its_registry, test_a_selector_spec_s_reads_take_its_python_type_rules), and a ServiceSpec's output, which its output_selector_spec's serializer describes, reads that nested spec's registry, else the service spec's (test_an_output_selector_spec_reads_its_own_registry, test_an_output_selector_spec_with_none_reads_the_outer_spec_s), as the compile does. With neither, the empty default.

phase="input" (default) returns the input-argument schema:

  • ServiceSpec → its input_serializer (spec.partial honoured), a bare {"type": "object"} without one, and, with no input_serializer under a spreading argument_binding, the service's own parameters (below). The target lookup's keys, such as the pk an instance_selector_spec reads, are not in it: a transport reflects that SelectorSpec itself and merges them. On a many=True spec that schema describes one item, and the input is an object with a single required property named by spec.many_argument -- {"type": "array", "items": <item>}, with minItems / maxItems where the list serializer declares allow_empty=False / min_length / max_length -- and additionalProperties: false. A transport describing a tool passes arguments as an object, which can never be the bare array the HTTP body is; dispatch that input with many_as_argument=True. partial relaxes the item and never the argument, which DRF requires under partial too, and max_depth counts from the item, since the wrapper is not a serializer.
  • SelectorSpec → an object whose properties combine the selector callable's own annotated parameters (skipping the request / user / view transport seeds) with its filter_set fields, so get_widget(user, pk) advertises pk instead of leaning on its docstring; a bare {"type": "object"} when it exposes neither. A **kwargs: Unpack[SomeExtras] parameter is expanded into one property per TypedDict key, its required keys populating required, so a URL kwarg read from extras is discoverable off-HTTP rather than a hidden KeyError. Introspecting a filter_set needs the [filter] extra.

supplied is for a transport describing its own tools, and names what that transport fills itself beyond the seeds every transport reserves: its registered pool seeds, the names a kwargs= provider returns, URL kwargs. None, the default, leaves the reflection exactly as it was without it: every reflected parameter is optional unless InputRequired (or a required TypedDict key) says otherwise, because a reader that does not know what the transport fills cannot tell a caller input from a pool value. A frozenset is that knowledge, and applies it to the selector callable's reflected parameters:

  • a name in supplied is dropped from properties and required, marked or not -- the transport fills it, and the client cannot replace it;
  • so is every name in RESERVED_POOL_SEEDS (progress, data and the rest), without being listed, because client input can never take one: the pool's request, user and progress are filled over whatever a caller sends, and a selector's params never reach the others;
  • any other parameter with no default that can be passed by keyword joins required: nothing but the caller's input is left to fill it, and the call raises TypeError without it -- so task_by_pk(user, *, pk) advertises pk as required rather than as an option. A positional-only one is advertised and never required, because dispatch passes everything by keyword and no input can fill it;
  • a parameter with a default stays optional, InputRequired stays required, and NotClientInput and the request / user / view seeds stay unadvertised;
  • an expanded TypedDict key has no default to read, so a supplied or reserved one is dropped and any other keeps the requiredness its TypedDict declares.

It applies the same way to a spreading service's reflected parameters, and reaches nothing else. A ServiceSpec's input_serializer has fields rather than reflected parameters, and phase="output" reflects no parameters at all; both are the same with or without supplied. So is a filter_set field, which the filter reads from the caller's input. A transport reflecting a nested instance_selector_spec or collection_selector_spec passes that SelectorSpec itself, with the names it fills there.

argument_binding is the binding the transport dispatches a ServiceSpec with, and it decides one thing: whether a service with no input_serializer takes the caller's input as its own parameters. Under a binding that resolves to SPREAD_AUTHOR_WINS or SPREAD_CALLER_WINS dispatch declares those parameters as the input, so UnknownArguments.REJECT admits them, and the schema lists them, reflected as a selector's are: keyword-passable parameters and the keys of a **kwargs: Unpack[SomeExtras], less every key a callable in the call marks NotClientInput (the service, its preconditions and its target lookup), positional-only parameters, view, the request / user seeds and every name in RESERVED_POOL_SEEDS, with supplied deciding requiredness as it does for a selector. For the built-in seeds the property names are exactly the keys dispatch declares for the same spec and binding; a transport registering its own pool seeds passes them in supplied, or they are listed as input. A bare **kwargs lists what the service names and states no additionalProperties, although dispatch then admits every key; drfs closes an input schema only around a many list, and leaves closure to a transport's own policy. AUTO, the default, resolves to BUNDLE for a service, which spreads nothing, so a caller that does not pass it gets the schema it always had. A SelectorSpec's input, a many=True spec's, one with an input_serializer and phase="output" do not read it.

phase="output" returns the output schema, or None when undeclared: a ServiceSpec supplies its output_selector_spec's output_serializer and affordances, a SelectorSpec its own. A ServiceSpec's schema states the kind dispatch renders, which is not always the one declared: an array for many=True, the nested kind where the output_selector_spec has a selector to re-read through, and one value otherwise, because with nothing to re-read dispatch presents the service's own return whatever kind the nested spec names. Declared affordances add the affordances object each rendered item carries, reason included -- the shape render_spec_output produces. Where dispatch may present None, the item's type is ["object", "null"], as can_present_nothing answers it: an allow_none RETRIEVE SelectorSpec's miss, a single-row ServiceSpec whose output_selector_spec re-reads through a selector that may find no row, and a ServiceSpec declaring allow_none=True. A ServiceSpec's nested output_selector_spec.allow_none is not read, as dispatch does not read it.

max_depth bounds how many serializer levels are described, truncating deeper ones to {"type": "object"}; None, the default, describes them all. It reaches the serializer-backed schemas — a ServiceSpec's input and either spec's output — and has nothing to bound on a SelectorSpec's input, which is reflected from a callable and a filter_set rather than walked. A serializer that nests itself is truncated after a fixed number of appearances regardless, because the alternative is a RecursionError raised while a transport declares its tools; where the two disagree the tighter wins, so this still yields exactly the levels it names.

metadata["json_schema"] is the one declaration this merges on top. Derivation reads serializers and callables, so there is nowhere for it to find a title for the operation or a sentence saying what the operation does; every transport was left to invent its own, from the spec name or a docstring. A consumer writes the fragment once, on the spec:

ServiceSpec(
    service=archive_project,
    input_serializer=ArchiveInput,
    metadata={
        "json_schema": {
            "input": {"title": "Archive project", "description": "Retire a project."}
        }
    },
)

It is keyed by phase, with the same two words phase= takes. One flat fragment merged into both would hang the operation's description off the output schema, which describes what comes back rather than what to send — two different sentences that only ever coincide by accident. A key that is neither "input" nor "output" raises rather than being ignored, because omitting the phase key is the mistake this shape invites and silently publishing nothing is the worst way to report it.

The fragment wins, key by key, and the merge is shallow. It is an author's explicit declaration standing against a derived value, so a derivation it could not override would leave a wrong derivation unfixable — which is the whole reason the hatch exists. Shallow means one rule: a key the fragment names is the fragment's, whole. So a fragment naming properties replaces the entire derived block rather than adding to it, which is the sharp edge and is deliberate — the alternative is a per-key policy for properties and another for required, and every answer there is wrong for somebody.

A fragment annotates a derived schema and never conjures one: where phase="output" yields None because nothing declares an output, an "output" fragment leaves it None. Otherwise metadata would become a schema-authoring channel and a fragment carrying only a description would publish as an output schema describing nothing.

The fragment is read off the spec passed in, never off a nested one: metadata does not merge or inherit, so a ServiceSpec's output schema takes the ServiceSpec's fragment even though the serializer behind it came from output_selector_spec.

Validation happens here rather than at construction. metadata is declared by consumers who may never generate a schema, and checking a reserved key on every ServiceSpec(...) would mean the kernel reads metadata contents — the one thing the field promises it does not do.

A selector's input schema reflects the selector callable's own parameters — names plus a JSON type from each annotation, skipping the request / user / view transport seeds — merged with its filter_set fields. So a lookup selector like get_widget(user, pk) advertises pk instead of a bare {"type": "object"} that leans on the docstring alone. An un-annotated parameter is still surfaced by name (untyped {}); a filter_set field wins over a callable parameter of the same name.

What a transport supplies: supplied=

Read on its own, a selector's signature cannot say which parameters a caller sends and which the transport fills. task_by_pk(user, *, pk) and outstanding(user, *, currency) look alike, yet a client must send pk, while currency arrives from a pool seed. So by default every reflected parameter is optional unless it carries InputRequired. A lookup without a default is then advertised as optional, and a call without it is refused with {"pk": ["This field is required."]}. A seed is advertised as an input that the seed then overrides.

A transport describing its own tools does know which names it fills: its registered pool seeds, the names its kwargs= providers return, and the URL kwargs it resolves. It passes them as supplied:

spec_to_json_schema(spec, phase="input", supplied=frozenset({"currency", "tenant"}))

The seeds every transport reserves need no listing. A name in RESERVED_POOL_SEEDS (progress, data, instance and the rest) is one client input can never take: the pool's request, user and progress are filled over whatever a caller sends, and a selector's params never reach the others. So under supplied those names are dropped as if listed, and a transport lists only what it fills beyond them.

A frozenset, including an empty one, opts into the transport rule for the selector callable's reflected parameters:

Parameter Without supplied With supplied
name in supplied property, required only as its markers say dropped from properties and required, marked or not
no default, not supplied optional property required
no default, positional-only optional property optional property
has a default optional optional
InputRequired required required, unless supplied or reserved
NotClientInput, request / user / view not advertised not advertised
any other name in RESERVED_POOL_SEEDS, such as progress property, required only as its markers say dropped, as if supplied
**kwargs: Unpack[TypedDict] key as its TypedDict declares dropped if supplied or reserved, otherwise as its TypedDict declares

A positional-only parameter stays optional because dispatch binds the pool by keyword: no input a caller sends can fill one, so requiring it would ask for what cannot be passed.

A TypedDict key has no default to read, so its totality (Required / NotRequired) remains its declaration. None, the default, keeps the output byte-identical to what it was before supplied existed, so a reader that does not know what is filled for it (drfs' own capability manifest, for one) is unchanged.

supplied reaches reflected callable parameters and nothing else: a selector's, and a spreading service's (below). A ServiceSpec's input_serializer has fields rather than parameters, the output phase reflects no parameters, and a filter_set field is read from the caller's own params, so all three are the same with or without it. To describe a service tool's target lookup, reflect its instance_selector_spec or collection_selector_spec (a SelectorSpec) with the names the transport fills there.

A spreading service's own parameters: argument_binding=

A ServiceSpec's input schema is its input_serializer's. A service with no serializer has nothing but its own signature to read the caller's input, and whether that signature is caller input is the transport's call: dispatch declares the service's parameters, and UnknownArguments.REJECT admits them, only under ArgumentBinding.SPREAD_AUTHOR_WINS. The schema lists them when the transport passes the same binding:

def close_ticket(*, instance, reason: str, note: str = "", user) -> Ticket: ...


spec = ServiceSpec(service=close_ticket, instance_selector_spec=ticket_by_pk)

spec_to_json_schema(spec, argument_binding=ArgumentBinding.SPREAD_AUTHOR_WINS)
# {"type": "object",
#  "properties": {"reason": {"type": "string"}, "note": {"type": "string"}}}

They are reflected the way a selector's are, through the same function: keyword-passable parameters and the keys of a **kwargs: Unpack[TypedDict], with NotClientInput, positional-only parameters, view, and every name in RESERVED_POOL_SEEDS (instance, user and the rest) left out. A key that a precondition or the target lookup marks NotClientInput is left out too: it is server-owned for the whole call, so dispatch strips the caller's value before any callable reads the pool, and REJECT refuses it. For the built-in seeds the property names are exactly the keys dispatch declares for that spec and binding; a transport that registers its own pool seeds passes them in supplied=, or the schema advertises them as input. Without supplied only a marker requires a parameter, as for a selector, because a kwargs= provider may fill one; with it, a parameter with no default is required, and a supplied name is dropped.

Three things are not in it:

  • The target lookup's keys. The pk that ticket_by_pk reads belongs to the lookup, not the service. Transports merge it themselves, by reflecting the lookup's SelectorSpec, as they do beside an input_serializer.
  • Closure. A bare **kwargs never passes an undeclared key on (REJECT refuses it, or under SPREAD_AUTHOR_WINS dispatch drops it), and the schema lists the parameters the service names without additionalProperties. drfs states additionalProperties: false only around a many=True list, and a transport closes a tool's input, or not, from its own policy.
  • Anything under AUTO or BUNDLE. A caller that does not pass SPREAD_AUTHOR_WINS gets the schema it always had: {"type": "object"} with no serializer, and dispatch agrees, since under those bindings the service's own parameters are no caller input. A many=True spec, one with an input_serializer, a SelectorSpec and the output phase do not read it.

What an annotation publishes

The mapping from a Python annotation is structural, so a declaration a caller took the trouble to write survives into the published schema:

Annotation Schema
str / int / float / bool / None the matching JSON type
datetime / date / time / UUID / Decimal string with the format DRF's own field would use
Literal["open", "closed"], an Enum subclass {"enum": [...]} — by member value for an Enum
list[X], set[X] / frozenset[X] array (a set adds uniqueItems)
dict[str, X] object with additionalProperties
X \| None, Union[X, Y] {"anyOf": [...]}

Anything else — a domain class, a Callable, a bare Any, an annotation that could not be resolved — publishes as {}, which in JSON Schema means any value, so a caller cannot tell it from a value that genuinely is unconstrained. Register the types that matter to you rather than letting them publish as anything:

registry = DEFAULT_JSON_SCHEMA_REGISTRY.extend(
    python_types=[(Money, {"type": "string", "format": "money"})],
)

Rules are matched by type identity, and members are resolved recursively, so one rule for Money also covers list[Money] and Money | None.

What a reflected input can say about itself

A serializer field describes itself through help_text, and a filter through its own. A reflected input has neither — it is a TypedDict key or a bare parameter — so it carries the sentence in its Annotated metadata, with InputDescription:

class WidgetExtras(HttpExtras[MyUser], total=False):
    project_pk: Annotated[
        int, InputRequired, InputDescription("The project whose widgets to list.")
    ]

project_pk publishes as {"type": "integer", "description": "The project whose widgets to list."} — description being the same key the serializer and filter paths already fill, so one project's inputs read the same way whichever side of a spec they arrive on. The marker composes with InputRequired in either order and is ignored by anything else reading the same Annotated.

It is refused beside NotClientInput, which drops the key from the schema entirely and so leaves the sentence no caller to reach, and refused twice on one input, because a schema publishes one description and picking a winner would be an arbitrary rule to memorise. See the off-HTTP inputs recipe.

Where a marker may sit

InputRequired, NotClientInput and InputDescription are read on the outermost layer of an input's annotation, or inside one Optional written around it. These two declare the same nullable, hidden key, and both are read:

def team_tasks(*, team: Annotated[int | None, NotClientInput] = None): ...
def team_tasks(*, team: Annotated[int, NotClientInput] | None = None): ...

Optional[Annotated[int, NotClientInput]] is the second spelling too, and the same holds for an Unpack[TypedDict] key. The type keeps its null branch, so an InputRequired key spelled this way publishes as required and nullable.

Anything wider raises ImproperlyConfigured rather than guessing what the marker was meant to cover: a union with another member beside the marked one (Annotated[int, NotClientInput] | str), two marked members, or a marker nested inside a container or under another Annotated (list[Annotated[int, NotClientInput]]). The refusal names the spelling that works. Another library's Annotated metadata is not placed, and stays legal at any depth.

A view, viewset or @service_action mounting the spec raises it from as_view(), so the app fails at URL-conf load rather than on every request. Off HTTP, a spec no view mounts raises it where its markers are first read: at schema generation, or on each dispatch_spec call. The spec's constructor does not, because an annotation naming a class declared further down the module does not resolve yet when the spec is built.

What a service's output schema describes

A ServiceSpec's output schema reads the serializer and affordances off its output_selector_spec, and describes the shape dispatch renders:

  • many=True renders the whole list, so the schema is an array whatever the nested kind.
  • An output_selector_spec with a selector re-reads the service's result, and its kind decides: one row for RETRIEVE, an array for LIST.
  • Without a selector nothing is re-read, and dispatch presents the service's own return as the nested kind says: one value for RETRIEVE, and for LIST a list, which a list operation returning its rows declares:
ServiceSpec(
    service=publish_tasks,  # returns the tasks it published
    collection_selector_spec=SelectorSpec(kind=SelectorKind.LIST, selector=drafts),
    output_selector_spec=SelectorSpec(kind=SelectorKind.LIST, output_serializer=TaskOut),
)
# spec_to_json_schema(spec, phase="output")["type"] == "array"

This is the same shape the HTTP view renders and every transport reads off result.kind, and the capability manifest's output_schema states it too.

Under a LIST declaration with no selector, the service has to return the rows: a QuerySet, a list, a generator or another iterable. A mapping, a str or bytes, None, or anything that does not iterate is refused with ImproperlyConfigured naming the declaration and the type that came back, because it is the author's error rather than the caller's. The service has already run when it is raised, and its own atomic block has closed, so dispatch does not undo what it wrote. It is not a refusal: a caller must not read it as nothing having changed, or retry it as though the call had been turned away.

A spec-level title and description

Derivation reads serializers, filters and callables. None of them can tell it what the operation is called or what it does, so spec_to_json_schema used to emit no title and no description at all and every transport invented its own — from the spec name, from a docstring, from a hand-written table.

The reserved metadata["json_schema"] key is where that is declared once:

ServiceSpec(
    service=archive_project,
    input_serializer=ArchiveInput,
    output_selector_spec=SelectorSpec(kind=SelectorKind.RETRIEVE, output_serializer=ProjectOut),
    metadata={
        "json_schema": {
            "input": {
                "title": "Archive project",
                "description": "Retire a project without deleting its history.",
            },
            "output": {"description": "The project as it stands after archiving."},
        }
    },
)

Three decisions worth knowing:

  • It is keyed by phase, with the same two words phase= takes. One flat fragment merged into both would hang the operation's description off the output schema, which describes what comes back rather than what to send. A key that is neither "input" nor "output" raises — forgetting the phase key is the mistake this shape invites, and publishing nothing is the worst way to report it.
  • The fragment wins, key by key, and the merge is shallow. It is an explicit declaration standing against a derived value, so a derivation it could not override would leave a wrong derivation unfixable. Shallow means one rule instead of a per-key policy: a fragment naming properties replaces the whole derived block rather than adding to it.
  • It annotates a derived schema and never conjures one. Where phase="output" yields None because nothing declares an output, an "output" fragment leaves it None — otherwise metadata would quietly become a schema-authoring channel.

The fragment is read off the spec you pass in, never off a nested one: metadata does not merge or inherit, so a ServiceSpec's output schema takes the ServiceSpec's fragment even though the serializer behind it came from output_selector_spec. Malformed declarations raise here rather than at construction, so declaring metadata costs nothing on a spec that generates no schemas.

What generation can and cannot see

Both entry points instantiate the serializer with the same baseline context dispatch renders with — {"request": None, "format": None, "view": None} — so a serializer whose get_fields reads self.context["request"] is describable, not just callable. Before, description raised KeyError on a serializer the same spec rendered perfectly.

The view and request are None and cannot be otherwise: a schema is built once, when a transport declares its tools, and there is no request at that moment to describe. So a get_fields that branches on the view or the user is reflected as the branch taken by a caller with neither. Reflection cannot report a field set that depends on who is asking, because at description time nobody is — if your field set varies by audience, declare it with field markings instead, which are resolved per render.

How deep generation goes

A nested serializer is walked into, and two things stop the walk.

A serializer that nests itself is truncated after four appearances. A category tree, a threaded comment, an org chart — the shape is ordinary, and describing it unbounded is a RecursionError raised while a transport declares its tools, before any request exists to fail. So the guard is always on and has nothing to configure. It follows the current path, not everything seen: the same address serializer under billing and under shipping sits on two different paths and is described in full on both, however often either is walked.

The allowance is four rather than one because a serializer may bound its own nesting — the countdown recipe, where each level constructs the next with one less to spend:

class NodeSerializer(serializers.Serializer):
    name = serializers.CharField()

    def __init__(self, *args, depth=3, **kwargs):
        super().__init__(*args, **kwargs)
        if depth > 0:
            self.fields["children"] = NodeSerializer(depth=depth - 1, many=True)

That declaration terminates by itself and was never at risk of recursing, but class identity cannot tell it apart from the unbounded form. Truncating at the first re-entry published one level where four are declared. Four appearances — the root plus the three levels this recipe is usually written with — is what publishes it as declared; a declaration that nests itself deeper than that is still truncated, and so is any declaration with no bound of its own.

max_depth is an opt-in ceiling on size. A schema grows roughly threefold per nesting level and both agent transports rebuild every tool's schema each time they list, so a deep declaration is paid for on every listing. It counts serializer objects — the root is level 1, and the array wrapper many=True produces costs no level:

schema = output_to_json_schema(InvoiceSerializer, kind=SelectorKind.LIST, max_depth=2)

Left unset it describes every level, which is what generation has always done.

The two bounds are read together and the tighter one wins: max_depth=2 yields two levels of a self-referential serializer, allowance or no allowance. The allowance is a floor under what generation does when nobody asked for a bound — never a quota a caller has to spend.

A truncated node is {"type": "object"} — the one thing still known to be true, and what a caller can still send: an object whose keys the schema declines to enumerate. Never $defs / $ref. Factoring the repeated sub-schema out is the obvious answer and it is refused deliberately: most MCP clients reject a tool schema containing a reference outright, so it would trade a size problem for a compatibility one. Every schema these helpers emit is flat and self-contained.

A registry on the spec

A registry passed as registry= reaches one call. Declared on the spec as json_schema_registry=, it travels with the spec, to every helper that describes it and to the spec as it compiles for the kernel:

registry = DEFAULT_JSON_SCHEMA_REGISTRY.extend(
    fields=[(MoneyField, {"type": "string", "format": "money", "pattern": r"^\d+$"})],
)

charge = ServiceSpec(
    service=charge_card,
    input_serializer=ChargeSerializer,
    json_schema_registry=registry,
)

spec_to_json_schema(charge)  # reads the spec's registry
spec_to_json_schema(charge, registry=DEFAULT_JSON_SCHEMA_REGISTRY)  # no rules

A registry= passed to a helper wins over the spec's, the empty default included, so a caller can still describe a spec without its rules. A spec that declares none reads the empty default, as it always did.

A nested spec reads its own, else the outer spec's. A service spec's target lookup and output selector spec each read their own registry, and the service spec's where they declare none. An affordance is a spec of its own and reads only its own.

In the compile, the keywords the kernel can state become checks. fields rules are read where the input and output serializers are walked, and python_types rules where a callable's annotations are. A keyword a kernel Parameter or OutputField spells sets that field, and is then enforced off HTTP as a built-in field's would be: type, format, enum, a bare items, and on an input the bounds (minimum, maximum, minItems, maxItems) and description, on an output title. Every other keyword, pattern above among them, is advertised in the compiled schema and not enforced there. Over HTTP DRF's serializer validates alone, so there a registered bound is a description and nothing more: a field whose rule states a range it does not check itself accepts a value outside it on the view and is refused off HTTP. Make the field, or the serializer, check what its rule states, and every route answers the same.

What the kernel cannot state is dropped from the compiled spec, never refused. A format outside the kernel's four (decimal, date-time, date, uuid) goes, so format: "money" above is in every schema drfs' helpers build and absent from the compiled one. So does a keyword the kernel would refuse for the field's type, such as a format with no type or a minimum on a string, and a value that is not JSON. The kernel decides by refusing, so a registry spec_to_json_schema accepts never stops a spec compiling, and drfs' own helpers keep describing the whole fragment.

filters rules describe drfs' schema alone. The kernel builds a filter_set's parameters itself, so a filters rule never reaches the compiled spec.

A python_types rule on a spec's registry types its reflected reads. A selector's, a target lookup's and a spread service's annotations are matched by identity, and a read a rule types is then checked as an annotation is. A class no rule names stays untyped, so this is opt-in: it starts when a spec declares the field.

A dataclass input is the one place the two routes read different rules: drfs' helpers match its annotations against python_types, while the compile walks the serializer built from it and matches its fields against fields, so a rule meant for both is registered twice, on the type and on the field class it is read through (a DecimalField for a Decimal).

JsonSchemaRegistry

JsonSchemaRegistry dataclass

Consumer-extensible type → JSON Schema fragment rules for the helpers.

Rules are tried in order and the first match wins, before the built-in mappings — so a rule both adds support for a custom type and can override a built-in. The matched fragment is copied per use, so callers may freely mutate the returned schema.

Immutable: extend returns a new registry rather than mutating, so there is no shared global state to leak across callers or tests. Start from DEFAULT_JSON_SCHEMA_REGISTRY, the empty base every *_to_json_schema helper falls back to, layer rules on, and pass the result via the helpers' registry= argument:

registry = DEFAULT_JSON_SCHEMA_REGISTRY.extend(
    fields=[(MoneyField, {"type": "string", "format": "money"})],
)
schema = serializer_to_json_schema(MySerializer, registry=registry)

That fragment describes every MoneyField as a string of format: "money" in the schemas drfs' own helpers build. Declared on a spec as json_schema_registry=, the same rule also reaches the spec as it compiles for the kernel, where only what the kernel can state is kept: the field is a string there and format: "money", which is none of the kernel's four formats, is dropped (test_the_compiled_schema_agrees_with_drfs_but_for_the_dropped_format).

Attributes:

Name Type Description
fields tuple[_Rule, ...]

DRF Field subclasses, matched by isinstance when walking a serializer (serializer_to_json_schema, and the input side of spec_to_json_schema).

filters tuple[_Rule, ...]

django-filter Filter subclasses, matched by isinstance when walking a FilterSet (filterset_to_json_schema).

python_types tuple[_Rule, ...]

Bare Python types, matched by identity when walking a dataclass's field annotations, and, from a spec's json_schema_registry, a selector's, target lookup's or spread service's annotations as the spec compiles.

extend

extend(
    *,
    fields: Sequence[_Rule] = (),
    filters: Sequence[_Rule] = (),
    python_types: Sequence[_Rule] = (),
) -> JsonSchemaRegistry

Return a new registry with the given rules prepended (they win first).

DEFAULT_JSON_SCHEMA_REGISTRY

DEFAULT_JSON_SCHEMA_REGISTRY module-attribute

DEFAULT_JSON_SCHEMA_REGISTRY: JsonSchemaRegistry = JsonSchemaRegistry()