Spec registry¶
A named, taggable home for a project's spec set, so each transport reads one source instead of enumerating the same specs again. Every symbol here is importable from the top-level package.
For the task-shaped walkthrough — where the instance lives, multiple registries, filtered views — see the recipe Declare specs once, project them to many transports.
SpecRegistry¶
SpecRegistry ¶
A name -> spec map with tags, stable ordering, and filtered views.
One declaration site for the operations a project exposes over more than one transport, so the transports read one source instead of each enumerating specs and drifting. It holds only the invariant part of an operation - which spec, its canonical name, its tags; per-transport configuration stays at the binding that owns it. Adapters read a registry at configuration time to build their binding tables; nothing consults one per dispatch.
registry = SpecRegistry()
registry.register("list_orders", orders_spec, tags=("read", "public"))
registry.register("refund_order", refund_spec, tags=("write", "admin"))
public = registry.by_tag("public") # a new registry - a snapshot
There is no global registry: a consumer holds as many of its own
instances as it likes, with no shared state between them. Registries are
mutable - register adds - but every derivation (by_tag,
subset, merge, replace) returns a new registry holding a
snapshot of the selected entries, sharing the spec objects rather than
copying them.
A derived view never mutates its source, and a later register() on the
source does not appear in a view derived earlier.
register ¶
register(
name: str,
spec: ServiceSpec | SelectorSpec | SpecSource,
*,
tags: Iterable[str] = (),
) -> None
Add a spec under name.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
The canonical name. Must be unused in this registry - a duplicate raises rather than overwriting, so a copy-pasted declaration fails at import time instead of silently shadowing an operation. |
required |
spec
|
ServiceSpec | SelectorSpec | SpecSource
|
A |
required |
tags
|
Iterable[str]
|
Free-form labels, deduplicated into a frozen set. |
()
|
Raises:
| Type | Description |
|---|---|
ValueError
|
|
TypeError
|
|
ImproperlyConfigured
|
the spec, or what it compiles to, has
|
all ¶
Every entry, in registration order, so a transport's listing is stable.
mutations ¶
The ServiceSpec entries, in registration order.
by_tag ¶
A new registry holding the entries carrying any of tags.
Union, not intersection - chain calls for an intersection
(reg.by_tag("read").by_tag("public")). No tags matches nothing.
subset ¶
A new registry holding the named entries, in the order given.
Raises:
| Type | Description |
|---|---|
KeyError
|
A name is not registered here - a typo is a configuration error, not a quietly smaller surface. |
merge ¶
A new registry combining this one with others, in order.
Names are unique per registry, so independent registries may reuse one; merging is the single place that reuse becomes a conflict.
Raises:
| Type | Description |
|---|---|
ValueError
|
The same name appears in more than one input. |
replace ¶
A new registry with spec under name in place of the spec registered there.
The entry keeps its tags and its place in the order, and every other
entry is shared, as in any view. The usual replacement is the
registered spec's own derive(...): a variant shaped for one
transport goes to the registry that transport is handed, while the
source keeps serving the base. The replacement is checked as
register checks a spec.
Raises:
| Type | Description |
|---|---|
KeyError
|
|
TypeError
|
|
ImproperlyConfigured
|
|
specs ¶
A fresh name -> spec dict - the shape adapters already accept.
Each spec as registered, so an object with as_kernel_spec() is
handed back as itself; every entry point here takes it as it is.
Pass registry.specs() wherever a dict[str, spec] goes today.
Mutating the returned dict does not affect the registry.
RegisteredSpec¶
RegisteredSpec
dataclass
¶
A spec under its canonical name, with free-form tags.
The value type held by
SpecRegistry. It
carries only the part of an operation that is invariant across
transports - which spec, what it is called, and how it is grouped.
Per-transport configuration (an HTTP view's URL kwargs, an MCP tool's
annotations, an agent adapter's own metadata) stays at the binding that
configures it, not here.
Fields:
name- the canonical name for this operation. Unique within the registry that holds it; two independent registries are separate namespaces and may reuse a name.spec- aServiceSpec(a write) or aSelectorSpec(a read), or aSpecSourcethat compiles to one, kept as registered: a transport reads the spec it runs throughkernel_spec, and the object itself is still there to be asked what only it knows. The kind is deliberately not stored as its own field: it is derived byisinstanceon the compiled spec wherever it is needed, so a stored discriminator can never drift from the object it describes.tags- a frozen set of free-form labels used to derive filtered views (registry.by_tag("public")). Tags carry boolean-ish facts -"read","admin","destructive"- that every transport can interpret in its own vocabulary.
agent_contract
property
¶
The registered object's own agent_contract(), or None when it has none.
Read-only, and never a field: a declaration lives on the spec, never
on the registry entry, so nothing is passed here and the entry only
asks. A ServiceSpec or SelectorSpec has none and answers
None; an object registered from djangorestframework-services
answers the contract its current release builds from what the spec
declares, for the transports published against that release. Typed
Any because the value is that object's, of a type this package
does not name. Asked on every read, so it is never stale.
capability_manifest¶
capability_manifest ¶
capability_manifest(
registry: SpecRegistry,
*,
schema_registry: JsonSchemaRegistry = DEFAULT_JSON_SCHEMA_REGISTRY,
) -> dict[str, Any]
Describe every operation in registry: what exists and what shape it takes.
The surface a transport already derives piecemeal -- an MCP tool list, an agent toolset's descriptions, a frontend's action menu -- as one JSON-native document, derived from the registry rather than written by hand:
{
"version": 1,
"dialect": "rest_framework_services.jsonschema/1",
"operations": [
{
"name": "refund_order",
"kind": "mutation",
"tags": ["admin", "write"],
"idempotent": True,
"input_schema": {"type": "object", "properties": {...}},
"output_schema": {"type": "object", "properties": {...}},
"guards": [{"source": "orders.permissions.IsSupport"}],
"affordances": [
{
"code": "order_shipped",
"reason": "A shipped order cannot be cancelled.",
"scope": "row",
}
],
},
...
],
"unguarded": [],
}
It names no principal and decides nothing, deliberately. It is the same for every caller, so it says which guards an operation carries and never what they grant: a permission class is code, and nothing short of running it against a principal answers that. A reader that understands a declarative policy can layer a decision onto each operation; this document is what such a reader starts from. For the same reason it is not a document to serve to a client -- it enumerates operations a given caller may be unable to see at all.
Keys, per operation:
name-- the registry name, which is the operation's identity across transports.kind--"mutation"for aServiceSpec,"query"for aSelectorSpec, derived by type exactly as the registry derives it.tags-- sorted, so two manifests of one registry are byte-identical.idempotent--ServiceSpec.idempotentas declared,Noneincluded: silence is not a claim, and a reader must be able to tell it from a declaredFalse. AlwaysNoneon a query, which has no such declaration -- itskindalready says it changes nothing.input_schema/output_schema-- exactly whatspec_to_json_schemaderives for each phase, so the document carries this package's schema dialect rather than a second one;output_schemaisNonewhere no output is declared. A single-rowoutput_schemaadmitsnullwherecan_present_nothingsays dispatch may presentNone.dialectnames that policy at the top level.guards-- one{"source": <dotted class path>}per permission class, in declaration order. Always a list: aSpecRegistryrefuses a spec that declares none, so[]is a declared "no permissions".affordances-- one{"code": ..., "reason": ..., "scope": ...}perAffordancea mutation declares, in declaration order: the codes a call can be refused with, and the sentence each refusal says.scopeis"row"for a condition on the row, which is answered per object, and"operation"for a callable one, which is answered without a row and so can decide whether the operation is offered at all.Nonewhere nothing is declared, and always on a query. Thecodehere is the samecodea rendered object'saffordancesanswer carries and a 409 body names, and thereasonis the same declared sentence those carry beside it, so a model reading the manifest learns what each refusal will say before it meets one. Both are declarations about the operation, never a row's state.
unguarded is unguarded_specs over the same registry: the operations
whose permission_classes is None, which have nothing to inherit off
HTTP. A SpecRegistry refuses such a spec when it is registered, so on a
registry that holds only what it accepted the list is empty; it stays for
the readers that expect the key.
Pure: it reads declarations and never the database, so building it at startup, in a management command, or in a test costs the schema walk and nothing else.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
registry
|
SpecRegistry
|
The operations to describe, in registration order. |
required |
schema_registry
|
JsonSchemaRegistry
|
Consumer rules for custom field / filter / Python types,
forwarded to the schema derivation -- the same object a transport
passes to |
DEFAULT_JSON_SCHEMA_REGISTRY
|
Raises:
| Type | Description |
|---|---|
TypeError
|
|
OfflineContract¶
OfflineContract
dataclass
¶
The declarations an off-HTTP caller needs and an HTTP one never does.
Over HTTP this is all free. A nested route's captures reach a spec
through view.kwargs because the URLconf declared them; read-shaping
params reach a serializer through request.query_params because the query
string carried them. A spec mounted on a view is already complete.
Off HTTP there is no route and no query string, so somebody has to say what they would have contained. That is what this is: not a missing part of the operation, but a description of the request that is not there.
UrlKwarg("project_pk") means "when there is no URL, synthesise this view
kwarg from a caller-supplied argument" -- a sentence with no meaning on a
transport that has a URL. Which is exactly why it does not belong on the spec
itself: the spec would carry a field HTTP must ignore, and a second
declaration of a fact the URLconf already owns, pointing the other way.
It belongs to the entry rather than to any one transport because every off-HTTP transport needs the identical answer. MCP and an in-process Pydantic-AI toolset synthesise the same absent request for the same operation; a project running both used to declare it twice, in two shapes, with nothing comparing them.
Deliberately not a home for bounds or strictness -- result-size caps, page ceilings, timeouts, unknown-argument policy. Those legitimately differ between a publicly exposed server and an in-process toolset, and one shared number would be a regression rather than a simplification. This carries only what cannot differ.
Sorting is absent for the same reason from the other direction: it is
declared by the spec's own filter_set, which is already read by every
transport, so it needs no second home.
Transports read this as a default. A mount may still override it, and one that says nothing inherits it.
field_audiences
class-attribute
instance-attribute
¶
Per-tool overrides layered over the output serializer's own markings.
Here rather than at a mount because
FieldAudience
already settles the question: the axis is audience, not protocol -- an MCP
server and an in-process toolset want the same thing as each other, and
something different from a browser. If the two are one audience, an override
of what that audience sees cannot legitimately differ between them.
It also belongs at the same level as the thing it overrides. The baseline is
the serializer's own FieldMarking markings, which are spec-level and read
by every transport; declaring the baseline once and the override per mount
is what let one spec project a different field set depending on which
transport served it -- a field hidden for one agent audience left visible to
the other, with nothing comparing them.