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.
- modwire_siren-0.1.0/.gitignore +6 -0
- modwire_siren-0.1.0/PKG-INFO +135 -0
- modwire_siren-0.1.0/README.md +117 -0
- modwire_siren-0.1.0/examples/build_document.py +28 -0
- modwire_siren-0.1.0/pyproject.toml +56 -0
- modwire_siren-0.1.0/src/modwire_siren/__init__.py +16 -0
- modwire_siren-0.1.0/src/modwire_siren/_version.py +24 -0
- modwire_siren-0.1.0/src/modwire_siren/composition.py +28 -0
- modwire_siren-0.1.0/src/modwire_siren/contracts/__init__.py +0 -0
- modwire_siren-0.1.0/src/modwire_siren/contracts/action.py +28 -0
- modwire_siren-0.1.0/src/modwire_siren/contracts/base.py +5 -0
- modwire_siren-0.1.0/src/modwire_siren/contracts/entity.py +39 -0
- modwire_siren-0.1.0/src/modwire_siren/contracts/link.py +12 -0
- modwire_siren-0.1.0/src/modwire_siren/contracts/operation.py +19 -0
- modwire_siren-0.1.0/src/modwire_siren/contracts/resource.py +17 -0
- modwire_siren-0.1.0/src/modwire_siren/facade.py +17 -0
- modwire_siren-0.1.0/src/modwire_siren/factories/__init__.py +0 -0
- modwire_siren-0.1.0/src/modwire_siren/factories/action.py +30 -0
- modwire_siren-0.1.0/src/modwire_siren/factories/entity.py +38 -0
- modwire_siren-0.1.0/src/modwire_siren/factories/field.py +28 -0
- modwire_siren-0.1.0/src/modwire_siren/factories/link.py +62 -0
- modwire_siren-0.1.0/src/modwire_siren/factories/resource.py +31 -0
- modwire_siren-0.1.0/src/modwire_siren/integrations/__init__.py +0 -0
- modwire_siren-0.1.0/src/modwire_siren/integrations/ninja_extra.py +80 -0
- modwire_siren-0.1.0/src/modwire_siren/openapi/__init__.py +0 -0
- modwire_siren-0.1.0/src/modwire_siren/openapi/catalog.py +62 -0
- modwire_siren-0.1.0/src/modwire_siren/openapi/contracts.py +19 -0
- modwire_siren-0.1.0/src/modwire_siren/openapi/error.py +4 -0
- modwire_siren-0.1.0/src/modwire_siren/openapi/factory.py +16 -0
- modwire_siren-0.1.0/src/modwire_siren/openapi/href.py +26 -0
- modwire_siren-0.1.0/src/modwire_siren/openapi/method.py +12 -0
- modwire_siren-0.1.0/src/modwire_siren/openapi/operation.py +73 -0
- modwire_siren-0.1.0/src/modwire_siren/openapi/resolver.py +56 -0
- modwire_siren-0.1.0/src/modwire_siren/openapi/resource.py +34 -0
- modwire_siren-0.1.0/src/modwire_siren/policies/__init__.py +0 -0
- modwire_siren-0.1.0/src/modwire_siren/policies/field_type.py +24 -0
- modwire_siren-0.1.0/src/modwire_siren/py.typed +1 -0
- modwire_siren-0.1.0/src/modwire_siren/serialization.py +15 -0
- modwire_siren-0.1.0/src/modwire_siren/standards.py +21 -0
- modwire_siren-0.1.0/tests/test_document.py +72 -0
- modwire_siren-0.1.0/tests/test_hardening.py +118 -0
- modwire_siren-0.1.0/tests/test_ninja_extra.py +56 -0
- modwire_siren-0.1.0/tests/test_openapi.py +133 -0
- modwire_siren-0.1.0/tests/test_repository.py +18 -0
- modwire_siren-0.1.0/tests/test_siren_public_api.py +27 -0
|
@@ -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())
|
|
File without changes
|
|
@@ -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,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,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))
|
|
File without changes
|
|
@@ -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
|
+
)
|