modwire-siren 0.1.0__py3-none-any.whl
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/__init__.py +16 -0
- modwire_siren/_version.py +24 -0
- modwire_siren/composition.py +28 -0
- modwire_siren/contracts/__init__.py +0 -0
- modwire_siren/contracts/action.py +28 -0
- modwire_siren/contracts/base.py +5 -0
- modwire_siren/contracts/entity.py +39 -0
- modwire_siren/contracts/link.py +12 -0
- modwire_siren/contracts/operation.py +19 -0
- modwire_siren/contracts/resource.py +17 -0
- modwire_siren/facade.py +17 -0
- modwire_siren/factories/__init__.py +0 -0
- modwire_siren/factories/action.py +30 -0
- modwire_siren/factories/entity.py +38 -0
- modwire_siren/factories/field.py +28 -0
- modwire_siren/factories/link.py +62 -0
- modwire_siren/factories/resource.py +31 -0
- modwire_siren/integrations/__init__.py +0 -0
- modwire_siren/integrations/ninja_extra.py +80 -0
- modwire_siren/openapi/__init__.py +0 -0
- modwire_siren/openapi/catalog.py +62 -0
- modwire_siren/openapi/contracts.py +19 -0
- modwire_siren/openapi/error.py +4 -0
- modwire_siren/openapi/factory.py +16 -0
- modwire_siren/openapi/href.py +26 -0
- modwire_siren/openapi/method.py +12 -0
- modwire_siren/openapi/operation.py +73 -0
- modwire_siren/openapi/resolver.py +56 -0
- modwire_siren/openapi/resource.py +34 -0
- modwire_siren/policies/__init__.py +0 -0
- modwire_siren/policies/field_type.py +24 -0
- modwire_siren/py.typed +1 -0
- modwire_siren/serialization.py +15 -0
- modwire_siren/standards.py +21 -0
- modwire_siren-0.1.0.dist-info/METADATA +135 -0
- modwire_siren-0.1.0.dist-info/RECORD +37 -0
- modwire_siren-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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, ...]
|
modwire_siren/facade.py
ADDED
|
@@ -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
|
+
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from collections.abc import Iterable, Mapping
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..contracts.link import SirenLink
|
|
6
|
+
from ..contracts.resource import SirenRelation, SirenResource
|
|
7
|
+
from ..openapi.catalog import SirenResourceCatalog
|
|
8
|
+
from ..openapi.error import OpenApiError
|
|
9
|
+
from ..standards import SirenMediaType, SirenRelationName
|
|
10
|
+
from .resource import SirenResourceHrefResolver
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SirenLinkFactory(ABC):
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def create(self, resource: SirenResource, properties: Mapping[str, Any]) -> tuple[SirenLink, ...]:
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OpenApiSirenLinkFactory(SirenLinkFactory):
|
|
20
|
+
def __init__(self, catalog: SirenResourceCatalog, hrefs: SirenResourceHrefResolver):
|
|
21
|
+
self._catalog = catalog
|
|
22
|
+
self._hrefs = hrefs
|
|
23
|
+
|
|
24
|
+
def create(self, resource: SirenResource, properties: Mapping[str, Any]) -> tuple[SirenLink, ...]:
|
|
25
|
+
return (self._self_link(resource, properties), *self._relation_links(resource, properties))
|
|
26
|
+
|
|
27
|
+
def _self_link(self, resource: SirenResource, properties: Mapping[str, Any]) -> SirenLink:
|
|
28
|
+
return SirenLink(
|
|
29
|
+
rel=(SirenRelationName.SELF,),
|
|
30
|
+
href=self._hrefs.href(resource, properties),
|
|
31
|
+
title=resource.resource_class,
|
|
32
|
+
media_type=SirenMediaType.ENTITY,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def _relation_links(self, resource: SirenResource, properties: Mapping[str, Any]) -> tuple[SirenLink, ...]:
|
|
36
|
+
return tuple(
|
|
37
|
+
self._relation_link(relation, value)
|
|
38
|
+
for relation in resource.relations
|
|
39
|
+
if relation.field in properties
|
|
40
|
+
for value in self._relation_values(relation, properties[relation.field])
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def _relation_link(self, relation: SirenRelation, value: Any) -> SirenLink:
|
|
44
|
+
target = self._catalog.resource(relation.resource)
|
|
45
|
+
return SirenLink(
|
|
46
|
+
rel=(relation.rel,),
|
|
47
|
+
href=self._hrefs.href(target, {target.identifier: value}),
|
|
48
|
+
title=relation.rel,
|
|
49
|
+
media_type=SirenMediaType.ENTITY,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def _relation_values(self, relation: SirenRelation, value: Any) -> tuple[Any, ...]:
|
|
53
|
+
if value is None:
|
|
54
|
+
raise OpenApiError(f"Relation {relation.rel!r} requires a non-null value")
|
|
55
|
+
if relation.many:
|
|
56
|
+
if isinstance(value, (str, bytes, Mapping)) or not isinstance(value, Iterable):
|
|
57
|
+
raise OpenApiError(f"Relation {relation.rel!r} requires an iterable value")
|
|
58
|
+
values = tuple(value)
|
|
59
|
+
if any(item is None for item in values):
|
|
60
|
+
raise OpenApiError(f"Relation {relation.rel!r} requires non-null values")
|
|
61
|
+
return values
|
|
62
|
+
return (value,)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..contracts.resource import SirenResource
|
|
6
|
+
from ..openapi.error import OpenApiError
|
|
7
|
+
from ..openapi.href import SirenHrefResolver
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SirenResourceHrefResolver(ABC):
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def href(self, resource: SirenResource, values: Mapping[str, Any]) -> str:
|
|
13
|
+
raise NotImplementedError
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def path_values(self, resource: SirenResource, values: Mapping[str, Any]) -> dict[str, Any]:
|
|
17
|
+
raise NotImplementedError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OpenApiSirenResourceHrefResolver(SirenResourceHrefResolver):
|
|
21
|
+
def __init__(self, hrefs: SirenHrefResolver):
|
|
22
|
+
self._hrefs = hrefs
|
|
23
|
+
|
|
24
|
+
def href(self, resource: SirenResource, values: Mapping[str, Any]) -> str:
|
|
25
|
+
return self._hrefs.resolve(resource.path, self.path_values(resource, values))
|
|
26
|
+
|
|
27
|
+
def path_values(self, resource: SirenResource, values: Mapping[str, Any]) -> dict[str, Any]:
|
|
28
|
+
missing = set(resource.path_parameters.values()) - set(values)
|
|
29
|
+
if missing:
|
|
30
|
+
raise OpenApiError(f"Missing properties {sorted(missing)} for resource {resource.name!r}")
|
|
31
|
+
return {parameter: values[property_name] for parameter, property_name in resource.path_parameters.items()}
|
|
File without changes
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from typing import Any, TypeVar, cast
|
|
5
|
+
|
|
6
|
+
from ..contracts.entity import SirenEmbeddedEntity, SirenEntityRequest
|
|
7
|
+
from ..facade import ModwireSiren
|
|
8
|
+
|
|
9
|
+
Result = Mapping[str, Any]
|
|
10
|
+
F = TypeVar("F", bound=Callable[..., Result | Awaitable[Result]])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NinjaExtraSirenController:
|
|
14
|
+
"""Framework-light base for Ninja Extra controllers that emit Siren documents."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, siren: ModwireSiren):
|
|
17
|
+
self._siren = siren
|
|
18
|
+
|
|
19
|
+
def siren_document(
|
|
20
|
+
self,
|
|
21
|
+
resource_name: str,
|
|
22
|
+
properties: Mapping[str, Any],
|
|
23
|
+
operation_ids: tuple[str, ...],
|
|
24
|
+
path_values: Mapping[str, Any],
|
|
25
|
+
entities: tuple[SirenEmbeddedEntity, ...] = (),
|
|
26
|
+
) -> dict[str, Any]:
|
|
27
|
+
return self._siren.document(
|
|
28
|
+
SirenEntityRequest(
|
|
29
|
+
resource_name=resource_name,
|
|
30
|
+
properties=dict(properties),
|
|
31
|
+
operation_ids=operation_ids,
|
|
32
|
+
path_values=dict(path_values),
|
|
33
|
+
entities=entities,
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SirenEntityDecorator:
|
|
39
|
+
"""Turn a controller method's property mapping into a Siren entity document."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, resource_name: str, *, operations: tuple[str, ...]):
|
|
42
|
+
if not resource_name.strip():
|
|
43
|
+
raise ValueError("Siren resource name must not be blank")
|
|
44
|
+
self._resource_name = resource_name
|
|
45
|
+
self._operations = operations
|
|
46
|
+
|
|
47
|
+
def __call__(self, function: F) -> F:
|
|
48
|
+
signature = inspect.signature(function)
|
|
49
|
+
resource_name = self._resource_name
|
|
50
|
+
operations = self._operations
|
|
51
|
+
|
|
52
|
+
def path_values(args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
53
|
+
arguments = signature.bind(*args, **kwargs).arguments
|
|
54
|
+
return {name: value for name, value in arguments.items() if name not in {"self", "request"}}
|
|
55
|
+
|
|
56
|
+
if inspect.iscoroutinefunction(function):
|
|
57
|
+
|
|
58
|
+
@wraps(function)
|
|
59
|
+
async def async_wrapper(self: NinjaExtraSirenController, *args: Any, **kwargs: Any) -> dict[str, Any]:
|
|
60
|
+
properties = await function(self, *args, **kwargs)
|
|
61
|
+
return self.siren_document(
|
|
62
|
+
resource_name,
|
|
63
|
+
properties,
|
|
64
|
+
operations,
|
|
65
|
+
path_values((self, *args), kwargs),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return cast(F, async_wrapper)
|
|
69
|
+
|
|
70
|
+
@wraps(function)
|
|
71
|
+
def wrapper(self: NinjaExtraSirenController, *args: Any, **kwargs: Any) -> dict[str, Any]:
|
|
72
|
+
properties = function(self, *args, **kwargs)
|
|
73
|
+
return self.siren_document(
|
|
74
|
+
resource_name,
|
|
75
|
+
properties,
|
|
76
|
+
operations,
|
|
77
|
+
path_values((self, *args), kwargs),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return cast(F, wrapper)
|
|
File without changes
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
|
|
5
|
+
from ..contracts.operation import OpenApiOperation
|
|
6
|
+
from ..contracts.resource import SirenResource
|
|
7
|
+
from .error import OpenApiError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SirenResourceCatalog(ABC):
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def operation(self, operation_id: str) -> OpenApiOperation:
|
|
13
|
+
raise NotImplementedError
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def resource(self, name: str) -> SirenResource:
|
|
17
|
+
raise NotImplementedError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OpenApiCatalog(SirenResourceCatalog):
|
|
21
|
+
def __init__(self, operations: tuple[OpenApiOperation, ...], resources: tuple[SirenResource, ...]):
|
|
22
|
+
self._reject_duplicates("OpenAPI operationId", (item.operation_id for item in operations))
|
|
23
|
+
self._reject_duplicates("Siren resource name", (item.name for item in resources))
|
|
24
|
+
self._operations = {item.operation_id: item for item in operations}
|
|
25
|
+
self._resources = {item.name: item for item in resources}
|
|
26
|
+
self._validate_resources()
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def _reject_duplicates(label: str, values: Iterable[str]) -> None:
|
|
30
|
+
seen: set[str] = set()
|
|
31
|
+
duplicates: set[str] = set()
|
|
32
|
+
for value in values:
|
|
33
|
+
if value in seen:
|
|
34
|
+
duplicates.add(value)
|
|
35
|
+
seen.add(value)
|
|
36
|
+
if duplicates:
|
|
37
|
+
raise OpenApiError(f"Duplicate {label}: {sorted(duplicates)}")
|
|
38
|
+
|
|
39
|
+
def operation(self, operation_id: str) -> OpenApiOperation:
|
|
40
|
+
try:
|
|
41
|
+
return self._operations[operation_id]
|
|
42
|
+
except KeyError as error:
|
|
43
|
+
raise OpenApiError(f"Unknown OpenAPI operation: {operation_id}") from error
|
|
44
|
+
|
|
45
|
+
def resource(self, name: str) -> SirenResource:
|
|
46
|
+
try:
|
|
47
|
+
return self._resources[name]
|
|
48
|
+
except KeyError as error:
|
|
49
|
+
raise OpenApiError(f"Unknown Siren resource: {name}") from error
|
|
50
|
+
|
|
51
|
+
def _validate_resources(self) -> None:
|
|
52
|
+
for resource in self._resources.values():
|
|
53
|
+
placeholders = set(re.findall(r"{([^}]+)}", resource.path))
|
|
54
|
+
declared = set(resource.path_parameters)
|
|
55
|
+
if placeholders != declared:
|
|
56
|
+
raise OpenApiError(
|
|
57
|
+
f"Resource {resource.name!r} path parameters must explicitly map "
|
|
58
|
+
f"{sorted(placeholders)}; received {sorted(declared)}"
|
|
59
|
+
)
|
|
60
|
+
unknown = {relation.resource for relation in resource.relations} - set(self._resources)
|
|
61
|
+
if unknown:
|
|
62
|
+
raise OpenApiError(f"Resource {resource.name!r} references unknown resources: {sorted(unknown)}")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
|
|
5
|
+
from ..contracts.base import SirenContract
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OpenApiRelationExtension(SirenContract):
|
|
9
|
+
rel: str
|
|
10
|
+
resource: str
|
|
11
|
+
many: bool
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OpenApiResourceExtension(SirenContract):
|
|
15
|
+
name: str
|
|
16
|
+
resource_class: Annotated[str, Field(validation_alias="class")]
|
|
17
|
+
identifier: str
|
|
18
|
+
path_parameters: Annotated[dict[str, str], Field(validation_alias="path-parameters")]
|
|
19
|
+
relations: dict[str, OpenApiRelationExtension]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from .catalog import OpenApiCatalog
|
|
4
|
+
from .operation import OpenApiOperationReader
|
|
5
|
+
from .resolver import ComponentSchemaResolver
|
|
6
|
+
from .resource import OpenApiResourceReader
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OpenApiCatalogFactory:
|
|
10
|
+
@classmethod
|
|
11
|
+
def create(cls, schema: dict[str, Any]) -> OpenApiCatalog:
|
|
12
|
+
components = schema.get("components", {}).get("schemas", {})
|
|
13
|
+
paths = schema.get("paths", {})
|
|
14
|
+
operations = OpenApiOperationReader(ComponentSchemaResolver(components)).read(paths)
|
|
15
|
+
resources = OpenApiResourceReader().read(paths)
|
|
16
|
+
return OpenApiCatalog(operations, resources)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
from urllib.parse import quote, urljoin
|
|
6
|
+
|
|
7
|
+
from .error import OpenApiError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SirenHrefResolver(ABC):
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def resolve(self, path: str, values: Mapping[str, Any]) -> str:
|
|
13
|
+
raise NotImplementedError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OpenApiHrefResolver(SirenHrefResolver):
|
|
17
|
+
def __init__(self, base_url: str):
|
|
18
|
+
self._base_url = base_url.rstrip("/") + "/"
|
|
19
|
+
|
|
20
|
+
def resolve(self, path: str, values: Mapping[str, Any]) -> str:
|
|
21
|
+
resolved = path
|
|
22
|
+
for name in re.findall(r"{([^}]+)}", path):
|
|
23
|
+
if name not in values:
|
|
24
|
+
raise OpenApiError(f"Missing path value {name!r} for {path}")
|
|
25
|
+
resolved = resolved.replace(f"{{{name}}}", quote(str(values[name]), safe=""))
|
|
26
|
+
return urljoin(self._base_url, resolved.lstrip("/"))
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from ..contracts.operation import OpenApiField, OpenApiOperation
|
|
5
|
+
from ..standards import SirenMediaType
|
|
6
|
+
from .error import OpenApiError
|
|
7
|
+
from .method import OpenApiHttpMethod
|
|
8
|
+
from .resolver import OpenApiSchemaResolver
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OpenApiOperationSource(ABC):
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def read(self, paths: dict[str, Any]) -> tuple[OpenApiOperation, ...]:
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OpenApiOperationReader(OpenApiOperationSource):
|
|
18
|
+
def __init__(self, schemas: OpenApiSchemaResolver):
|
|
19
|
+
self._schemas = schemas
|
|
20
|
+
|
|
21
|
+
def read(self, paths: dict[str, Any]) -> tuple[OpenApiOperation, ...]:
|
|
22
|
+
return tuple(
|
|
23
|
+
self._operation(path, method, specification, path_item.get("parameters", ()))
|
|
24
|
+
for path, path_item in paths.items()
|
|
25
|
+
for method, specification in path_item.items()
|
|
26
|
+
if method.lower() in OpenApiHttpMethod and isinstance(specification, dict)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def _operation(
|
|
30
|
+
self,
|
|
31
|
+
path: str,
|
|
32
|
+
method: str,
|
|
33
|
+
specification: dict[str, Any],
|
|
34
|
+
path_parameters: list[dict[str, Any]] | tuple[dict[str, Any], ...],
|
|
35
|
+
) -> OpenApiOperation:
|
|
36
|
+
operation_id = specification.get("operationId")
|
|
37
|
+
if not operation_id:
|
|
38
|
+
raise OpenApiError(f"Siren requires operationId for {method.upper()} {path}")
|
|
39
|
+
return OpenApiOperation(
|
|
40
|
+
operation_id=operation_id,
|
|
41
|
+
method=method.upper(),
|
|
42
|
+
path=path,
|
|
43
|
+
title=specification.get("summary", operation_id),
|
|
44
|
+
fields=self._fields(specification, path_parameters),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def _fields(
|
|
48
|
+
self,
|
|
49
|
+
specification: dict[str, Any],
|
|
50
|
+
path_parameters: list[dict[str, Any]] | tuple[dict[str, Any], ...],
|
|
51
|
+
) -> tuple[OpenApiField, ...]:
|
|
52
|
+
parameters = {
|
|
53
|
+
(parameter.get("name"), parameter.get("in")): parameter
|
|
54
|
+
for parameter in (*path_parameters, *specification.get("parameters", ()))
|
|
55
|
+
}
|
|
56
|
+
query = tuple(
|
|
57
|
+
OpenApiField(
|
|
58
|
+
name=parameter["name"],
|
|
59
|
+
schema=self._schemas.resolve(parameter.get("schema", {})),
|
|
60
|
+
required=parameter.get("required", False),
|
|
61
|
+
)
|
|
62
|
+
for parameter in parameters.values()
|
|
63
|
+
if parameter.get("in") == "query"
|
|
64
|
+
)
|
|
65
|
+
content = specification.get("requestBody", {}).get("content", {})
|
|
66
|
+
media = content.get(SirenMediaType.ACTION) or next(iter(content.values()), {})
|
|
67
|
+
schema = self._schemas.resolve(media.get("schema", {}))
|
|
68
|
+
required = set(schema.get("required", []))
|
|
69
|
+
body = tuple(
|
|
70
|
+
OpenApiField(name=name, schema=self._schemas.resolve(value), required=name in required)
|
|
71
|
+
for name, value in schema.get("properties", {}).items()
|
|
72
|
+
)
|
|
73
|
+
return query + body
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from copy import deepcopy
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .error import OpenApiError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OpenApiSchemaResolver(ABC):
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def resolve(self, schema: dict[str, Any]) -> dict[str, Any]:
|
|
11
|
+
raise NotImplementedError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ComponentSchemaResolver(OpenApiSchemaResolver):
|
|
15
|
+
def __init__(self, components: dict[str, Any]):
|
|
16
|
+
self._components = deepcopy(components)
|
|
17
|
+
|
|
18
|
+
def resolve(self, schema: dict[str, Any]) -> dict[str, Any]:
|
|
19
|
+
return self._resolve(schema, ())
|
|
20
|
+
|
|
21
|
+
def _resolve(self, schema: dict[str, Any], resolving: tuple[str, ...]) -> dict[str, Any]:
|
|
22
|
+
resolved = deepcopy(schema)
|
|
23
|
+
reference = resolved.pop("$ref", None)
|
|
24
|
+
if reference:
|
|
25
|
+
component_name = reference.rsplit("/", 1)[-1]
|
|
26
|
+
if component_name not in self._components:
|
|
27
|
+
raise OpenApiError(f"Unknown OpenAPI schema reference: {reference}")
|
|
28
|
+
if component_name in resolving:
|
|
29
|
+
chain = " -> ".join((*resolving, component_name))
|
|
30
|
+
raise OpenApiError(f"Circular OpenAPI schema reference: {chain}")
|
|
31
|
+
resolved = deepcopy(self._components[component_name]) | resolved
|
|
32
|
+
resolving = (*resolving, component_name)
|
|
33
|
+
merged: dict[str, Any] = {}
|
|
34
|
+
for part in resolved.pop("allOf", ()):
|
|
35
|
+
merged = self._merge(merged, self._resolve(part, resolving))
|
|
36
|
+
resolved = self._merge(merged, resolved)
|
|
37
|
+
for keyword in ("anyOf", "oneOf", "prefixItems"):
|
|
38
|
+
if keyword in resolved:
|
|
39
|
+
resolved[keyword] = [self._resolve(item, resolving) for item in resolved[keyword]]
|
|
40
|
+
for keyword in ("items", "additionalProperties"):
|
|
41
|
+
if isinstance(resolved.get(keyword), dict):
|
|
42
|
+
resolved[keyword] = self._resolve(resolved[keyword], resolving)
|
|
43
|
+
if "properties" in resolved:
|
|
44
|
+
resolved["properties"] = {
|
|
45
|
+
name: self._resolve(value, resolving) for name, value in resolved["properties"].items()
|
|
46
|
+
}
|
|
47
|
+
return resolved
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def _merge(left: dict[str, Any], right: dict[str, Any]) -> dict[str, Any]:
|
|
51
|
+
merged = left | right
|
|
52
|
+
if "properties" in left or "properties" in right:
|
|
53
|
+
merged["properties"] = left.get("properties", {}) | right.get("properties", {})
|
|
54
|
+
if "required" in left or "required" in right:
|
|
55
|
+
merged["required"] = list(dict.fromkeys((*left.get("required", ()), *right.get("required", ()))))
|
|
56
|
+
return merged
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from ..contracts.resource import SirenRelation, SirenResource
|
|
5
|
+
from ..standards import SirenOpenApiExtension
|
|
6
|
+
from .contracts import OpenApiResourceExtension
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OpenApiResourceSource(ABC):
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def read(self, paths: dict[str, Any]) -> tuple[SirenResource, ...]:
|
|
12
|
+
raise NotImplementedError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OpenApiResourceReader(OpenApiResourceSource):
|
|
16
|
+
def read(self, paths: dict[str, Any]) -> tuple[SirenResource, ...]:
|
|
17
|
+
return tuple(
|
|
18
|
+
self._resource(path, raw_extension)
|
|
19
|
+
for path, path_item in paths.items()
|
|
20
|
+
if (raw_extension := path_item.get(SirenOpenApiExtension.RESOURCE))
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
def _resource(self, path: str, raw_extension: dict[str, Any]) -> SirenResource:
|
|
24
|
+
extension = OpenApiResourceExtension.model_validate(raw_extension)
|
|
25
|
+
return SirenResource(
|
|
26
|
+
name=extension.name,
|
|
27
|
+
path=path,
|
|
28
|
+
resource_class=extension.resource_class,
|
|
29
|
+
identifier=extension.identifier,
|
|
30
|
+
path_parameters=extension.path_parameters,
|
|
31
|
+
relations=tuple(
|
|
32
|
+
SirenRelation(field=field, **relation.model_dump()) for field, relation in extension.relations.items()
|
|
33
|
+
),
|
|
34
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import ClassVar
|
|
3
|
+
|
|
4
|
+
from ..standards import SirenFieldType
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SirenFieldTypeResolver(ABC):
|
|
8
|
+
@abstractmethod
|
|
9
|
+
def resolve(self, schema: dict) -> SirenFieldType:
|
|
10
|
+
raise NotImplementedError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OpenApiSirenFieldTypeResolver(SirenFieldTypeResolver):
|
|
14
|
+
TYPE_MAP: ClassVar = {
|
|
15
|
+
"boolean": SirenFieldType.CHECKBOX,
|
|
16
|
+
"integer": SirenFieldType.NUMBER,
|
|
17
|
+
"string": SirenFieldType.TEXT,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
def resolve(self, schema: dict) -> SirenFieldType:
|
|
21
|
+
kind = schema.get("type", "string")
|
|
22
|
+
if kind in {"array", "object"} or "anyOf" in schema:
|
|
23
|
+
return SirenFieldType.JSON
|
|
24
|
+
return self.TYPE_MAP.get(kind, SirenFieldType.TEXT)
|
modwire_siren/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# typed
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from .contracts.entity import SirenEntity
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SirenSerializer(ABC):
|
|
8
|
+
@abstractmethod
|
|
9
|
+
def serialize(self, entity: SirenEntity) -> dict[str, Any]:
|
|
10
|
+
raise NotImplementedError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PydanticSirenSerializer(SirenSerializer):
|
|
14
|
+
def serialize(self, entity: SirenEntity) -> dict[str, Any]:
|
|
15
|
+
return entity.model_dump(mode="json", by_alias=True)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SirenMediaType(StrEnum):
|
|
5
|
+
ACTION = "application/json"
|
|
6
|
+
ENTITY = "application/vnd.siren+json"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SirenRelationName(StrEnum):
|
|
10
|
+
SELF = "self"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SirenFieldType(StrEnum):
|
|
14
|
+
CHECKBOX = "checkbox"
|
|
15
|
+
JSON = "json"
|
|
16
|
+
NUMBER = "number"
|
|
17
|
+
TEXT = "text"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SirenOpenApiExtension(StrEnum):
|
|
21
|
+
RESOURCE = "x-siren-resource"
|
|
@@ -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,37 @@
|
|
|
1
|
+
modwire_siren/__init__.py,sha256=J-p2o0e1M4belucGsiXkKRF71NrJ8OWQYz4mO9XG6Ms,475
|
|
2
|
+
modwire_siren/_version.py,sha256=n_5vdJsPNu7wZ57LGuRL585uvll-hiuvZUBWzdG0RQU,520
|
|
3
|
+
modwire_siren/composition.py,sha256=1rAw--WSHZxdOLsgOKPCrXRNazHxBcl8NlgTTNy_KNI,1317
|
|
4
|
+
modwire_siren/facade.py,sha256=0EFpJclK4vVSCU6D7Ptr5gQ4pl24yx8H9PrVcDw3R5k,627
|
|
5
|
+
modwire_siren/py.typed,sha256=PkRcTGuZkArgW1q-rCE9SkWNSwwdgEXZnIl5CY27NgY,7
|
|
6
|
+
modwire_siren/serialization.py,sha256=Ib0YkbaitVgg2KDA1_iOMMQl6qPok1rr6BT2Cs54l6k,425
|
|
7
|
+
modwire_siren/standards.py,sha256=U1sEaB-WlC7S9jcm4Ih4BEYWG8b8Y8uNsIJIfO5amlE,377
|
|
8
|
+
modwire_siren/contracts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
modwire_siren/contracts/action.py,sha256=Q26YC2xRHATDK_nQyH_pda-EFZoAZc7X7DWaDqbOYSA,686
|
|
10
|
+
modwire_siren/contracts/base.py,sha256=bNXaMJwu7yr-BjfJVDnmDp2zwGRHPNvj21bsqMR1Y5c,149
|
|
11
|
+
modwire_siren/contracts/entity.py,sha256=YSGwTnzPolFRYkp9ilbtVYdfHsesPUrUz51Q8dp1Jfg,1042
|
|
12
|
+
modwire_siren/contracts/link.py,sha256=_w9bxNjQgUrEPSlknIJKtu5Zvid3gtdGDt7Z34jmSMQ,244
|
|
13
|
+
modwire_siren/contracts/operation.py,sha256=rn-aBdXM8bfXF6KuVy7kaJb14HWp2rP-b0dkUNCARGA,393
|
|
14
|
+
modwire_siren/contracts/resource.py,sha256=JxxiZM6a3lk0Bvf9cq5dxN3YXXXZ1fhTf_6BKV091K4,318
|
|
15
|
+
modwire_siren/factories/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
modwire_siren/factories/action.py,sha256=7gGptM6_KTuPXaomqmLkCEFKOCh3sEouql9mTm6_VWk,1028
|
|
17
|
+
modwire_siren/factories/entity.py,sha256=lkDR9b7cZ4bUeUP-gjpGVTQ85VYW06ajWjiHnE6m4cw,1592
|
|
18
|
+
modwire_siren/factories/field.py,sha256=Bc5_t6_7g0-PWZzKzYmfjUqokFsBlVKWqIhD0_K2tfY,1033
|
|
19
|
+
modwire_siren/factories/link.py,sha256=evJv4eXAJajr6wzBeoJgJKTb1YUMA15stoLCVkC4qjU,2685
|
|
20
|
+
modwire_siren/factories/resource.py,sha256=8VvMZe-DeQ7eHgcg5Txe61l5A9I6Kelwp_zLnezJppg,1274
|
|
21
|
+
modwire_siren/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
22
|
+
modwire_siren/integrations/ninja_extra.py,sha256=5_X5GV5_6UpayiDSAzLo0QpCzNjvjXHIRzoJtxnHaEE,2857
|
|
23
|
+
modwire_siren/openapi/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
24
|
+
modwire_siren/openapi/catalog.py,sha256=OXMUZctNO-rFGL3jQ0mBoLyXbdcbGx5fgVdZEOOJE1I,2553
|
|
25
|
+
modwire_siren/openapi/contracts.py,sha256=B3y76D179HgXq-6vNGAt5IWJ_dCt75CQejLa9L8tXl4,488
|
|
26
|
+
modwire_siren/openapi/error.py,sha256=J3ly-HXuICvUqD6tmWk1KEdmqvIEFE_K5_nei2xQDA0,121
|
|
27
|
+
modwire_siren/openapi/factory.py,sha256=L-v_Gc9vz-AgDZVfpYORUbgAGeJTIDjQM_kkVUuQHms,618
|
|
28
|
+
modwire_siren/openapi/href.py,sha256=znjHK0FfvAYgaFB8w_2095u6Sa2OogBAY-4k6Bhb8Rs,880
|
|
29
|
+
modwire_siren/openapi/method.py,sha256=R2cLl4PolGQNYlFBhXL_U7eh-YElaskk9K0rAvxUnmg,215
|
|
30
|
+
modwire_siren/openapi/operation.py,sha256=NDnB0ZKRAm6HBMn7SXna9PvC6byL-BgaEdNe9Tx3e4s,2855
|
|
31
|
+
modwire_siren/openapi/resolver.py,sha256=UYfrYr4MoFTUEVzCULe1w-i-JNjabEvHxJviQ1BqTao,2530
|
|
32
|
+
modwire_siren/openapi/resource.py,sha256=nJtfsjE_BXwvmNtNdgXQhYuAhucgL2PEj-89dL3yeio,1285
|
|
33
|
+
modwire_siren/policies/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
34
|
+
modwire_siren/policies/field_type.py,sha256=LgTl7u54uIAOJ3yGqfIFuN6pAJ-DiSnigKnVXtgHs6c,731
|
|
35
|
+
modwire_siren-0.1.0.dist-info/METADATA,sha256=iUPdMTJD0KTvnZt5rgFcKNsmjfPFYEoXLbM_APqXNVM,5496
|
|
36
|
+
modwire_siren-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
37
|
+
modwire_siren-0.1.0.dist-info/RECORD,,
|