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,
) -> 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. 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.
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.
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 = DEFAULT_JSON_SCHEMA_REGISTRY,
max_depth: int | None = None,
) -> 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.
phase="input" (default) returns the input-argument schema:
ServiceSpec→ itsinput_serializer(spec.partialhonoured).SelectorSpec→ an object whosepropertiescombine the selector callable's own annotated parameters (skipping therequest/user/viewtransport seeds) with itsfilter_setfields, soget_widget(user, pk)advertisespkinstead of leaning on its docstring; a bare{"type": "object"}when it exposes neither. A**kwargs: Unpack[SomeExtras]parameter is expanded into one property perTypedDictkey, its required keys populatingrequired, so a URL kwarg read fromextrasis discoverable off-HTTP rather than a hiddenKeyError. Introspecting afilter_setneeds the[filter]extra.
phase="output" returns the output schema, or None when undeclared: a
ServiceSpec supplies its
output_selector_spec's output_serializer and kind, a
SelectorSpec its own.
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 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.
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
propertiesreplaces the whole derived block rather than adding to it. - It annotates a derived schema and never conjures one. Where
phase="output"yieldsNonebecause nothing declares an output, an"output"fragment leaves itNone— otherwisemetadatawould 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:
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.
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)
Attributes:
| Name | Type | Description |
|---|---|---|
fields |
tuple[_Rule, ...]
|
DRF |
filters |
tuple[_Rule, ...]
|
|
python_types |
tuple[_Rule, ...]
|
Bare Python types, matched by identity when walking a dataclass's field annotations. |
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).