Skip to content

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 ServiceSpec (write) or SelectorSpec (read), or an object with as_kernel_spec() compiling to one. The object is kept as registered, and compiled here, once: a declaration that does not compile, or compiles to an unguarded spec, fails at registration.

required
tags Iterable[str]

Free-form labels, deduplicated into a frozen set.

()

Raises:

Type Description
ValueError

name is already registered here.

TypeError

spec is neither a ServiceSpec nor a SelectorSpec, nor an object compiling to one.

ImproperlyConfigured

the spec, or what it compiles to, has permissions is None. Off HTTP there is no view whose policy an undeclared spec could inherit, so registering one that means to be open must say so with permissions=[Unrestricted()]; the alternative is a spec that dispatch would refuse on every call.

get

get(name: str) -> RegisteredSpec | None

Return the entry registered under name, or None.

all

all() -> tuple[RegisteredSpec, ...]

Every entry, in registration order, so a transport's listing is stable.

mutations

mutations() -> tuple[RegisteredSpec, ...]

The ServiceSpec entries, in registration order.

queries

queries() -> tuple[RegisteredSpec, ...]

The SelectorSpec entries, in registration order.

by_tag

by_tag(*tags: str) -> SpecRegistry

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

subset(*names: str) -> SpecRegistry

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

merge(*others: SpecRegistry) -> SpecRegistry

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

replace(name: str, spec: ServiceSpec | SelectorSpec | SpecSource) -> SpecRegistry

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

name is not registered here - a replacement for an operation nobody registered is a typo, not a registration.

TypeError

spec is neither a ServiceSpec nor a SelectorSpec, nor an object compiling to one.

ImproperlyConfigured

spec, or what it compiles to, has permissions is None.

specs

specs() -> dict[str, ServiceSpec | SelectorSpec | SpecSource]

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 - a ServiceSpec (a write) or a SelectorSpec (a read), or a SpecSource that compiles to one, kept as registered: a transport reads the spec it runs through kernel_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 by isinstance on 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

agent_contract: Any

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 a ServiceSpec, "query" for a SelectorSpec, derived by type exactly as the registry derives it.
  • tags -- sorted, so two manifests of one registry are byte-identical.
  • idempotent -- ServiceSpec.idempotent as declared, None included: silence is not a claim, and a reader must be able to tell it from a declared False. Always None on a query, which has no such declaration -- its kind already says it changes nothing.
  • input_schema / output_schema -- exactly what spec_to_json_schema derives for each phase, so the document carries this package's schema dialect rather than a second one; output_schema is None where no output is declared. A single-row output_schema admits null where can_present_nothing says dispatch may present None. dialect names that policy at the top level.
  • guards -- one {"source": <dotted class path>} per permission class, in declaration order. Always a list: a SpecRegistry refuses a spec that declares none, so [] is a declared "no permissions".
  • affordances -- one {"code": ..., "reason": ..., "scope": ...} per Affordance a mutation declares, in declaration order: the codes a call can be refused with, and the sentence each refusal says. scope is "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. None where nothing is declared, and always on a query. The code here is the same code a rendered object's affordances answer carries and a 409 body names, and the reason is 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 spec_to_json_schema, so the manifest and the transport describe a custom field identically.

DEFAULT_JSON_SCHEMA_REGISTRY

Raises:

Type Description
TypeError

registry is not a SpecRegistry. A bare name -> spec mapping has no tags, so every operation would be reported untagged -- a manifest that is quietly smaller than the one a registry produces.

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

field_audiences: Mapping[str, FieldMarking] | None = None

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.