Skip to main content

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.

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; and
  • actions: 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 the explicit x-siren-resource metadata from one OpenAPI document, then projects runtime resource values into a typed Siren entity. 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 the operation IDs that are legal for the current entity and principal, and only those operations become actions. It also does not serve HTTP responses itself; the framework layer remains responsible for content negotiation and returning Content-Type: application/vnd.siren+json.

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.

Useful next improvements

The most valuable additions for this project would be:

  1. Add framework response adapters that set the Siren content type and handle content negotiation, while keeping the core framework-independent.
  2. Publish a machine-readable schema for x-siren-resource and validate it in editor/CI workflows, so mistakes are caught before application startup.
  3. Add specification conformance fixtures for embedded entities, every action field type, and link relations, alongside the current unit tests.
  4. Generate a complete example response in this README, not only the construction code, so users can immediately see the resulting wire format.
  5. Document an authorization recipe showing how a policy selects the runtime operation_ids without leaking unavailable actions to clients.

Public API

The supported root imports below are generated from modwire_siren.__all__.

Symbol Purpose Primary API
ModwireSiren Project validated entity requests into serialized Siren documents. document(request: modwire_siren.contracts.entity.SirenEntityRequest) -> 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, ...] = ()) -> dict[str, typing.Any]
OpenApiError Report invalid or incomplete OpenAPI data used 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
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.
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.

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": {},
            },
            "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
    patch:
      operationId: revise_record
      summary: Revise record

The strict OpenApiResourceExtension validates the extension. Unknown resources, incomplete path mappings, absent operation IDs, and unknown schema references fail while building the catalog.

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.

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, SirenEntityDecorator

@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")
    @SirenEntityDecorator("record", operations=("revise_record",))
    def get_record(self, record_slug: str):
        return self.records.get(record_slug)

The method returns only resource properties. @SirenEntityDecorator(...) 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.

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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

modwire_siren-1.1.0.tar.gz (18.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

modwire_siren-1.1.0-py3-none-any.whl (24.6 kB view details)

Uploaded Python 3

File details

Details for the file modwire_siren-1.1.0.tar.gz.

File metadata

  • Download URL: modwire_siren-1.1.0.tar.gz
  • Upload date:
  • Size: 18.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for modwire_siren-1.1.0.tar.gz
Algorithm Hash digest
SHA256 ec210e13fd6bbe3058760927794a6cc66faa8a3c7834d261bb6bfc0724cddfaf
MD5 ddea9c03d162f07efbfe9870c8117300
BLAKE2b-256 7e477527e31e046523f7961579f21c70a0bd876806bbc1059e7c113fd44b24f1

See more details on using hashes here.

Provenance

The following attestation bundles were made for modwire_siren-1.1.0.tar.gz:

Publisher: release.yml on modwire/modwire-siren

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file modwire_siren-1.1.0-py3-none-any.whl.

File metadata

  • Download URL: modwire_siren-1.1.0-py3-none-any.whl
  • Upload date:
  • Size: 24.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for modwire_siren-1.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 09297c86813bcf95e580d1d6155594bef8c532ecd0cc8f3ed1c98d7f98c74395
MD5 7b3bc04d739ee340d278f780cf187d1e
BLAKE2b-256 d31297cd25e9783311853c745ba3fa74dc65983a63502e98da26729df6f49d95

See more details on using hashes here.

Provenance

The following attestation bundles were made for modwire_siren-1.1.0-py3-none-any.whl:

Publisher: release.yml on modwire/modwire-siren

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page