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.
Files changed (196) hide show
  1. httk/registry/cli/serve/__init__.py +9 -0
  2. httk/registry/cli/serve/py.typed +1 -0
  3. httk/registry/entries/serve/__init__.py +13 -0
  4. httk/registry/entries/serve/py.typed +1 -0
  5. httk/serve/__init__.py +16 -0
  6. httk/serve/composition.py +141 -0
  7. httk/serve/dsp/__init__.py +92 -0
  8. httk/serve/dsp/api.py +169 -0
  9. httk/serve/dsp/callbacks.py +24 -0
  10. httk/serve/dsp/catalogue.py +312 -0
  11. httk/serve/dsp/config.py +338 -0
  12. httk/serve/dsp/models.py +290 -0
  13. httk/serve/dsp/provider.py +1056 -0
  14. httk/serve/dsp/py.typed +1 -0
  15. httk/serve/dsp/schemas/LICENSE-APACHE-2.0.txt +176 -0
  16. httk/serve/dsp/schemas/LICENSE-CC-BY-3.0.txt +92 -0
  17. httk/serve/dsp/schemas/LICENSE-CC-BY-4.0.txt +396 -0
  18. httk/serve/dsp/schemas/README.md +20 -0
  19. httk/serve/dsp/schemas/catalog/catalog-error-schema.json +35 -0
  20. httk/serve/dsp/schemas/catalog/catalog-request-message-schema.json +32 -0
  21. httk/serve/dsp/schemas/catalog/catalog-schema.json +83 -0
  22. httk/serve/dsp/schemas/catalog/dataset-request-message-schema.json +33 -0
  23. httk/serve/dsp/schemas/catalog/dataset-schema.json +150 -0
  24. httk/serve/dsp/schemas/catalog/examples/catalog-error.json +10 -0
  25. httk/serve/dsp/schemas/catalog/examples/catalog-request-message.json +7 -0
  26. httk/serve/dsp/schemas/catalog/examples/catalog.json +46 -0
  27. httk/serve/dsp/schemas/catalog/examples/dataset-request-message.json +7 -0
  28. httk/serve/dsp/schemas/catalog/examples/dataset.json +36 -0
  29. httk/serve/dsp/schemas/catalog/examples/nested-catalog.json +28 -0
  30. httk/serve/dsp/schemas/common/context-schema.json +28 -0
  31. httk/serve/dsp/schemas/common/examples/protocol-version.json +40 -0
  32. httk/serve/dsp/schemas/common/protocol-version-schema.json +92 -0
  33. httk/serve/dsp/schemas/context/dspace.jsonld +451 -0
  34. httk/serve/dsp/schemas/context/odrl.jsonld +96 -0
  35. httk/serve/dsp/schemas/dcat-ap/dcat-ap-SHACL.ttl +2217 -0
  36. httk/serve/dsp/schemas/dcat-ap/dcat-ap-context.jsonld +675 -0
  37. httk/serve/dsp/schemas/dcat-ap/dcat-ap-controlled-vocabularies.shape.ttl +491 -0
  38. httk/serve/dsp/schemas/dcat-ap/dcat-ap-ranges.ttl +1232 -0
  39. httk/serve/dsp/schemas/dcat-ap/eu-file-type-csv.rdf +73 -0
  40. httk/serve/dsp/schemas/dcat-ap/eu-file-type-json-ld.rdf +59 -0
  41. httk/serve/dsp/schemas/dcat-ap/eu-file-type-json.rdf +61 -0
  42. httk/serve/dsp/schemas/dcat-ap/spdx-2.3-ontology.owl.xml +3724 -0
  43. httk/serve/dsp/schemas/negotiation/contract-agreement-message-schema.json +41 -0
  44. httk/serve/dsp/schemas/negotiation/contract-agreement-verification-message-schema.json +37 -0
  45. httk/serve/dsp/schemas/negotiation/contract-negotiation-error-schema.json +45 -0
  46. httk/serve/dsp/schemas/negotiation/contract-negotiation-event-message-schema.json +45 -0
  47. httk/serve/dsp/schemas/negotiation/contract-negotiation-schema.json +50 -0
  48. httk/serve/dsp/schemas/negotiation/contract-negotiation-termination-message-schema.json +45 -0
  49. httk/serve/dsp/schemas/negotiation/contract-offer-message-schema.json +73 -0
  50. httk/serve/dsp/schemas/negotiation/contract-request-message-schema.json +56 -0
  51. httk/serve/dsp/schemas/negotiation/contract-schema.json +374 -0
  52. httk/serve/dsp/schemas/negotiation/examples/contract-agreement-message-full.json +143 -0
  53. httk/serve/dsp/schemas/negotiation/examples/contract-agreement-message.json +28 -0
  54. httk/serve/dsp/schemas/negotiation/examples/contract-agreement-verification-message.json +8 -0
  55. httk/serve/dsp/schemas/negotiation/examples/contract-negotiation-error.json +12 -0
  56. httk/serve/dsp/schemas/negotiation/examples/contract-negotiation-event-message.json +9 -0
  57. httk/serve/dsp/schemas/negotiation/examples/contract-negotiation-termination-message.json +12 -0
  58. httk/serve/dsp/schemas/negotiation/examples/contract-negotiation.json +9 -0
  59. httk/serve/dsp/schemas/negotiation/examples/contract-offer-message.json +25 -0
  60. httk/serve/dsp/schemas/negotiation/examples/contract-offer-message_initial.json +18 -0
  61. httk/serve/dsp/schemas/negotiation/examples/contract-request-message.json +25 -0
  62. httk/serve/dsp/schemas/negotiation/examples/contract-request-message_initial.json +18 -0
  63. httk/serve/dsp/schemas/openapi.yaml +282 -0
  64. httk/serve/dsp/schemas/profile/context.jsonld +86 -0
  65. httk/serve/dsp/schemas/profile/dcat-ap-catalogue-schema.json +97 -0
  66. httk/serve/dsp/schemas/profile/example-catalogue.json +181 -0
  67. httk/serve/dsp/schemas/profile/example-http-pull-configuration.json +8 -0
  68. httk/serve/dsp/schemas/profile/http-pull-profile-schema.json +21 -0
  69. httk/serve/dsp/schemas/provenance.json +76 -0
  70. httk/serve/dsp/schemas/transfer/data-address-schema.json +59 -0
  71. httk/serve/dsp/schemas/transfer/examples/transfer-completion-message.json +8 -0
  72. httk/serve/dsp/schemas/transfer/examples/transfer-error.json +12 -0
  73. httk/serve/dsp/schemas/transfer/examples/transfer-process.json +9 -0
  74. httk/serve/dsp/schemas/transfer/examples/transfer-request-message.json +27 -0
  75. httk/serve/dsp/schemas/transfer/examples/transfer-start-message.json +25 -0
  76. httk/serve/dsp/schemas/transfer/examples/transfer-suspension-message.json +11 -0
  77. httk/serve/dsp/schemas/transfer/examples/transfer-termination-message.json +12 -0
  78. httk/serve/dsp/schemas/transfer/transfer-completion-message-schema.json +37 -0
  79. httk/serve/dsp/schemas/transfer/transfer-error-schema.json +37 -0
  80. httk/serve/dsp/schemas/transfer/transfer-process-schema.json +48 -0
  81. httk/serve/dsp/schemas/transfer/transfer-request-message-schema.json +48 -0
  82. httk/serve/dsp/schemas/transfer/transfer-schema.json +41 -0
  83. httk/serve/dsp/schemas/transfer/transfer-start-message-schema.json +40 -0
  84. httk/serve/dsp/schemas/transfer/transfer-suspension-message-schema.json +37 -0
  85. httk/serve/dsp/schemas/transfer/transfer-termination-message-schema.json +31 -0
  86. httk/serve/dsp/serializers.py +300 -0
  87. httk/serve/dsp/state.py +378 -0
  88. httk/serve/dsp/validation.py +39 -0
  89. httk/serve/http/__init__.py +50 -0
  90. httk/serve/http/accept.py +227 -0
  91. httk/serve/http/api.py +194 -0
  92. httk/serve/http/apptypes.py +17 -0
  93. httk/serve/http/fields.py +46 -0
  94. httk/serve/http/identifiers.py +50 -0
  95. httk/serve/http/openapi/__init__.py +59 -0
  96. httk/serve/http/openapi/app.py +654 -0
  97. httk/serve/http/openapi/binding.py +373 -0
  98. httk/serve/http/openapi/contract.py +110 -0
  99. httk/serve/http/openapi/resources.py +83 -0
  100. httk/serve/http/openapi/schemas.py +93 -0
  101. httk/serve/http/py.typed +0 -0
  102. httk/serve/http/webhook.py +469 -0
  103. httk/serve/jsondata.py +78 -0
  104. httk/serve/optimade/__init__.py +86 -0
  105. httk/serve/optimade/api.py +149 -0
  106. httk/serve/optimade/backend/__init__.py +46 -0
  107. httk/serve/optimade/backend/_property_handlers.py +45 -0
  108. httk/serve/optimade/backend/adapter.py +137 -0
  109. httk/serve/optimade/backend/execution.py +198 -0
  110. httk/serve/optimade/backend/handlers.py +63 -0
  111. httk/serve/optimade/backend/memory_store.py +460 -0
  112. httk/serve/optimade/backend/partial.py +46 -0
  113. httk/serve/optimade/backend/protocols.py +30 -0
  114. httk/serve/optimade/backend/providers.py +283 -0
  115. httk/serve/optimade/backend/stores.py +458 -0
  116. httk/serve/optimade/backend/translation.py +200 -0
  117. httk/serve/optimade/client.py +742 -0
  118. httk/serve/optimade/endpoints/__init__.py +24 -0
  119. httk/serve/optimade/endpoints/entries.py +349 -0
  120. httk/serve/optimade/endpoints/error.py +56 -0
  121. httk/serve/optimade/endpoints/info.py +193 -0
  122. httk/serve/optimade/endpoints/meta.py +104 -0
  123. httk/serve/optimade/endpoints/partial_data.py +97 -0
  124. httk/serve/optimade/engine/__init__.py +6 -0
  125. httk/serve/optimade/engine/processing.py +399 -0
  126. httk/serve/optimade/engine/validate.py +488 -0
  127. httk/serve/optimade/filter/__init__.py +20 -0
  128. httk/serve/optimade/model/__init__.py +29 -0
  129. httk/serve/optimade/model/config.py +135 -0
  130. httk/serve/optimade/model/errors.py +41 -0
  131. httk/serve/optimade/model/request.py +173 -0
  132. httk/serve/optimade/model/results.py +110 -0
  133. httk/serve/optimade/model/versions.py +6 -0
  134. httk/serve/optimade/py.typed +0 -0
  135. httk/serve/optimade/remote_query.py +1666 -0
  136. httk/serve/optimade/runtime/__init__.py +6 -0
  137. httk/serve/optimade/runtime/asgi.py +249 -0
  138. httk/serve/optimade/runtime/devserver.py +16 -0
  139. httk/serve/optimade/schema/__init__.py +5 -0
  140. httk/serve/optimade/schema/served.py +461 -0
  141. httk/serve/optimade/schema/trajectories.py +137 -0
  142. httk/serve/py.typed +0 -0
  143. httk/serve/web/__init__.py +17 -0
  144. httk/serve/web/api.py +132 -0
  145. httk/serve/web/assets/serve-optimade-fields.css +22 -0
  146. httk/serve/web/assets/serve-optimade-table-protocol.mjs +568 -0
  147. httk/serve/web/assets/serve-optimade-table.css +56 -0
  148. httk/serve/web/assets/serve-optimade-table.mjs +937 -0
  149. httk/serve/web/assets/serve-table.css +38 -0
  150. httk/serve/web/assets/serve-table.js +71 -0
  151. httk/serve/web/cli.py +238 -0
  152. httk/serve/web/compat/__init__.py +1 -0
  153. httk/serve/web/engine/__init__.py +5 -0
  154. httk/serve/web/engine/discovery.py +64 -0
  155. httk/serve/web/engine/site_engine.py +1001 -0
  156. httk/serve/web/functions/__init__.py +5 -0
  157. httk/serve/web/functions/python_module.py +160 -0
  158. httk/serve/web/model/__init__.py +31 -0
  159. httk/serve/web/model/config.py +102 -0
  160. httk/serve/web/model/errors.py +94 -0
  161. httk/serve/web/model/page.py +53 -0
  162. httk/serve/web/model/request.py +19 -0
  163. httk/serve/web/providers.py +366 -0
  164. httk/serve/web/publishing/__init__.py +5 -0
  165. httk/serve/web/publishing/static.py +82 -0
  166. httk/serve/web/py.typed +0 -0
  167. httk/serve/web/renderers/__init__.py +24 -0
  168. httk/serve/web/renderers/_frontmatter.py +51 -0
  169. httk/serve/web/renderers/base.py +52 -0
  170. httk/serve/web/renderers/html.py +22 -0
  171. httk/serve/web/renderers/httkweb_compat.py +46 -0
  172. httk/serve/web/renderers/markdown.py +33 -0
  173. httk/serve/web/renderers/rst.py +105 -0
  174. httk/serve/web/resources.py +95 -0
  175. httk/serve/web/runtime/__init__.py +5 -0
  176. httk/serve/web/runtime/asgi.py +241 -0
  177. httk/serve/web/runtime/devserver.py +15 -0
  178. httk/serve/web/templating/__init__.py +7 -0
  179. httk/serve/web/templating/_legacy_formatter.py +183 -0
  180. httk/serve/web/templating/base.py +32 -0
  181. httk/serve/web/templating/httk_compat.py +81 -0
  182. httk/serve/web/templating/jinja2_engine.py +168 -0
  183. httk/serve/web/widgets/__init__.py +34 -0
  184. httk/serve/web/widgets/assets.py +68 -0
  185. httk/serve/web/widgets/core.py +265 -0
  186. httk/serve/web/widgets/extraction.py +331 -0
  187. httk/serve/web/widgets/loader.py +126 -0
  188. httk/serve/web/widgets/optimade_assets.py +36 -0
  189. httk/serve/web/widgets/optimade_fields.py +125 -0
  190. httk/serve/web/widgets/optimade_table.py +645 -0
  191. httk/serve/web/widgets/table.py +812 -0
  192. httk_serve-2.1.0.dist-info/METADATA +115 -0
  193. httk_serve-2.1.0.dist-info/RECORD +196 -0
  194. httk_serve-2.1.0.dist-info/WHEEL +5 -0
  195. httk_serve-2.1.0.dist-info/licenses/LICENSE +661 -0
  196. httk_serve-2.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,9 @@
1
+ """Register the :command:`httk serve web` umbrella command."""
2
+
3
+ from httk.core.register import register_cli_command
4
+
5
+ register_cli_command(
6
+ "serve",
7
+ "httk.serve.web.cli:command",
8
+ "serve and validate httk-serve sites",
9
+ )
@@ -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"]