Siren hypermedia and OpenAPI integration for Modwire.
Project description
modwire-siren
Typed Siren documents projected from OpenAPI without application-owned route maps.
Every external boundary is behind a package interface: catalog, href resolution, field policy,
field creation, link creation, resource hrefs, and serialization. ModwireSirenFactory is the
standard composition root; ModwireSiren is the small public façade.
Contents
- What Siren is
- Install
- What this package adds
- Approved UI profile
- Following advertised controls
- Public API
- OpenAPI contract
- Collection projection
- Django Ninja Extra
- Development and release
Install
pip install modwire-siren
The package has no runtime Django or Ninja Extra dependency. Framework integrations expose framework-light payloads and decorators; the host application decides how those payloads are mapped onto concrete HTTP response classes.
What Siren is
Siren is a hypermedia specification for representing an
entity together with the controls a client can use next. A JSON Siren document uses the media type
application/vnd.siren+json and can contain:
properties: the entity's data;entities: related entities embedded in the representation;links: navigational controls identified by link relations; andactions: named state transitions, including the target, HTTP method, media type, and input fields.
This makes a Siren response more than a JSON snapshot. A client can discover available transitions from the response instead of reconstructing URLs or duplicating server-side routing rules. Siren is a community specification, not an IETF RFC. Its normative project specification is the Siren specification; link relation semantics come from RFC 8288, with standard relation names listed in the IANA Link Relations registry.
What this package adds
OpenAPI describes the API surface; Siren describes the controls available in a particular response.
modwire-siren joins the two: it reads routes, operations, request schemas, and explicit
x-siren-resource metadata from one OpenAPI document, then projects runtime values into typed Siren
entity and collection documents. Applications therefore do not need to maintain a second route map
for Siren links and actions.
The package intentionally does not decide authorization. Callers pass, or policy hooks select, the operation IDs that are legal for the current request and resource; only those operations become actions. Core projection stays framework-neutral. The Django Ninja Extra integration adds framework-light response payloads with the Siren media type, problem JSON media type, status code, headers, and 204 handling ready for an application adapter to map onto the framework response.
Approved UI profile
The owner-approved Modwire Siren UI Profile defines a Siren-native, framework-independent vocabulary for nested interfaces, action forms, semantic component selection, and predictable state transitions. This package implements its producer, validation, discovery, and enhancement contract. The packaged JSON Schema is the only vocabulary and type authority. Python applies its progressive defaults to plain JSON data and rejects dangling properties, relations, actions, fields, and regions through one structured validation error contract.
The supported Python profile revision is 1.0-draft, identified by
https://raw.githubusercontent.com/modwire/modwire-siren/main/docs/siren-ui-profile/README.md. Profiled responses advertise both the required
profile link and the media type parameter. SirenProfile.media_type exposes the exact response
Content-Type for a framework adapter.
OpenAPI resources opt in with x-siren-ui-profile. The extension contains the complete schema-valid
metadata document. Runtime operation_ids remain the authorization authority: unavailable actions
and their presentation metadata are removed together before reference validation and serialization.
paths:
/records/{record_slug}:
x-siren-resource:
name: record
class: record
identifier: slug
path-parameters: {record_slug: slug}
relations: {}
x-siren-ui-profile:
profile: https://raw.githubusercontent.com/modwire/modwire-siren/main/docs/siren-ui-profile/README.md
presentation: {role: detail}
properties:
title: {label: Title, importance: primary}
actions:
revise_record:
intent: primary
result: {mode: replace, relations: [self], optimistic: false}
patch:
operationId: revise_record
summary: Revise record
The modwire_siren.profile package exposes one profile class.
It also enhances manually assembled Siren dictionaries, so OpenAPI is an integration path rather
than a requirement:
from modwire_siren.profile import SirenProfile
profile = SirenProfile()
metadata = profile.discover(document)
Following advertised controls
SirenClient consumes links and actions without reconstructing server routes. The caller owns an
async SirenTransport and its lifecycle, so the package remains independent of HTTP libraries:
from modwire_siren import SirenClient
client = SirenClient("https://api.example.com/", transport)
root = await client.root()
records = await client.follow(root, "records")
record = await client.collection_item(records, "architecture")
updated = await client.execute(record, "revise_record", {"title": "Architecture"})
Relative targets are resolved against the configured root and must remain on the same origin.
Missing or malformed affordances raise SirenClientError; non-success responses preserve their
status and complete problem document.
Release status
The current package surface covers the release-critical Siren producer workflow:
- entity and collection projection from OpenAPI operation/resource metadata;
- offset and custom collection pagination links;
- typed
x-siren-resourcedeclarations, injection, and validation; - resource-owned sub-actions for operations below the resource path;
- dynamic operation and related-link policy hooks; and
- Django Ninja Extra response payloads for Siren, problem JSON, and 204 responses.
Remaining ecosystem work is intentionally outside this release surface: publishing the resource and profile schemas at stable public URLs, adding editor integrations, and adding concrete adapters for more web frameworks.
Public API
The supported root imports below are generated from modwire_siren.__all__.
| Symbol | Purpose | Primary API |
|---|---|---|
CustomPagination |
Use application-provided collection pagination links. | — |
ModwireSiren |
Project validated entity requests into serialized Siren documents. | document(request: modwire_siren.contracts.entity.SirenEntityRequest) -> dict[str, typing.Any]collection(request: modwire_siren.contracts.collection.SirenCollectionRequest) -> dict[str, typing.Any] |
ModwireSirenFactory |
Build the standard OpenAPI-backed Siren façade. | standard(schema: dict[str, typing.Any], base_url: str) -> modwire_siren.facade.ModwireSiren |
NinjaExtraSirenController |
Framework-light base for Ninja Extra controllers that emit Siren documents. | siren_document(resource_name: str, properties: collections.abc.Mapping[str, typing.Any], operation_ids: tuple[str, ...], path_values: collections.abc.Mapping[str, typing.Any], entities: tuple[modwire_siren.contracts.entity.SirenEmbeddedEntity, ...] = (), related_links: tuple[modwire_siren.contracts.related_link.RelatedLinkInput, ...] = ()) -> dict[str, typing.Any]siren_response(resource_name: str, properties: collections.abc.Mapping[str, typing.Any], *, operation_ids: tuple[str, ...], path_values: collections.abc.Mapping[str, typing.Any] = {}, entities: tuple[modwire_siren.contracts.entity.SirenEmbeddedEntity, ...] = (), related_links: tuple[modwire_siren.contracts.related_link.RelatedLinkInput, ...] = (), status_code: int = 200, headers: collections.abc.Mapping[str, str] = {}) -> modwire_siren.integrations.ninja_extra.response.NinjaExtraSirenResponsesiren_collection_response(request: modwire_siren.contracts.collection.SirenCollectionRequest, *, status_code: int = 200, headers: collections.abc.Mapping[str, str] = {}) -> modwire_siren.integrations.ninja_extra.response.NinjaExtraSirenResponsesiren_responses: <class 'modwire_siren.integrations.ninja_extra.adapter.NinjaExtraSirenResponseAdapter'> |
NinjaExtraSirenResponse |
Framework-light response payload for Ninja Extra adapters. | — |
NinjaExtraSirenResponseAdapter |
Build framework-light response payloads for Ninja Extra controllers. | entity(resource_name: str, properties: collections.abc.Mapping[str, typing.Any], *, operations: tuple[str, ...], path_values: collections.abc.Mapping[str, typing.Any] = {}, entities: tuple[modwire_siren.contracts.entity.SirenEmbeddedEntity, ...] = (), related_links: tuple[modwire_siren.contracts.related_link.RelatedLinkInput, ...] = (), status_code: int = 200, headers: collections.abc.Mapping[str, str] = {}) -> modwire_siren.integrations.ninja_extra.response.NinjaExtraSirenResponsecollection(request: modwire_siren.contracts.collection.SirenCollectionRequest, *, status_code: int = 200, headers: collections.abc.Mapping[str, str] = {}) -> modwire_siren.integrations.ninja_extra.response.NinjaExtraSirenResponseproblem(problem: collections.abc.Mapping[str, typing.Any], *, status_code: int, headers: collections.abc.Mapping[str, str] = {}) -> modwire_siren.integrations.ninja_extra.response.NinjaExtraSirenResponseno_content(*, headers: collections.abc.Mapping[str, str] = {}) -> modwire_siren.integrations.ninja_extra.response.NinjaExtraSirenResponse |
OffsetPagination |
Create standard offset pagination links for a collection. | — |
OpenApiError |
Report invalid or incomplete OpenAPI data used for Siren projection. | — |
PaginationLinkInput |
Describe one collection pagination link relative to the collection path. | — |
RelatedLinkInput |
Describe one application-owned related link for Siren projection. | — |
SirenClient |
Navigate Siren relations and execute only advertised actions. | root() -> dict[str, Any]follow(document: Mapping[str, Any], relation: str) -> dict[str, Any]`execute(document: Mapping[str, Any], action_name: str, payload: Mapping[str, Any] |
SirenClientError |
Report navigation, affordance, transport, and remote problem failures. | as_dict() -> dict[str, Any]problem(status_code: int, document: Mapping[str, Any]) -> SirenClientError |
SirenCollectionRequest |
Describe resource items and controls projected into one Siren collection. | — |
SirenEntityDecorator |
Turn a controller method's property mapping into a Siren entity document. | — |
SirenEntityRequest |
Describe the resource data and allowed operations projected into one entity. | — |
SirenRelationSpec |
Declare one relation in an x-siren-resource extension. | — |
SirenResourceSpec |
Declare one x-siren-resource extension for an OpenAPI path template. | — |
SirenResponse |
Carry one transport response without coupling Siren to an HTTP library. | — |
SirenTransport |
Execute Siren requests for a client-owned transport lifecycle. | `request(method: str, href: str, payload: Mapping[str, Any] |
__version__ |
Installed distribution version. | — |
collect_siren_resources |
Collect Siren resource declarations attached to controller classes. | — |
inject_siren_resources |
Attach typed Siren resource declarations to an OpenAPI schema copy. | — |
siren_collection |
Turn a controller method's item mappings into a Siren collection response payload. | — |
siren_entity |
Turn a controller method's property mapping into a Siren response payload. | — |
siren_resource |
Attach a typed Siren resource declaration to a Ninja Extra controller class. | — |
validate_siren_resources |
Validate Siren resource metadata with the standard OpenAPI catalog. | — |
Executable example
Source: build_document.py. This file is executed by the test suite.
from modwire_siren import ModwireSirenFactory, SirenEntityRequest
openapi_schema = {
"openapi": "3.1.0",
"paths": {
"/records/{record_slug}": {
"x-siren-resource": {
"name": "record",
"class": "record",
"identifier": "slug",
"path-parameters": {"record_slug": "slug"},
"relations": {},
},
"x-siren-ui-profile": {
"profile": "https://raw.githubusercontent.com/modwire/modwire-siren/main/docs/siren-ui-profile/README.md",
"presentation": {"role": "detail", "label": "Architecture record"},
"properties": {
"title": {"label": "Title", "importance": "primary"},
},
},
"get": {"operationId": "get_record", "summary": "Get record"},
}
},
}
siren = ModwireSirenFactory.standard(openapi_schema, "https://api.example.com/")
document = siren.document(
SirenEntityRequest(
resource_name="record",
properties={"slug": "architecture/aggregate", "title": "Architecture"},
operation_ids=("get_record",),
path_values={},
entities=(),
)
)
OpenAPI contract
paths:
/records/{record_slug}:
x-siren-resource:
name: record
class: record
identifier: slug
path-parameters:
record_slug: slug
relations:
section_slug:
rel: section
resource: section
many: false
operations:
- preview_record
patch:
operationId: revise_record
summary: Revise record
/records/{record_slug}/preview:
post:
operationId: preview_record
summary: Preview record
The strict OpenApiResourceExtension validates the extension. Unknown resources, incomplete path
mappings, absent operation IDs, and unknown schema references fail while building the catalog.
Applications can declare the same metadata without hand-writing extension dictionaries:
from modwire_siren import SirenRelationSpec, SirenResourceSpec, inject_siren_resources, validate_siren_resources
schema = inject_siren_resources(
schema,
(
SirenResourceSpec(
name="record",
path="/records/{record_slug}",
resource_class="record",
identifier="slug",
path_parameters={"record_slug": "slug"},
relations={
"section_slug": SirenRelationSpec(rel="section", resource="section", many=False),
},
),
),
)
validate_siren_resources(schema, ("record",))
Resource spec paths use OpenAPI template syntax such as /records/{record_slug}. Framework route
converter syntax such as /{path:record_slug} is rejected before injection. The base URL still
belongs to ModwireSirenFactory.standard(schema, base_url); resource declarations describe schema
paths only and do not carry deployment URLs.
Resource-owned operations allow sub-actions below the resource path. Operations listed in
x-siren-resource.operations may be advertised for that resource even when their OpenAPI path is a
child path such as /records/{record_slug}/preview. Other foreign-path operations remain rejected.
Owned sub-actions must not introduce extra path placeholders beyond the resource path; projection
has only the resource path values available when it builds entity actions.
Identifiers may be named id, slug, or another field, but the identifier must be one of the
fields mapped by path-parameters. Route path_values can supply values not present in returned
properties. Path-like identifiers are URL encoded during href creation, so
architecture/aggregate becomes architecture%2Faggregate.
The Pydantic Siren contracts own wire aliases such as class, type, and schema.
PydanticSirenSerializer implements the SirenSerializer interface with one model dump; it does
not redeclare the wire schema.
Collection projection
Collections use a separate request type but keep OpenAPI as the route and action source of truth:
from modwire_siren import ModwireSirenFactory, OffsetPagination, SirenCollectionRequest
siren = ModwireSirenFactory.standard(schema, "https://api.example.com/")
document = siren.collection(
SirenCollectionRequest(
resource_name="record",
items=(
{"slug": "architecture", "title": "Architecture"},
{"slug": "billing", "title": "Billing"},
),
collection_operation_ids=("list_records", "create_record"),
item_operation_ids=("get_record",),
path_values={},
pagination=OffsetPagination(limit=50, offset=0, count=2, has_next=False),
)
)
The resulting Siren document has class: ["collection", "record"], properties.count, embedded
item entities with rel: ["item"], collection actions from the supplied collection operation IDs,
and item actions from the supplied item operation IDs. Offset pagination emits self, first,
previous when applicable, and next when has_next is true. CustomPagination lets
applications provide package-owned pagination link inputs while still requiring an explicit self
link. Collection links are built from the collection operation path plus explicit pagination link
inputs; if a filtered collection needs query parameters such as status=active, include them in
the pagination inputs so they are preserved in advertised links.
Django Ninja Extra
The controller adapter does not import Django or Ninja Extra, so the core package keeps no framework dependency. It composes directly with Ninja Extra's controller and route decorators:
from ninja_extra import ControllerBase, api_controller, route
from modwire_siren import (
ModwireSiren,
NinjaExtraSirenController,
RelatedLinkInput,
collect_siren_resources,
inject_siren_resources,
siren_collection,
siren_entity,
siren_resource,
)
class RecordSirenPolicy:
def operations_for_entity(self, request, resource_name, properties):
operations = ["get_record"]
if request.user.can_edit(properties):
operations.append("revise_record")
return tuple(operations)
def operations_for_collection(self, request, resource_name):
operations = ["list_records"]
if request.user.can_create(resource_name):
operations.append("create_record")
return tuple(operations)
def related_links_for_entity(self, request, resource_name, properties):
if "owner_id" in properties:
return (RelatedLinkInput(rel="owner", resource="user", value=properties["owner_id"]),)
return ()
@siren_resource(
name="record",
path="/records/{record_slug}",
class_="record",
identifier="slug",
path_parameters={"record_slug": "slug"},
relations={},
)
@api_controller("/records")
class RecordController(ControllerBase, NinjaExtraSirenController):
def __init__(self, records: RecordService, siren: ModwireSiren):
NinjaExtraSirenController.__init__(self, siren)
self.records = records
@route.get("/{record_slug}", operation_id="get_record")
@siren_entity(resource="record", policy=RecordSirenPolicy())
def get_record(self, request, record_slug: str):
return self.records.get(record_slug)
@route.get("", operation_id="list_records")
@siren_collection(resource="record", policy=RecordSirenPolicy(), item_operations=("get_record",))
def list_records(self, request):
return self.records.list()
schema = inject_siren_resources(schema, collect_siren_resources(RecordController))
The method returns only resource properties. @siren_entity(...) retains its signature for Ninja's
parameter inspection, supplies route arguments as path values, supports sync and async handlers, and
projects the result through the standard ModwireSiren composition root. The decorator returns a
framework-light NinjaExtraSirenResponse with body, status_code, headers, and content_type
fields. Framework code can map those fields onto its response object while preserving non-content
headers. SirenEntityDecorator(...) remains available for controllers that need the plain Siren
document dictionary instead of a response payload.
Lower-level response APIs are available when decorators do not fit:
response = controller.siren_responses.entity(
"record",
{"slug": "architecture", "title": "Architecture"},
operations=("get_record", "revise_record"),
)
problem = controller.siren_responses.problem(
{"title": "Missing record", "status": 404},
status_code=404,
)
empty = controller.siren_responses.no_content()
The response factory rejects duplicate Content-Type headers and mismatched problem statuses. A
204 response carries no body and no content type.
Development and release
Run uv sync --all-groups and make verify. Releases use strict SemVer tags and PyPI Trusted
Publishing configured for repository modwire/modwire-siren, workflow release.yml, and environment
pypi. Create and push the tag before publishing its GitHub Release; that release drives the shared
build, attaches the verified distributions, and then publishes the same files to PyPI.
git tag -a v1.0.1 -m "v1.0.1"
git push origin v1.0.1
gh release create v1.0.1 --verify-tag --generate-notes --title v1.0.1
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file modwire_siren-1.3.1.tar.gz.
File metadata
- Download URL: modwire_siren-1.3.1.tar.gz
- Upload date:
- Size: 54.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0d54e3def978743553365804dacd444d10d89cbbd15161ed565dba26da8bd515
|
|
| MD5 |
7aa0b1d96755f02674e7607923911566
|
|
| BLAKE2b-256 |
349488778ce9d03798a7a4c1886455e9e12b937758f22cc32b13c52c6680bae8
|
Provenance
The following attestation bundles were made for modwire_siren-1.3.1.tar.gz:
Publisher:
release.yml on modwire/modwire-siren
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
modwire_siren-1.3.1.tar.gz -
Subject digest:
0d54e3def978743553365804dacd444d10d89cbbd15161ed565dba26da8bd515 - Sigstore transparency entry: 2194697177
- Sigstore integration time:
-
Permalink:
modwire/modwire-siren@7903a9717d50ec450e0bf218130b724277a2512b -
Branch / Tag:
refs/tags/v1.3.1 - Owner: https://github.com/modwire
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@7903a9717d50ec450e0bf218130b724277a2512b -
Trigger Event:
release
-
Statement type:
File details
Details for the file modwire_siren-1.3.1-py3-none-any.whl.
File metadata
- Download URL: modwire_siren-1.3.1-py3-none-any.whl
- Upload date:
- Size: 56.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5eff58d6db1a1c0f98bd7383f60ffecd1366253dea89b1c30e3bd25092943d28
|
|
| MD5 |
c02db6a64b67a2f4389a83bb5fd35e73
|
|
| BLAKE2b-256 |
2a923890b31fa44b133ce9551ed4b1b78905d64cd280f0cebf5e395d67f0367c
|
Provenance
The following attestation bundles were made for modwire_siren-1.3.1-py3-none-any.whl:
Publisher:
release.yml on modwire/modwire-siren
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
modwire_siren-1.3.1-py3-none-any.whl -
Subject digest:
5eff58d6db1a1c0f98bd7383f60ffecd1366253dea89b1c30e3bd25092943d28 - Sigstore transparency entry: 2194697180
- Sigstore integration time:
-
Permalink:
modwire/modwire-siren@7903a9717d50ec450e0bf218130b724277a2512b -
Branch / Tag:
refs/tags/v1.3.1 - Owner: https://github.com/modwire
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@7903a9717d50ec450e0bf218130b724277a2512b -
Trigger Event:
release
-
Statement type: