httk-serve 2.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.
- httk/registry/cli/serve/__init__.py +9 -0
- httk/registry/cli/serve/py.typed +1 -0
- httk/registry/entries/serve/__init__.py +13 -0
- httk/registry/entries/serve/py.typed +1 -0
- httk/serve/__init__.py +16 -0
- httk/serve/composition.py +141 -0
- httk/serve/dsp/__init__.py +92 -0
- httk/serve/dsp/api.py +169 -0
- httk/serve/dsp/callbacks.py +24 -0
- httk/serve/dsp/catalogue.py +312 -0
- httk/serve/dsp/config.py +338 -0
- httk/serve/dsp/models.py +290 -0
- httk/serve/dsp/provider.py +1056 -0
- httk/serve/dsp/py.typed +1 -0
- httk/serve/dsp/schemas/LICENSE-APACHE-2.0.txt +176 -0
- httk/serve/dsp/schemas/LICENSE-CC-BY-3.0.txt +92 -0
- httk/serve/dsp/schemas/LICENSE-CC-BY-4.0.txt +396 -0
- httk/serve/dsp/schemas/README.md +20 -0
- httk/serve/dsp/schemas/catalog/catalog-error-schema.json +35 -0
- httk/serve/dsp/schemas/catalog/catalog-request-message-schema.json +32 -0
- httk/serve/dsp/schemas/catalog/catalog-schema.json +83 -0
- httk/serve/dsp/schemas/catalog/dataset-request-message-schema.json +33 -0
- httk/serve/dsp/schemas/catalog/dataset-schema.json +150 -0
- httk/serve/dsp/schemas/catalog/examples/catalog-error.json +10 -0
- httk/serve/dsp/schemas/catalog/examples/catalog-request-message.json +7 -0
- httk/serve/dsp/schemas/catalog/examples/catalog.json +46 -0
- httk/serve/dsp/schemas/catalog/examples/dataset-request-message.json +7 -0
- httk/serve/dsp/schemas/catalog/examples/dataset.json +36 -0
- httk/serve/dsp/schemas/catalog/examples/nested-catalog.json +28 -0
- httk/serve/dsp/schemas/common/context-schema.json +28 -0
- httk/serve/dsp/schemas/common/examples/protocol-version.json +40 -0
- httk/serve/dsp/schemas/common/protocol-version-schema.json +92 -0
- httk/serve/dsp/schemas/context/dspace.jsonld +451 -0
- httk/serve/dsp/schemas/context/odrl.jsonld +96 -0
- httk/serve/dsp/schemas/dcat-ap/dcat-ap-SHACL.ttl +2217 -0
- httk/serve/dsp/schemas/dcat-ap/dcat-ap-context.jsonld +675 -0
- httk/serve/dsp/schemas/dcat-ap/dcat-ap-controlled-vocabularies.shape.ttl +491 -0
- httk/serve/dsp/schemas/dcat-ap/dcat-ap-ranges.ttl +1232 -0
- httk/serve/dsp/schemas/dcat-ap/eu-file-type-csv.rdf +73 -0
- httk/serve/dsp/schemas/dcat-ap/eu-file-type-json-ld.rdf +59 -0
- httk/serve/dsp/schemas/dcat-ap/eu-file-type-json.rdf +61 -0
- httk/serve/dsp/schemas/dcat-ap/spdx-2.3-ontology.owl.xml +3724 -0
- httk/serve/dsp/schemas/negotiation/contract-agreement-message-schema.json +41 -0
- httk/serve/dsp/schemas/negotiation/contract-agreement-verification-message-schema.json +37 -0
- httk/serve/dsp/schemas/negotiation/contract-negotiation-error-schema.json +45 -0
- httk/serve/dsp/schemas/negotiation/contract-negotiation-event-message-schema.json +45 -0
- httk/serve/dsp/schemas/negotiation/contract-negotiation-schema.json +50 -0
- httk/serve/dsp/schemas/negotiation/contract-negotiation-termination-message-schema.json +45 -0
- httk/serve/dsp/schemas/negotiation/contract-offer-message-schema.json +73 -0
- httk/serve/dsp/schemas/negotiation/contract-request-message-schema.json +56 -0
- httk/serve/dsp/schemas/negotiation/contract-schema.json +374 -0
- httk/serve/dsp/schemas/negotiation/examples/contract-agreement-message-full.json +143 -0
- httk/serve/dsp/schemas/negotiation/examples/contract-agreement-message.json +28 -0
- httk/serve/dsp/schemas/negotiation/examples/contract-agreement-verification-message.json +8 -0
- httk/serve/dsp/schemas/negotiation/examples/contract-negotiation-error.json +12 -0
- httk/serve/dsp/schemas/negotiation/examples/contract-negotiation-event-message.json +9 -0
- httk/serve/dsp/schemas/negotiation/examples/contract-negotiation-termination-message.json +12 -0
- httk/serve/dsp/schemas/negotiation/examples/contract-negotiation.json +9 -0
- httk/serve/dsp/schemas/negotiation/examples/contract-offer-message.json +25 -0
- httk/serve/dsp/schemas/negotiation/examples/contract-offer-message_initial.json +18 -0
- httk/serve/dsp/schemas/negotiation/examples/contract-request-message.json +25 -0
- httk/serve/dsp/schemas/negotiation/examples/contract-request-message_initial.json +18 -0
- httk/serve/dsp/schemas/openapi.yaml +282 -0
- httk/serve/dsp/schemas/profile/context.jsonld +86 -0
- httk/serve/dsp/schemas/profile/dcat-ap-catalogue-schema.json +97 -0
- httk/serve/dsp/schemas/profile/example-catalogue.json +181 -0
- httk/serve/dsp/schemas/profile/example-http-pull-configuration.json +8 -0
- httk/serve/dsp/schemas/profile/http-pull-profile-schema.json +21 -0
- httk/serve/dsp/schemas/provenance.json +76 -0
- httk/serve/dsp/schemas/transfer/data-address-schema.json +59 -0
- httk/serve/dsp/schemas/transfer/examples/transfer-completion-message.json +8 -0
- httk/serve/dsp/schemas/transfer/examples/transfer-error.json +12 -0
- httk/serve/dsp/schemas/transfer/examples/transfer-process.json +9 -0
- httk/serve/dsp/schemas/transfer/examples/transfer-request-message.json +27 -0
- httk/serve/dsp/schemas/transfer/examples/transfer-start-message.json +25 -0
- httk/serve/dsp/schemas/transfer/examples/transfer-suspension-message.json +11 -0
- httk/serve/dsp/schemas/transfer/examples/transfer-termination-message.json +12 -0
- httk/serve/dsp/schemas/transfer/transfer-completion-message-schema.json +37 -0
- httk/serve/dsp/schemas/transfer/transfer-error-schema.json +37 -0
- httk/serve/dsp/schemas/transfer/transfer-process-schema.json +48 -0
- httk/serve/dsp/schemas/transfer/transfer-request-message-schema.json +48 -0
- httk/serve/dsp/schemas/transfer/transfer-schema.json +41 -0
- httk/serve/dsp/schemas/transfer/transfer-start-message-schema.json +40 -0
- httk/serve/dsp/schemas/transfer/transfer-suspension-message-schema.json +37 -0
- httk/serve/dsp/schemas/transfer/transfer-termination-message-schema.json +31 -0
- httk/serve/dsp/serializers.py +300 -0
- httk/serve/dsp/state.py +378 -0
- httk/serve/dsp/validation.py +39 -0
- httk/serve/http/__init__.py +50 -0
- httk/serve/http/accept.py +227 -0
- httk/serve/http/api.py +194 -0
- httk/serve/http/apptypes.py +17 -0
- httk/serve/http/fields.py +46 -0
- httk/serve/http/identifiers.py +50 -0
- httk/serve/http/openapi/__init__.py +59 -0
- httk/serve/http/openapi/app.py +654 -0
- httk/serve/http/openapi/binding.py +373 -0
- httk/serve/http/openapi/contract.py +110 -0
- httk/serve/http/openapi/resources.py +83 -0
- httk/serve/http/openapi/schemas.py +93 -0
- httk/serve/http/py.typed +0 -0
- httk/serve/http/webhook.py +469 -0
- httk/serve/jsondata.py +78 -0
- httk/serve/optimade/__init__.py +86 -0
- httk/serve/optimade/api.py +149 -0
- httk/serve/optimade/backend/__init__.py +46 -0
- httk/serve/optimade/backend/_property_handlers.py +45 -0
- httk/serve/optimade/backend/adapter.py +137 -0
- httk/serve/optimade/backend/execution.py +198 -0
- httk/serve/optimade/backend/handlers.py +63 -0
- httk/serve/optimade/backend/memory_store.py +460 -0
- httk/serve/optimade/backend/partial.py +46 -0
- httk/serve/optimade/backend/protocols.py +30 -0
- httk/serve/optimade/backend/providers.py +283 -0
- httk/serve/optimade/backend/stores.py +458 -0
- httk/serve/optimade/backend/translation.py +200 -0
- httk/serve/optimade/client.py +742 -0
- httk/serve/optimade/endpoints/__init__.py +24 -0
- httk/serve/optimade/endpoints/entries.py +349 -0
- httk/serve/optimade/endpoints/error.py +56 -0
- httk/serve/optimade/endpoints/info.py +193 -0
- httk/serve/optimade/endpoints/meta.py +104 -0
- httk/serve/optimade/endpoints/partial_data.py +97 -0
- httk/serve/optimade/engine/__init__.py +6 -0
- httk/serve/optimade/engine/processing.py +399 -0
- httk/serve/optimade/engine/validate.py +488 -0
- httk/serve/optimade/filter/__init__.py +20 -0
- httk/serve/optimade/model/__init__.py +29 -0
- httk/serve/optimade/model/config.py +135 -0
- httk/serve/optimade/model/errors.py +41 -0
- httk/serve/optimade/model/request.py +173 -0
- httk/serve/optimade/model/results.py +110 -0
- httk/serve/optimade/model/versions.py +6 -0
- httk/serve/optimade/py.typed +0 -0
- httk/serve/optimade/remote_query.py +1666 -0
- httk/serve/optimade/runtime/__init__.py +6 -0
- httk/serve/optimade/runtime/asgi.py +249 -0
- httk/serve/optimade/runtime/devserver.py +16 -0
- httk/serve/optimade/schema/__init__.py +5 -0
- httk/serve/optimade/schema/served.py +461 -0
- httk/serve/optimade/schema/trajectories.py +137 -0
- httk/serve/py.typed +0 -0
- httk/serve/web/__init__.py +17 -0
- httk/serve/web/api.py +132 -0
- httk/serve/web/assets/serve-optimade-fields.css +22 -0
- httk/serve/web/assets/serve-optimade-table-protocol.mjs +568 -0
- httk/serve/web/assets/serve-optimade-table.css +56 -0
- httk/serve/web/assets/serve-optimade-table.mjs +937 -0
- httk/serve/web/assets/serve-table.css +38 -0
- httk/serve/web/assets/serve-table.js +71 -0
- httk/serve/web/cli.py +238 -0
- httk/serve/web/compat/__init__.py +1 -0
- httk/serve/web/engine/__init__.py +5 -0
- httk/serve/web/engine/discovery.py +64 -0
- httk/serve/web/engine/site_engine.py +1001 -0
- httk/serve/web/functions/__init__.py +5 -0
- httk/serve/web/functions/python_module.py +160 -0
- httk/serve/web/model/__init__.py +31 -0
- httk/serve/web/model/config.py +102 -0
- httk/serve/web/model/errors.py +94 -0
- httk/serve/web/model/page.py +53 -0
- httk/serve/web/model/request.py +19 -0
- httk/serve/web/providers.py +366 -0
- httk/serve/web/publishing/__init__.py +5 -0
- httk/serve/web/publishing/static.py +82 -0
- httk/serve/web/py.typed +0 -0
- httk/serve/web/renderers/__init__.py +24 -0
- httk/serve/web/renderers/_frontmatter.py +51 -0
- httk/serve/web/renderers/base.py +52 -0
- httk/serve/web/renderers/html.py +22 -0
- httk/serve/web/renderers/httkweb_compat.py +46 -0
- httk/serve/web/renderers/markdown.py +33 -0
- httk/serve/web/renderers/rst.py +105 -0
- httk/serve/web/resources.py +95 -0
- httk/serve/web/runtime/__init__.py +5 -0
- httk/serve/web/runtime/asgi.py +241 -0
- httk/serve/web/runtime/devserver.py +15 -0
- httk/serve/web/templating/__init__.py +7 -0
- httk/serve/web/templating/_legacy_formatter.py +183 -0
- httk/serve/web/templating/base.py +32 -0
- httk/serve/web/templating/httk_compat.py +81 -0
- httk/serve/web/templating/jinja2_engine.py +168 -0
- httk/serve/web/widgets/__init__.py +34 -0
- httk/serve/web/widgets/assets.py +68 -0
- httk/serve/web/widgets/core.py +265 -0
- httk/serve/web/widgets/extraction.py +331 -0
- httk/serve/web/widgets/loader.py +126 -0
- httk/serve/web/widgets/optimade_assets.py +36 -0
- httk/serve/web/widgets/optimade_fields.py +125 -0
- httk/serve/web/widgets/optimade_table.py +645 -0
- httk/serve/web/widgets/table.py +812 -0
- httk_serve-2.1.0.dist-info/METADATA +115 -0
- httk_serve-2.1.0.dist-info/RECORD +196 -0
- httk_serve-2.1.0.dist-info/WHEEL +5 -0
- httk_serve-2.1.0.dist-info/licenses/LICENSE +661 -0
- httk_serve-2.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Register httk-serve's durable, non-OPTIMADE publication family."""
|
|
2
|
+
|
|
3
|
+
from httk.core import register_entry_family, register_entry_record
|
|
4
|
+
|
|
5
|
+
register_entry_family(
|
|
6
|
+
name="dsp-publications",
|
|
7
|
+
family="httk.serve.dsp.config:DspPublicationEntry",
|
|
8
|
+
)
|
|
9
|
+
register_entry_record(
|
|
10
|
+
name="serve-dsp-publication",
|
|
11
|
+
record="httk.serve.dsp.config:DspPublicationRecord",
|
|
12
|
+
family="dsp-publications",
|
|
13
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
httk/serve/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Provide unified web-serving, application-composition, and OPTIMADE capabilities."""
|
|
2
|
+
|
|
3
|
+
from .composition import ASGIAppMount, compose_asgi_apps
|
|
4
|
+
from .http.apptypes import ServeApp
|
|
5
|
+
from .jsondata import FrozenJsonValue, JsonScalar, JsonValue, freeze_json, thaw_json
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ASGIAppMount",
|
|
9
|
+
"FrozenJsonValue",
|
|
10
|
+
"JsonScalar",
|
|
11
|
+
"JsonValue",
|
|
12
|
+
"ServeApp",
|
|
13
|
+
"compose_asgi_apps",
|
|
14
|
+
"freeze_json",
|
|
15
|
+
"thaw_json",
|
|
16
|
+
]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Compose caller-owned Starlette applications at explicit URL paths."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from contextlib import AsyncExitStack, asynccontextmanager
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from starlette.applications import Starlette
|
|
9
|
+
from starlette.routing import BaseRoute, Mount
|
|
10
|
+
from starlette.types import Receive, Scope, Send
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _validate_mount_path(path: object) -> str:
|
|
14
|
+
"""Validate and return a canonical absolute mount path."""
|
|
15
|
+
if not isinstance(path, str) or not path:
|
|
16
|
+
raise ValueError("mount paths must be non-empty strings")
|
|
17
|
+
if path == "/":
|
|
18
|
+
return path
|
|
19
|
+
if (
|
|
20
|
+
not path.startswith("/")
|
|
21
|
+
or path.endswith("/")
|
|
22
|
+
or "//" in path
|
|
23
|
+
or "\\" in path
|
|
24
|
+
or "?" in path
|
|
25
|
+
or "#" in path
|
|
26
|
+
or any(ord(character) < 32 or ord(character) == 127 for character in path)
|
|
27
|
+
):
|
|
28
|
+
raise ValueError("mount paths must be canonical absolute paths")
|
|
29
|
+
segments = path.split("/")
|
|
30
|
+
if any(segment in ("", ".", "..") for segment in segments[1:]):
|
|
31
|
+
raise ValueError("mount paths must be canonical absolute paths")
|
|
32
|
+
return path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class ASGIAppMount:
|
|
37
|
+
"""Associate a Starlette application with an explicit absolute path.
|
|
38
|
+
|
|
39
|
+
:param path: Canonical absolute URL path, with ``/`` reserved for a root
|
|
40
|
+
fallback.
|
|
41
|
+
:param app: Starlette application to mount.
|
|
42
|
+
|
|
43
|
+
The composed application owns the mounted applications for the duration of
|
|
44
|
+
its lifespan and enters each distinct child's Starlette lifespan once.
|
|
45
|
+
Constructing a mount or a composition does not close or otherwise mutate
|
|
46
|
+
caller-owned applications when validation fails.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
path: str
|
|
50
|
+
app: Starlette
|
|
51
|
+
|
|
52
|
+
def __post_init__(self) -> None:
|
|
53
|
+
_validate_mount_path(self.path)
|
|
54
|
+
if not isinstance(self.app, Starlette):
|
|
55
|
+
raise TypeError("ASGIAppMount.app must be a Starlette application")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _MountedChild:
|
|
59
|
+
"""Own one child's yielded lifespan state and route calls to that child."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, app: Starlette) -> None:
|
|
62
|
+
self.app = app
|
|
63
|
+
self.state: dict[str, Any] = {}
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def routes(self) -> list[BaseRoute]:
|
|
67
|
+
"""Return the live child route table for Starlette reverse routing."""
|
|
68
|
+
return self.app.routes
|
|
69
|
+
|
|
70
|
+
@asynccontextmanager
|
|
71
|
+
async def lifespan(self):
|
|
72
|
+
try:
|
|
73
|
+
async with self.app.router.lifespan_context(self.app) as state:
|
|
74
|
+
self.state = dict(state or {})
|
|
75
|
+
yield
|
|
76
|
+
finally:
|
|
77
|
+
self.state.clear()
|
|
78
|
+
|
|
79
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
80
|
+
child_scope = dict(scope)
|
|
81
|
+
if scope["type"] in {"http", "websocket"}:
|
|
82
|
+
child_scope["state"] = {**dict(scope.get("state") or {}), **self.state}
|
|
83
|
+
await self.app(child_scope, receive, send)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _ordered_mounts(mounts: Iterable[ASGIAppMount], root: ASGIAppMount | None) -> list[ASGIAppMount]:
|
|
87
|
+
"""Validate mount ownership and return routes from specific to broad."""
|
|
88
|
+
supplied = list(mounts)
|
|
89
|
+
if any(not isinstance(mount, ASGIAppMount) for mount in supplied):
|
|
90
|
+
raise TypeError("mounts must contain ASGIAppMount instances")
|
|
91
|
+
if root is not None and not isinstance(root, ASGIAppMount):
|
|
92
|
+
raise TypeError("root must be an ASGIAppMount instance or None")
|
|
93
|
+
|
|
94
|
+
paths = [mount.path for mount in supplied]
|
|
95
|
+
if "/" in paths:
|
|
96
|
+
raise ValueError("the root mount must be supplied with root=, not among mounts")
|
|
97
|
+
if root is not None and root.path != "/":
|
|
98
|
+
raise ValueError("root mount path must be '/'")
|
|
99
|
+
if len(paths) != len(set(paths)):
|
|
100
|
+
raise ValueError("mount paths must be unique")
|
|
101
|
+
|
|
102
|
+
return sorted(supplied, key=lambda mount: (-mount.path.count("/"), mount.path))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def compose_asgi_apps(mounts: Iterable[ASGIAppMount], *, root: ASGIAppMount | None = None) -> Starlette:
|
|
106
|
+
"""Compose Starlette applications at caller-selected URL paths.
|
|
107
|
+
|
|
108
|
+
More-specific paths are routed before their ancestors regardless of caller
|
|
109
|
+
order. At application startup, each distinct mounted child is entered in
|
|
110
|
+
that deterministic route order and is exited in reverse order; a failed
|
|
111
|
+
startup unwinds children that already started. The returned parent owns
|
|
112
|
+
lifespan coordination but not application configuration or data.
|
|
113
|
+
|
|
114
|
+
:param mounts: Service mounts with paths other than ``/``.
|
|
115
|
+
:param root: Optional ``ASGIAppMount`` at ``/`` used as the fallback route.
|
|
116
|
+
:return: Parent Starlette application containing the requested mounts.
|
|
117
|
+
:raises TypeError: If a descriptor or application has the wrong type.
|
|
118
|
+
:raises ValueError: If paths are malformed, noncanonical, or duplicated.
|
|
119
|
+
"""
|
|
120
|
+
ordered = _ordered_mounts(mounts, root)
|
|
121
|
+
all_mounts = ordered + ([root] if root is not None else [])
|
|
122
|
+
children_by_id: dict[int, _MountedChild] = {}
|
|
123
|
+
children: list[_MountedChild] = []
|
|
124
|
+
routes: list[Mount] = []
|
|
125
|
+
for mount in all_mounts:
|
|
126
|
+
app_id = id(mount.app)
|
|
127
|
+
child = children_by_id.get(app_id)
|
|
128
|
+
if child is None:
|
|
129
|
+
child = _MountedChild(mount.app)
|
|
130
|
+
children_by_id[app_id] = child
|
|
131
|
+
children.append(child)
|
|
132
|
+
routes.append(Mount(mount.path, app=child))
|
|
133
|
+
|
|
134
|
+
@asynccontextmanager
|
|
135
|
+
async def lifespan(_app: Starlette):
|
|
136
|
+
async with AsyncExitStack() as stack:
|
|
137
|
+
for child in children:
|
|
138
|
+
await stack.enter_async_context(child.lifespan())
|
|
139
|
+
yield
|
|
140
|
+
|
|
141
|
+
return Starlette(routes=routes, lifespan=lifespan)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Serve the DSP 2025-1 minimal public-catalogue profile."""
|
|
2
|
+
|
|
3
|
+
from .api import create_dsp_app
|
|
4
|
+
from .callbacks import CallbackSender, CallbackTransportError, DefaultCallbackSender, callback_url
|
|
5
|
+
from .catalogue import (
|
|
6
|
+
DCAT_MEDIA_TYPE,
|
|
7
|
+
DCAT_PROFILE,
|
|
8
|
+
DspCataloguePolicy,
|
|
9
|
+
DspCatalogueRepresentation,
|
|
10
|
+
MinimalDspCataloguePolicy,
|
|
11
|
+
)
|
|
12
|
+
from .config import (
|
|
13
|
+
DCAT_AP_3_0_1_PROFILE,
|
|
14
|
+
DCAT_AP_MINIMAL_CONTENT_NEGOTIATION,
|
|
15
|
+
DCAT_AP_MINIMAL_PROFILE,
|
|
16
|
+
DSP_2025_1_SPECIFICATION,
|
|
17
|
+
DSP_CONTEXT,
|
|
18
|
+
DSP_MINIMAL_PROFILE,
|
|
19
|
+
DSP_VERSION,
|
|
20
|
+
EU_FILE_TYPE_CSV,
|
|
21
|
+
EU_FILE_TYPE_JSON,
|
|
22
|
+
HTTP_ENDPOINT_TYPE,
|
|
23
|
+
IANA_MEDIA_TYPE_CSV,
|
|
24
|
+
IANA_MEDIA_TYPE_JSON,
|
|
25
|
+
SPDX_SHA256,
|
|
26
|
+
DspDatasetPublication,
|
|
27
|
+
DspProviderConfig,
|
|
28
|
+
DspPublicationEntry,
|
|
29
|
+
DspPublicationRecord,
|
|
30
|
+
)
|
|
31
|
+
from .models import (
|
|
32
|
+
AgreementRecord,
|
|
33
|
+
CatalogueProfile,
|
|
34
|
+
DataServiceProfile,
|
|
35
|
+
DatasetProfile,
|
|
36
|
+
DcatDataServiceProfile,
|
|
37
|
+
DeliveryStatus,
|
|
38
|
+
DistributionProfile,
|
|
39
|
+
DspProtocolError,
|
|
40
|
+
DspTransitionSuperseded,
|
|
41
|
+
JsonValue,
|
|
42
|
+
NegotiationRecord,
|
|
43
|
+
OfferProfile,
|
|
44
|
+
TransferRecord,
|
|
45
|
+
)
|
|
46
|
+
from .provider import DspProvider, UtcClock, UuidFactory
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"DCAT_AP_3_0_1_PROFILE",
|
|
50
|
+
"DCAT_AP_MINIMAL_CONTENT_NEGOTIATION",
|
|
51
|
+
"DCAT_AP_MINIMAL_PROFILE",
|
|
52
|
+
"DCAT_MEDIA_TYPE",
|
|
53
|
+
"DCAT_PROFILE",
|
|
54
|
+
"DSP_2025_1_SPECIFICATION",
|
|
55
|
+
"DSP_CONTEXT",
|
|
56
|
+
"DSP_MINIMAL_PROFILE",
|
|
57
|
+
"DSP_VERSION",
|
|
58
|
+
"EU_FILE_TYPE_CSV",
|
|
59
|
+
"EU_FILE_TYPE_JSON",
|
|
60
|
+
"HTTP_ENDPOINT_TYPE",
|
|
61
|
+
"IANA_MEDIA_TYPE_CSV",
|
|
62
|
+
"IANA_MEDIA_TYPE_JSON",
|
|
63
|
+
"SPDX_SHA256",
|
|
64
|
+
"AgreementRecord",
|
|
65
|
+
"CallbackSender",
|
|
66
|
+
"CallbackTransportError",
|
|
67
|
+
"CatalogueProfile",
|
|
68
|
+
"DataServiceProfile",
|
|
69
|
+
"DatasetProfile",
|
|
70
|
+
"DcatDataServiceProfile",
|
|
71
|
+
"DefaultCallbackSender",
|
|
72
|
+
"DeliveryStatus",
|
|
73
|
+
"DistributionProfile",
|
|
74
|
+
"DspCataloguePolicy",
|
|
75
|
+
"DspCatalogueRepresentation",
|
|
76
|
+
"DspDatasetPublication",
|
|
77
|
+
"DspProtocolError",
|
|
78
|
+
"DspProvider",
|
|
79
|
+
"DspProviderConfig",
|
|
80
|
+
"DspPublicationEntry",
|
|
81
|
+
"DspPublicationRecord",
|
|
82
|
+
"DspTransitionSuperseded",
|
|
83
|
+
"JsonValue",
|
|
84
|
+
"MinimalDspCataloguePolicy",
|
|
85
|
+
"NegotiationRecord",
|
|
86
|
+
"OfferProfile",
|
|
87
|
+
"TransferRecord",
|
|
88
|
+
"UtcClock",
|
|
89
|
+
"UuidFactory",
|
|
90
|
+
"callback_url",
|
|
91
|
+
"create_dsp_app",
|
|
92
|
+
]
|
httk/serve/dsp/api.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Adapt the bundled DSP OpenAPI contract through the public OpenAPI adapter."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Mapping
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from functools import partial
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from httk.serve.http import ServeApp
|
|
9
|
+
from httk.serve.http.openapi import (
|
|
10
|
+
OpenAPIRequest,
|
|
11
|
+
OpenAPIRequestError,
|
|
12
|
+
OpenAPIResponse,
|
|
13
|
+
OperationBinding,
|
|
14
|
+
OperationContext,
|
|
15
|
+
create_openapi_app,
|
|
16
|
+
operation,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from .catalogue import DCAT_MEDIA_TYPE, DCAT_PROFILE
|
|
20
|
+
from .models import DspProtocolError, ErrorKind
|
|
21
|
+
from .provider import DspProvider
|
|
22
|
+
from .validation import dsp_contract
|
|
23
|
+
|
|
24
|
+
_OPERATIONS: Mapping[str, OperationBinding] = {
|
|
25
|
+
"version_discovery": operation(DspProvider.version_document),
|
|
26
|
+
"catalog_request": operation(DspProvider.catalogue, aliases={"body": "request"}, extras=("representation",)),
|
|
27
|
+
"dataset_request": operation(DspProvider.dsp_dataset, aliases={"id": "dataset_id"}),
|
|
28
|
+
"negotiation_state": operation(DspProvider.get_negotiation),
|
|
29
|
+
"negotiation_request": operation(
|
|
30
|
+
DspProvider.request_negotiation, aliases={"body": "message"}, extras=("_automatic_batch",)
|
|
31
|
+
),
|
|
32
|
+
"negotiation_counter_request": operation(DspProvider.counter_request, aliases={"body": "message"}),
|
|
33
|
+
"negotiation_event": operation(DspProvider.negotiation_event, aliases={"body": "message"}),
|
|
34
|
+
"agreement_verification": operation(
|
|
35
|
+
DspProvider.verify_agreement, aliases={"body": "message"}, extras=("_automatic_batch",)
|
|
36
|
+
),
|
|
37
|
+
"negotiation_termination": operation(DspProvider.receive_negotiation_termination, aliases={"body": "message"}),
|
|
38
|
+
"transfer_state": operation(DspProvider.get_transfer),
|
|
39
|
+
"transfer_request": operation(
|
|
40
|
+
DspProvider.request_transfer, aliases={"body": "message"}, extras=("_automatic_batch",)
|
|
41
|
+
),
|
|
42
|
+
"transfer_start": operation(DspProvider.resume_transfer, aliases={"body": "message"}),
|
|
43
|
+
"transfer_suspension": operation(DspProvider.receive_transfer_suspension, aliases={"body": "message"}),
|
|
44
|
+
"transfer_completion": operation(DspProvider.receive_transfer_completion, aliases={"body": "message"}),
|
|
45
|
+
"transfer_termination": operation(DspProvider.receive_transfer_termination, aliases={"body": "message"}),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _error_kind(operation_id: str) -> ErrorKind:
|
|
50
|
+
"""Classify an operation for its official DSP error document."""
|
|
51
|
+
if operation_id.startswith("catalog") or operation_id == "dataset_request":
|
|
52
|
+
return "catalog"
|
|
53
|
+
if operation_id.startswith("negotiation") or operation_id == "agreement_verification":
|
|
54
|
+
return "negotiation"
|
|
55
|
+
return "transfer"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _schema_error(operation_id: str, detail: str, path: Mapping[str, str], body: object) -> DspProtocolError:
|
|
59
|
+
"""Convert adapter validation errors into official protocol error objects."""
|
|
60
|
+
mapping = body if isinstance(body, Mapping) else {}
|
|
61
|
+
provider_pid = path.get("providerPid") or mapping.get("providerPid")
|
|
62
|
+
consumer_pid = mapping.get("consumerPid")
|
|
63
|
+
return DspProtocolError(
|
|
64
|
+
_error_kind(operation_id),
|
|
65
|
+
400,
|
|
66
|
+
detail,
|
|
67
|
+
code="invalid-message",
|
|
68
|
+
provider_pid=provider_pid if isinstance(provider_pid, str) else None,
|
|
69
|
+
consumer_pid=consumer_pid if isinstance(consumer_pid, str) else None,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _complete_error_document(error: DspProtocolError, path: Mapping[str, str], body: object) -> dict[str, Any]:
|
|
74
|
+
"""Fill mandatory process correlation fields when malformed input omitted them."""
|
|
75
|
+
document = error.as_document()
|
|
76
|
+
if error.kind in {"negotiation", "transfer"}:
|
|
77
|
+
mapping = body if isinstance(body, Mapping) else {}
|
|
78
|
+
document.setdefault("providerPid", path.get("providerPid") or mapping.get("providerPid") or "")
|
|
79
|
+
document.setdefault("consumerPid", mapping.get("consumerPid") or "")
|
|
80
|
+
for field_name in ("providerPid", "consumerPid"):
|
|
81
|
+
value = document[field_name]
|
|
82
|
+
if not isinstance(value, str) or any(0xD800 <= ord(character) <= 0xDFFF for character in value):
|
|
83
|
+
document[field_name] = ""
|
|
84
|
+
return document
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@asynccontextmanager
|
|
88
|
+
async def _scope(provider: DspProvider, request: OpenAPIRequest):
|
|
89
|
+
"""Supply per-request DSP inputs and defer automatic callbacks past a clean response."""
|
|
90
|
+
batch = provider.automatic_batch()
|
|
91
|
+
context = OperationContext(extras={"_automatic_batch": batch})
|
|
92
|
+
if request.operation.operation_id == "catalog_request":
|
|
93
|
+
representation = provider.select_catalogue_representation(request.header("accept"))
|
|
94
|
+
context.extras["representation"] = representation
|
|
95
|
+
context.media_type = representation.media_type
|
|
96
|
+
context.headers = dict(representation.headers)
|
|
97
|
+
yield context
|
|
98
|
+
if provider.has_automatic_actions(batch):
|
|
99
|
+
context.after_response = partial(provider.release_automatic, batch)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _request_error(error: OpenAPIRequestError) -> OpenAPIResponse:
|
|
103
|
+
"""Convert neutral request failures to DSP's schema-validated error response."""
|
|
104
|
+
protocol_error = _schema_error(
|
|
105
|
+
error.operation.operation_id, error.detail, error.request.path_params, error.request.body
|
|
106
|
+
)
|
|
107
|
+
return _protocol_error(protocol_error, error.request)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _protocol_error(error: DspProtocolError, request: OpenAPIRequest) -> OpenAPIResponse:
|
|
111
|
+
"""Convert a DSP protocol exception to the neutral response contract."""
|
|
112
|
+
return OpenAPIResponse(
|
|
113
|
+
error.status_code,
|
|
114
|
+
_complete_error_document(error, request.path_params, request.body),
|
|
115
|
+
media_type="application/json",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _adapt_protocol_error(error: Exception, request: OpenAPIRequest) -> OpenAPIResponse:
|
|
120
|
+
"""Narrow the generic exception hook to DSP protocol exceptions."""
|
|
121
|
+
if not isinstance(error, DspProtocolError):
|
|
122
|
+
raise TypeError("DSP exception adapter received a non-DSP exception")
|
|
123
|
+
return _protocol_error(error, request)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def create_dsp_app(provider: DspProvider, *, debug: bool = False) -> ServeApp:
|
|
127
|
+
"""Create a mountable serving application for one DSP provider.
|
|
128
|
+
|
|
129
|
+
Routes, methods, body schemas, status codes, and media types are loaded from
|
|
130
|
+
the packaged OpenAPI 3.1 contract. Request and response validation resolves
|
|
131
|
+
only the packaged offline schema registry.
|
|
132
|
+
|
|
133
|
+
:param provider: In-memory provider whose business operations are exposed.
|
|
134
|
+
:param debug: Whether Starlette debug responses are enabled for unexpected failures.
|
|
135
|
+
:return: Mountable serving application with the provider on ``app.state``.
|
|
136
|
+
:raises TypeError: If ``provider`` is not a :class:`DspProvider`.
|
|
137
|
+
:raises RuntimeError: If the packaged contract uses an unsupported construct.
|
|
138
|
+
"""
|
|
139
|
+
if not isinstance(provider, DspProvider):
|
|
140
|
+
raise TypeError("provider must be a DspProvider")
|
|
141
|
+
|
|
142
|
+
@asynccontextmanager
|
|
143
|
+
async def lifespan(_app: ServeApp):
|
|
144
|
+
try:
|
|
145
|
+
yield
|
|
146
|
+
finally:
|
|
147
|
+
await provider.cancel_automatic()
|
|
148
|
+
|
|
149
|
+
contract = dsp_contract()
|
|
150
|
+
exception_handlers: Mapping[type[Exception], Callable[[Exception, OpenAPIRequest], OpenAPIResponse]] = {
|
|
151
|
+
DspProtocolError: _adapt_protocol_error
|
|
152
|
+
}
|
|
153
|
+
app = create_openapi_app(
|
|
154
|
+
contract,
|
|
155
|
+
_OPERATIONS,
|
|
156
|
+
implementation=provider,
|
|
157
|
+
request_error_handler=_request_error,
|
|
158
|
+
exception_handlers=exception_handlers,
|
|
159
|
+
request_scope=partial(_scope, provider),
|
|
160
|
+
scope_names=("_automatic_batch", "representation"),
|
|
161
|
+
lifespan=lifespan,
|
|
162
|
+
debug=debug,
|
|
163
|
+
path_converters={"id": "path"},
|
|
164
|
+
)
|
|
165
|
+
app.state.dsp_provider = provider
|
|
166
|
+
return app
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
__all__ = ["DCAT_MEDIA_TYPE", "DCAT_PROFILE", "create_dsp_app"]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""DSP-facing names for the hardened outbound webhook transport.
|
|
2
|
+
|
|
3
|
+
The implementation moved to :mod:`httk.serve.http.webhook`. These names are
|
|
4
|
+
retained as the DSP-facing spelling of the generic transport and resolve to the
|
|
5
|
+
same objects, so identity and :func:`isinstance` checks continue to hold.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from httk.serve.http.webhook import (
|
|
9
|
+
PinnedHttpsJsonPoster,
|
|
10
|
+
WebhookSender,
|
|
11
|
+
WebhookTransportError,
|
|
12
|
+
_ResolvedAddress, # noqa: F401 # re-exported for the DSP test seam.
|
|
13
|
+
_validate_callback_url, # noqa: F401 # re-exported for the DSP test seam.
|
|
14
|
+
_WebhookTarget,
|
|
15
|
+
join_url_path,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
CallbackSender = WebhookSender
|
|
19
|
+
CallbackTransportError = WebhookTransportError
|
|
20
|
+
DefaultCallbackSender = PinnedHttpsJsonPoster
|
|
21
|
+
callback_url = join_url_path
|
|
22
|
+
_CallbackTarget = _WebhookTarget
|
|
23
|
+
|
|
24
|
+
__all__ = ["CallbackSender", "CallbackTransportError", "DefaultCallbackSender", "callback_url"]
|