modwire-siren 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. modwire_siren-0.1.0/.gitignore +6 -0
  2. modwire_siren-0.1.0/PKG-INFO +135 -0
  3. modwire_siren-0.1.0/README.md +117 -0
  4. modwire_siren-0.1.0/examples/build_document.py +28 -0
  5. modwire_siren-0.1.0/pyproject.toml +56 -0
  6. modwire_siren-0.1.0/src/modwire_siren/__init__.py +16 -0
  7. modwire_siren-0.1.0/src/modwire_siren/_version.py +24 -0
  8. modwire_siren-0.1.0/src/modwire_siren/composition.py +28 -0
  9. modwire_siren-0.1.0/src/modwire_siren/contracts/__init__.py +0 -0
  10. modwire_siren-0.1.0/src/modwire_siren/contracts/action.py +28 -0
  11. modwire_siren-0.1.0/src/modwire_siren/contracts/base.py +5 -0
  12. modwire_siren-0.1.0/src/modwire_siren/contracts/entity.py +39 -0
  13. modwire_siren-0.1.0/src/modwire_siren/contracts/link.py +12 -0
  14. modwire_siren-0.1.0/src/modwire_siren/contracts/operation.py +19 -0
  15. modwire_siren-0.1.0/src/modwire_siren/contracts/resource.py +17 -0
  16. modwire_siren-0.1.0/src/modwire_siren/facade.py +17 -0
  17. modwire_siren-0.1.0/src/modwire_siren/factories/__init__.py +0 -0
  18. modwire_siren-0.1.0/src/modwire_siren/factories/action.py +30 -0
  19. modwire_siren-0.1.0/src/modwire_siren/factories/entity.py +38 -0
  20. modwire_siren-0.1.0/src/modwire_siren/factories/field.py +28 -0
  21. modwire_siren-0.1.0/src/modwire_siren/factories/link.py +62 -0
  22. modwire_siren-0.1.0/src/modwire_siren/factories/resource.py +31 -0
  23. modwire_siren-0.1.0/src/modwire_siren/integrations/__init__.py +0 -0
  24. modwire_siren-0.1.0/src/modwire_siren/integrations/ninja_extra.py +80 -0
  25. modwire_siren-0.1.0/src/modwire_siren/openapi/__init__.py +0 -0
  26. modwire_siren-0.1.0/src/modwire_siren/openapi/catalog.py +62 -0
  27. modwire_siren-0.1.0/src/modwire_siren/openapi/contracts.py +19 -0
  28. modwire_siren-0.1.0/src/modwire_siren/openapi/error.py +4 -0
  29. modwire_siren-0.1.0/src/modwire_siren/openapi/factory.py +16 -0
  30. modwire_siren-0.1.0/src/modwire_siren/openapi/href.py +26 -0
  31. modwire_siren-0.1.0/src/modwire_siren/openapi/method.py +12 -0
  32. modwire_siren-0.1.0/src/modwire_siren/openapi/operation.py +73 -0
  33. modwire_siren-0.1.0/src/modwire_siren/openapi/resolver.py +56 -0
  34. modwire_siren-0.1.0/src/modwire_siren/openapi/resource.py +34 -0
  35. modwire_siren-0.1.0/src/modwire_siren/policies/__init__.py +0 -0
  36. modwire_siren-0.1.0/src/modwire_siren/policies/field_type.py +24 -0
  37. modwire_siren-0.1.0/src/modwire_siren/py.typed +1 -0
  38. modwire_siren-0.1.0/src/modwire_siren/serialization.py +15 -0
  39. modwire_siren-0.1.0/src/modwire_siren/standards.py +21 -0
  40. modwire_siren-0.1.0/tests/test_document.py +72 -0
  41. modwire_siren-0.1.0/tests/test_hardening.py +118 -0
  42. modwire_siren-0.1.0/tests/test_ninja_extra.py +56 -0
  43. modwire_siren-0.1.0/tests/test_openapi.py +133 -0
  44. modwire_siren-0.1.0/tests/test_repository.py +18 -0
  45. modwire_siren-0.1.0/tests/test_siren_public_api.py +27 -0
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ .pytest_cache/
3
+ .ruff_cache/
4
+ __pycache__/
5
+ *.py[cod]
6
+ dist/
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.4
2
+ Name: modwire-siren
3
+ Version: 0.1.0
4
+ Summary: Siren hypermedia and OpenAPI integration for Modwire.
5
+ Project-URL: Repository, https://github.com/9orky/modwire-siren
6
+ Project-URL: Issues, https://github.com/9orky/modwire-siren/issues
7
+ Author: 9orky
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.12
16
+ Requires-Dist: pydantic>=2.12
17
+ Description-Content-Type: text/markdown
18
+
19
+ # modwire-siren
20
+
21
+ Typed Siren documents projected from OpenAPI without application-owned route maps.
22
+
23
+ Every external boundary is behind a package interface: catalog, href resolution, field policy,
24
+ field creation, link creation, resource hrefs, and serialization. `ModwireSirenFactory` is the
25
+ standard composition root; `ModwireSiren` is the small public façade.
26
+
27
+ <!-- generated:public-api:start -->
28
+ ## Public API
29
+
30
+ The supported root imports below are generated from `modwire_siren.__all__`.
31
+
32
+ | Symbol | Purpose | Primary API |
33
+ | --- | --- | --- |
34
+ | `ModwireSiren` | Project validated entity requests into serialized Siren documents. | `document(request: modwire_siren.contracts.entity.SirenEntityRequest) -> dict[str, typing.Any]` |
35
+ | `ModwireSirenFactory` | Build the standard OpenAPI-backed Siren façade. | `standard(schema: dict[str, typing.Any], base_url: str) -> modwire_siren.facade.ModwireSiren` |
36
+ | `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]` |
37
+ | `OpenApiError` | Report invalid or incomplete OpenAPI data used for Siren projection. | — |
38
+ | `SirenEntityDecorator` | Turn a controller method's property mapping into a Siren entity document. | — |
39
+ | `SirenEntityRequest` | Describe the resource data and allowed operations projected into one entity. | — |
40
+ | `__version__` | Installed distribution version. | — |
41
+
42
+ ## Executable example
43
+
44
+ Source: [`build_document.py`](examples/build_document.py). This file is executed by the test suite.
45
+
46
+ ```python
47
+ from modwire_siren import ModwireSirenFactory, SirenEntityRequest
48
+
49
+ openapi_schema = {
50
+ "openapi": "3.1.0",
51
+ "paths": {
52
+ "/records/{record_slug}": {
53
+ "x-siren-resource": {
54
+ "name": "record",
55
+ "class": "record",
56
+ "identifier": "slug",
57
+ "path-parameters": {"record_slug": "slug"},
58
+ "relations": {},
59
+ },
60
+ "get": {"operationId": "get_record", "summary": "Get record"},
61
+ }
62
+ },
63
+ }
64
+
65
+ siren = ModwireSirenFactory.standard(openapi_schema, "https://api.example.com/")
66
+ document = siren.document(
67
+ SirenEntityRequest(
68
+ resource_name="record",
69
+ properties={"slug": "architecture/aggregate", "title": "Architecture"},
70
+ operation_ids=("get_record",),
71
+ path_values={},
72
+ entities=(),
73
+ )
74
+ )
75
+ ```
76
+ <!-- generated:public-api:end -->
77
+
78
+ ## OpenAPI contract
79
+
80
+ ```yaml
81
+ paths:
82
+ /records/{record_slug}:
83
+ x-siren-resource:
84
+ name: record
85
+ class: record
86
+ identifier: slug
87
+ path-parameters:
88
+ record_slug: slug
89
+ relations:
90
+ section_slug:
91
+ rel: section
92
+ resource: section
93
+ many: false
94
+ patch:
95
+ operationId: revise_record
96
+ summary: Revise record
97
+ ```
98
+
99
+ The strict `OpenApiResourceExtension` validates the extension. Unknown resources, incomplete path
100
+ mappings, absent operation IDs, and unknown schema references fail while building the catalog.
101
+
102
+ The Pydantic Siren contracts own wire aliases such as `class`, `type`, and `schema`.
103
+ `PydanticSirenSerializer` implements the `SirenSerializer` interface with one model dump; it does
104
+ not redeclare the wire schema.
105
+
106
+ ## Django Ninja Extra
107
+
108
+ The controller adapter does not import Django or Ninja Extra, so the core package keeps no framework
109
+ dependency. It composes directly with Ninja Extra's controller and route decorators:
110
+
111
+ ```python
112
+ from ninja_extra import ControllerBase, api_controller, route
113
+ from modwire_siren import ModwireSiren, NinjaExtraSirenController, SirenEntityDecorator
114
+
115
+ @api_controller("/records")
116
+ class RecordController(ControllerBase, NinjaExtraSirenController):
117
+ def __init__(self, records: RecordService, siren: ModwireSiren):
118
+ NinjaExtraSirenController.__init__(self, siren)
119
+ self.records = records
120
+
121
+ @route.get("/{record_slug}", operation_id="get_record")
122
+ @SirenEntityDecorator("record", operations=("revise_record",))
123
+ def get_record(self, record_slug: str):
124
+ return self.records.get(record_slug)
125
+ ```
126
+
127
+ The method returns only resource properties. `@SirenEntityDecorator(...)` retains its signature for
128
+ Ninja's parameter inspection, supplies route arguments as path values, supports sync and async
129
+ handlers, and projects the result through the standard `ModwireSiren` composition root.
130
+
131
+ ## Development and release
132
+
133
+ Run `uv sync --all-groups` and `make verify`. Releases use strict SemVer tags and PyPI Trusted
134
+ Publishing configured for repository `9orky/modwire-siren`, workflow `release.yml`, and environment
135
+ `pypi`.
@@ -0,0 +1,117 @@
1
+ # modwire-siren
2
+
3
+ Typed Siren documents projected from OpenAPI without application-owned route maps.
4
+
5
+ Every external boundary is behind a package interface: catalog, href resolution, field policy,
6
+ field creation, link creation, resource hrefs, and serialization. `ModwireSirenFactory` is the
7
+ standard composition root; `ModwireSiren` is the small public façade.
8
+
9
+ <!-- generated:public-api:start -->
10
+ ## Public API
11
+
12
+ The supported root imports below are generated from `modwire_siren.__all__`.
13
+
14
+ | Symbol | Purpose | Primary API |
15
+ | --- | --- | --- |
16
+ | `ModwireSiren` | Project validated entity requests into serialized Siren documents. | `document(request: modwire_siren.contracts.entity.SirenEntityRequest) -> dict[str, typing.Any]` |
17
+ | `ModwireSirenFactory` | Build the standard OpenAPI-backed Siren façade. | `standard(schema: dict[str, typing.Any], base_url: str) -> modwire_siren.facade.ModwireSiren` |
18
+ | `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]` |
19
+ | `OpenApiError` | Report invalid or incomplete OpenAPI data used for Siren projection. | — |
20
+ | `SirenEntityDecorator` | Turn a controller method's property mapping into a Siren entity document. | — |
21
+ | `SirenEntityRequest` | Describe the resource data and allowed operations projected into one entity. | — |
22
+ | `__version__` | Installed distribution version. | — |
23
+
24
+ ## Executable example
25
+
26
+ Source: [`build_document.py`](examples/build_document.py). This file is executed by the test suite.
27
+
28
+ ```python
29
+ from modwire_siren import ModwireSirenFactory, SirenEntityRequest
30
+
31
+ openapi_schema = {
32
+ "openapi": "3.1.0",
33
+ "paths": {
34
+ "/records/{record_slug}": {
35
+ "x-siren-resource": {
36
+ "name": "record",
37
+ "class": "record",
38
+ "identifier": "slug",
39
+ "path-parameters": {"record_slug": "slug"},
40
+ "relations": {},
41
+ },
42
+ "get": {"operationId": "get_record", "summary": "Get record"},
43
+ }
44
+ },
45
+ }
46
+
47
+ siren = ModwireSirenFactory.standard(openapi_schema, "https://api.example.com/")
48
+ document = siren.document(
49
+ SirenEntityRequest(
50
+ resource_name="record",
51
+ properties={"slug": "architecture/aggregate", "title": "Architecture"},
52
+ operation_ids=("get_record",),
53
+ path_values={},
54
+ entities=(),
55
+ )
56
+ )
57
+ ```
58
+ <!-- generated:public-api:end -->
59
+
60
+ ## OpenAPI contract
61
+
62
+ ```yaml
63
+ paths:
64
+ /records/{record_slug}:
65
+ x-siren-resource:
66
+ name: record
67
+ class: record
68
+ identifier: slug
69
+ path-parameters:
70
+ record_slug: slug
71
+ relations:
72
+ section_slug:
73
+ rel: section
74
+ resource: section
75
+ many: false
76
+ patch:
77
+ operationId: revise_record
78
+ summary: Revise record
79
+ ```
80
+
81
+ The strict `OpenApiResourceExtension` validates the extension. Unknown resources, incomplete path
82
+ mappings, absent operation IDs, and unknown schema references fail while building the catalog.
83
+
84
+ The Pydantic Siren contracts own wire aliases such as `class`, `type`, and `schema`.
85
+ `PydanticSirenSerializer` implements the `SirenSerializer` interface with one model dump; it does
86
+ not redeclare the wire schema.
87
+
88
+ ## Django Ninja Extra
89
+
90
+ The controller adapter does not import Django or Ninja Extra, so the core package keeps no framework
91
+ dependency. It composes directly with Ninja Extra's controller and route decorators:
92
+
93
+ ```python
94
+ from ninja_extra import ControllerBase, api_controller, route
95
+ from modwire_siren import ModwireSiren, NinjaExtraSirenController, SirenEntityDecorator
96
+
97
+ @api_controller("/records")
98
+ class RecordController(ControllerBase, NinjaExtraSirenController):
99
+ def __init__(self, records: RecordService, siren: ModwireSiren):
100
+ NinjaExtraSirenController.__init__(self, siren)
101
+ self.records = records
102
+
103
+ @route.get("/{record_slug}", operation_id="get_record")
104
+ @SirenEntityDecorator("record", operations=("revise_record",))
105
+ def get_record(self, record_slug: str):
106
+ return self.records.get(record_slug)
107
+ ```
108
+
109
+ The method returns only resource properties. `@SirenEntityDecorator(...)` retains its signature for
110
+ Ninja's parameter inspection, supplies route arguments as path values, supports sync and async
111
+ handlers, and projects the result through the standard `ModwireSiren` composition root.
112
+
113
+ ## Development and release
114
+
115
+ Run `uv sync --all-groups` and `make verify`. Releases use strict SemVer tags and PyPI Trusted
116
+ Publishing configured for repository `9orky/modwire-siren`, workflow `release.yml`, and environment
117
+ `pypi`.
@@ -0,0 +1,28 @@
1
+ from modwire_siren import ModwireSirenFactory, SirenEntityRequest
2
+
3
+ openapi_schema = {
4
+ "openapi": "3.1.0",
5
+ "paths": {
6
+ "/records/{record_slug}": {
7
+ "x-siren-resource": {
8
+ "name": "record",
9
+ "class": "record",
10
+ "identifier": "slug",
11
+ "path-parameters": {"record_slug": "slug"},
12
+ "relations": {},
13
+ },
14
+ "get": {"operationId": "get_record", "summary": "Get record"},
15
+ }
16
+ },
17
+ }
18
+
19
+ siren = ModwireSirenFactory.standard(openapi_schema, "https://api.example.com/")
20
+ document = siren.document(
21
+ SirenEntityRequest(
22
+ resource_name="record",
23
+ properties={"slug": "architecture/aggregate", "title": "Architecture"},
24
+ operation_ids=("get_record",),
25
+ path_values={},
26
+ entities=(),
27
+ )
28
+ )
@@ -0,0 +1,56 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.28", "hatch-vcs>=0.5"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "modwire-siren"
7
+ dynamic = ["version"]
8
+ description = "Siren hypermedia and OpenAPI integration for Modwire."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "MIT"
12
+ authors = [{ name = "9orky" }]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Typing :: Typed",
20
+ ]
21
+
22
+ dependencies = ["pydantic>=2.12"]
23
+
24
+ [dependency-groups]
25
+ dev = ["build>=1.3", "packaging>=25", "pytest>=9", "ruff>=0.14", "twine>=6"]
26
+
27
+ [project.urls]
28
+ Repository = "https://github.com/9orky/modwire-siren"
29
+ Issues = "https://github.com/9orky/modwire-siren/issues"
30
+
31
+ [tool.hatch.version]
32
+ source = "vcs"
33
+ tag-pattern = '^v(?P<version>\d+\.\d+\.\d+)$'
34
+ fallback-version = "0.0.0"
35
+
36
+ [tool.hatch.build.hooks.vcs]
37
+ version-file = "src/modwire_siren/_version.py"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/modwire_siren"]
41
+
42
+ [tool.hatch.build.targets.sdist]
43
+ include = ["examples", "src", "tests", "README.md", "pyproject.toml"]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
47
+ addopts = ["-ra", "--strict-config", "--strict-markers"]
48
+ filterwarnings = ["error"]
49
+
50
+ [tool.ruff]
51
+ line-length = 120
52
+ extend-exclude = ["**/_version.py"]
53
+ target-version = "py312"
54
+
55
+ [tool.ruff.lint]
56
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
@@ -0,0 +1,16 @@
1
+ from ._version import __version__
2
+ from .composition import ModwireSirenFactory
3
+ from .contracts.entity import SirenEntityRequest
4
+ from .facade import ModwireSiren
5
+ from .integrations.ninja_extra import NinjaExtraSirenController, SirenEntityDecorator
6
+ from .openapi.error import OpenApiError
7
+
8
+ __all__ = [
9
+ "ModwireSiren",
10
+ "ModwireSirenFactory",
11
+ "NinjaExtraSirenController",
12
+ "OpenApiError",
13
+ "SirenEntityDecorator",
14
+ "SirenEntityRequest",
15
+ "__version__",
16
+ ]
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,28 @@
1
+ from typing import Any
2
+
3
+ from .facade import ModwireSiren
4
+ from .factories.action import SirenActionFactory
5
+ from .factories.entity import SirenEntityFactory
6
+ from .factories.field import OpenApiSirenFieldFactory
7
+ from .factories.link import OpenApiSirenLinkFactory
8
+ from .factories.resource import OpenApiSirenResourceHrefResolver
9
+ from .openapi.factory import OpenApiCatalogFactory
10
+ from .openapi.href import OpenApiHrefResolver
11
+ from .policies.field_type import OpenApiSirenFieldTypeResolver
12
+ from .serialization import PydanticSirenSerializer
13
+
14
+
15
+ class ModwireSirenFactory:
16
+ """Build the standard OpenAPI-backed Siren façade."""
17
+
18
+ @classmethod
19
+ def standard(cls, schema: dict[str, Any], base_url: str) -> ModwireSiren:
20
+ """Create a ready-to-use façade from an OpenAPI schema and API base URL."""
21
+ catalog = OpenApiCatalogFactory.create(schema)
22
+ hrefs = OpenApiHrefResolver(base_url)
23
+ resource_hrefs = OpenApiSirenResourceHrefResolver(hrefs)
24
+ fields = OpenApiSirenFieldFactory(OpenApiSirenFieldTypeResolver())
25
+ actions = SirenActionFactory(catalog, hrefs, fields)
26
+ links = OpenApiSirenLinkFactory(catalog, resource_hrefs)
27
+ entities = SirenEntityFactory(catalog, resource_hrefs, links, actions)
28
+ return ModwireSiren(entities, PydanticSirenSerializer())
@@ -0,0 +1,28 @@
1
+ from typing import Annotated, Any
2
+
3
+ from pydantic import Field, field_validator
4
+
5
+ from .base import SirenContract
6
+
7
+
8
+ class SirenField(SirenContract):
9
+ name: str
10
+ type: str
11
+ required: bool
12
+ title: str
13
+ options: tuple[dict[str, Any], ...]
14
+ schema_definition: Annotated[dict[str, Any], Field(validation_alias="schema", serialization_alias="schema")]
15
+
16
+
17
+ class SirenAction(SirenContract):
18
+ name: str
19
+ href: str
20
+ method: str
21
+ title: str
22
+ media_type: Annotated[str, Field(serialization_alias="type")]
23
+ fields: tuple[SirenField, ...]
24
+
25
+ @field_validator("method")
26
+ @classmethod
27
+ def normalize_method(cls, value: str) -> str:
28
+ return value.upper()
@@ -0,0 +1,5 @@
1
+ from pydantic import BaseModel, ConfigDict
2
+
3
+
4
+ class SirenContract(BaseModel):
5
+ model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated, Any
4
+
5
+ from pydantic import Field, model_serializer
6
+
7
+ from .action import SirenAction
8
+ from .base import SirenContract
9
+ from .link import SirenLink
10
+
11
+
12
+ class SirenEntity(SirenContract):
13
+ classes: Annotated[tuple[str, ...], Field(serialization_alias="class")]
14
+ properties: dict[str, Any]
15
+ links: tuple[SirenLink, ...]
16
+ actions: tuple[SirenAction, ...]
17
+ entities: tuple[SirenEmbeddedEntity, ...]
18
+
19
+
20
+ class SirenEmbeddedEntity(SirenContract):
21
+ rel: tuple[str, ...]
22
+ entity: SirenEntity
23
+
24
+ @model_serializer
25
+ def serialize(self) -> dict[str, Any]:
26
+ return {"rel": self.rel, **self.entity.model_dump(mode="json", by_alias=True)}
27
+
28
+
29
+ class SirenEntityRequest(SirenContract):
30
+ """Describe the resource data and allowed operations projected into one entity."""
31
+
32
+ resource_name: str
33
+ properties: dict[str, Any]
34
+ operation_ids: tuple[str, ...]
35
+ path_values: dict[str, Any]
36
+ entities: tuple[SirenEmbeddedEntity, ...]
37
+
38
+
39
+ SirenEntity.model_rebuild()
@@ -0,0 +1,12 @@
1
+ from typing import Annotated
2
+
3
+ from pydantic import Field
4
+
5
+ from .base import SirenContract
6
+
7
+
8
+ class SirenLink(SirenContract):
9
+ rel: tuple[str, ...]
10
+ href: str
11
+ title: str
12
+ media_type: Annotated[str, Field(serialization_alias="type")]
@@ -0,0 +1,19 @@
1
+ from typing import Annotated, Any
2
+
3
+ from pydantic import Field
4
+
5
+ from .base import SirenContract
6
+
7
+
8
+ class OpenApiField(SirenContract):
9
+ name: str
10
+ schema_definition: Annotated[dict[str, Any], Field(validation_alias="schema")]
11
+ required: bool
12
+
13
+
14
+ class OpenApiOperation(SirenContract):
15
+ operation_id: str
16
+ method: str
17
+ path: str
18
+ title: str
19
+ fields: tuple[OpenApiField, ...]
@@ -0,0 +1,17 @@
1
+ from .base import SirenContract
2
+
3
+
4
+ class SirenRelation(SirenContract):
5
+ field: str
6
+ rel: str
7
+ resource: str
8
+ many: bool
9
+
10
+
11
+ class SirenResource(SirenContract):
12
+ name: str
13
+ path: str
14
+ resource_class: str
15
+ identifier: str
16
+ path_parameters: dict[str, str]
17
+ relations: tuple[SirenRelation, ...]
@@ -0,0 +1,17 @@
1
+ from typing import Any
2
+
3
+ from .contracts.entity import SirenEntityRequest
4
+ from .factories.entity import SirenEntityFactory
5
+ from .serialization import SirenSerializer
6
+
7
+
8
+ class ModwireSiren:
9
+ """Project validated entity requests into serialized Siren documents."""
10
+
11
+ def __init__(self, entities: SirenEntityFactory, serializer: SirenSerializer):
12
+ self._entities = entities
13
+ self._serializer = serializer
14
+
15
+ def document(self, request: SirenEntityRequest) -> dict[str, Any]:
16
+ """Build and serialize one Siren entity document."""
17
+ return self._serializer.serialize(self._entities.create(request))
@@ -0,0 +1,30 @@
1
+ from collections.abc import Mapping
2
+ from typing import Any
3
+
4
+ from ..contracts.action import SirenAction
5
+ from ..openapi.catalog import SirenResourceCatalog
6
+ from ..openapi.href import SirenHrefResolver
7
+ from ..standards import SirenMediaType
8
+ from .field import SirenFieldFactory
9
+
10
+
11
+ class SirenActionFactory:
12
+ def __init__(
13
+ self,
14
+ catalog: SirenResourceCatalog,
15
+ hrefs: SirenHrefResolver,
16
+ fields: SirenFieldFactory,
17
+ ):
18
+ self._catalog = catalog
19
+ self._hrefs = hrefs
20
+ self._fields = fields
21
+
22
+ def create(self, operation_id: str, path_values: Mapping[str, Any]) -> SirenAction:
23
+ operation = self._catalog.operation(operation_id)
24
+ return SirenAction(
25
+ **operation.model_dump(include={"method", "title"}),
26
+ name=operation.operation_id,
27
+ href=self._hrefs.resolve(operation.path, path_values),
28
+ media_type=SirenMediaType.ACTION,
29
+ fields=tuple(self._fields.create(field) for field in operation.fields),
30
+ )
@@ -0,0 +1,38 @@
1
+ from ..contracts.entity import SirenEntity, SirenEntityRequest
2
+ from ..openapi.catalog import SirenResourceCatalog
3
+ from ..openapi.error import OpenApiError
4
+ from .action import SirenActionFactory
5
+ from .link import SirenLinkFactory
6
+ from .resource import SirenResourceHrefResolver
7
+
8
+
9
+ class SirenEntityFactory:
10
+ def __init__(
11
+ self,
12
+ catalog: SirenResourceCatalog,
13
+ hrefs: SirenResourceHrefResolver,
14
+ links: SirenLinkFactory,
15
+ actions: SirenActionFactory,
16
+ ):
17
+ self._catalog = catalog
18
+ self._hrefs = hrefs
19
+ self._links = links
20
+ self._actions = actions
21
+
22
+ def create(self, request: SirenEntityRequest) -> SirenEntity:
23
+ resource = self._catalog.resource(request.resource_name)
24
+ operations = tuple(self._catalog.operation(operation_id) for operation_id in request.operation_ids)
25
+ foreign = tuple(operation.operation_id for operation in operations if operation.path != resource.path)
26
+ if foreign:
27
+ raise OpenApiError(f"Operations {list(foreign)} do not belong to resource {resource.name!r}")
28
+ values = request.properties | request.path_values
29
+ operation_values = self._hrefs.path_values(resource, values)
30
+ return SirenEntity(
31
+ classes=(resource.resource_class,),
32
+ properties=request.properties,
33
+ links=self._links.create(resource, values),
34
+ actions=tuple(
35
+ self._actions.create(operation.operation_id, operation_values) for operation in operations
36
+ ),
37
+ entities=request.entities,
38
+ )
@@ -0,0 +1,28 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ from ..contracts.action import SirenField
4
+ from ..contracts.operation import OpenApiField
5
+ from ..policies.field_type import SirenFieldTypeResolver
6
+ from ..standards import SirenFieldType
7
+
8
+
9
+ class SirenFieldFactory(ABC):
10
+ @abstractmethod
11
+ def create(self, field: OpenApiField) -> SirenField:
12
+ raise NotImplementedError
13
+
14
+
15
+ class OpenApiSirenFieldFactory(SirenFieldFactory):
16
+ def __init__(self, types: SirenFieldTypeResolver):
17
+ self._types = types
18
+
19
+ def create(self, field: OpenApiField) -> SirenField:
20
+ field_type = self._types.resolve(field.schema_definition)
21
+ return SirenField(
22
+ name=field.name,
23
+ type=field_type,
24
+ required=field.required,
25
+ title=field.schema_definition.get("title", field.name),
26
+ options=tuple({"value": value, "title": str(value)} for value in field.schema_definition.get("enum", [])),
27
+ schema=field.schema_definition if field_type is SirenFieldType.JSON else {},
28
+ )