offering-protocol 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.
- offering_protocol/__init__.py +7 -0
- offering_protocol/agent/__init__.py +66 -0
- offering_protocol/agent/agent.py +135 -0
- offering_protocol/agent/cache.py +51 -0
- offering_protocol/agent/capabilities.py +262 -0
- offering_protocol/agent/client.py +667 -0
- offering_protocol/agent/details.py +227 -0
- offering_protocol/agent/schema.py +129 -0
- offering_protocol/core/__init__.py +64 -0
- offering_protocol/core/models.py +475 -0
- offering_protocol/core/references.py +135 -0
- offering_protocol/core/schemas/action-relation.schema.json +9 -0
- offering_protocol/core/schemas/action-request.schema.json +18 -0
- offering_protocol/core/schemas/action.schema.json +56 -0
- offering_protocol/core/schemas/attribute-schema-reference.schema.json +6 -0
- offering_protocol/core/schemas/authentication-requirement.schema.json +11 -0
- offering_protocol/core/schemas/capability-identifier.schema.json +9 -0
- offering_protocol/core/schemas/capability-link.schema.json +14 -0
- offering_protocol/core/schemas/collection-search-request.schema.json +45 -0
- offering_protocol/core/schemas/collection.schema.json +72 -0
- offering_protocol/core/schemas/detail-fields.schema.json +16 -0
- offering_protocol/core/schemas/enrollment-protocol.schema.json +15 -0
- offering_protocol/core/schemas/filter-capability-source.schema.json +41 -0
- offering_protocol/core/schemas/filter-definition-page.schema.json +25 -0
- offering_protocol/core/schemas/filter-definition.schema.json +63 -0
- offering_protocol/core/schemas/filter-expression.schema.json +45 -0
- offering_protocol/core/schemas/filter-operator.schema.json +14 -0
- offering_protocol/core/schemas/filter-type.schema.json +14 -0
- offering_protocol/core/schemas/filter-unit.schema.json +45 -0
- offering_protocol/core/schemas/http-action-target.schema.json +36 -0
- offering_protocol/core/schemas/invalid-parameter.schema.json +55 -0
- offering_protocol/core/schemas/local-resource-identifier-list.schema.json +10 -0
- offering_protocol/core/schemas/local-resource-identifier.schema.json +10 -0
- offering_protocol/core/schemas/mcp-endpoint.schema.json +29 -0
- offering_protocol/core/schemas/offering-search-request.schema.json +68 -0
- offering_protocol/core/schemas/offering-search-response.schema.json +20 -0
- offering_protocol/core/schemas/offering.schema.json +92 -0
- offering_protocol/core/schemas/openapi-action-target.schema.json +20 -0
- offering_protocol/core/schemas/operation-descriptor.schema.json +27 -0
- offering_protocol/core/schemas/page-envelope.schema.json +25 -0
- offering_protocol/core/schemas/page-limit.schema.json +8 -0
- offering_protocol/core/schemas/payment-option.schema.json +24 -0
- offering_protocol/core/schemas/payment-protocol.schema.json +34 -0
- offering_protocol/core/schemas/price-preview.schema.json +133 -0
- offering_protocol/core/schemas/problem-code.schema.json +9 -0
- offering_protocol/core/schemas/problem-details.schema.json +66 -0
- offering_protocol/core/schemas/protocol-version.schema.json +8 -0
- offering_protocol/core/schemas/refinement-bucket.schema.json +28 -0
- offering_protocol/core/schemas/refinement-group.schema.json +24 -0
- offering_protocol/core/schemas/representation.schema.json +11 -0
- offering_protocol/core/schemas/resource-identity.schema.json +27 -0
- offering_protocol/core/schemas/resource-image.schema.json +40 -0
- offering_protocol/core/schemas/resource-reference.schema.json +19 -0
- offering_protocol/core/schemas/schema-reference.schema.json +15 -0
- offering_protocol/core/schemas/search-capabilities.schema.json +26 -0
- offering_protocol/core/schemas/service-branding-image.schema.json +23 -0
- offering_protocol/core/schemas/service-branding.schema.json +19 -0
- offering_protocol/core/schemas/service-document.schema.json +337 -0
- offering_protocol/core/schemas/service-openapi.schema.json +15 -0
- offering_protocol/core/schemas/service-origin.schema.json +9 -0
- offering_protocol/core/schemas/service-protocols.schema.json +101 -0
- offering_protocol/core/schemas/sort-capability-source.schema.json +41 -0
- offering_protocol/core/schemas/sort-definition-page.schema.json +25 -0
- offering_protocol/core/schemas/sort-definition.schema.json +35 -0
- offering_protocol/core/schemas/sort-key.schema.json +28 -0
- offering_protocol/core/schemas/top-level-document.schema.json +16 -0
- offering_protocol/core/schemas/trust-protocol.schema.json +15 -0
- offering_protocol/core/validation.py +390 -0
- offering_protocol/directory/__init__.py +51 -0
- offering_protocol/directory/client.py +206 -0
- offering_protocol/directory/models.py +103 -0
- offering_protocol/directory/transport.py +145 -0
- offering_protocol/py.typed +1 -0
- offering_protocol/service/__init__.py +32 -0
- offering_protocol/service/service.py +444 -0
- offering_protocol/service/static_catalog.py +258 -0
- offering_protocol-0.1.0.dist-info/METADATA +362 -0
- offering_protocol-0.1.0.dist-info/RECORD +80 -0
- offering_protocol-0.1.0.dist-info/WHEEL +4 -0
- offering_protocol-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""Framework-neutral ODP Service integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Protocol, TypeVar, cast
|
|
9
|
+
from urllib.parse import parse_qsl
|
|
10
|
+
|
|
11
|
+
from offering_protocol.core import (
|
|
12
|
+
VERSION,
|
|
13
|
+
AuthenticationRequirement,
|
|
14
|
+
Collection,
|
|
15
|
+
CollectionSearchRequest,
|
|
16
|
+
EnrollmentProtocol,
|
|
17
|
+
HttpConfiguration,
|
|
18
|
+
McpEndpoint,
|
|
19
|
+
OdpValidationError,
|
|
20
|
+
Offering,
|
|
21
|
+
OfferingPage,
|
|
22
|
+
OfferingSearchRequest,
|
|
23
|
+
Operation,
|
|
24
|
+
OperationDescriptor,
|
|
25
|
+
Page,
|
|
26
|
+
PaymentProtocol,
|
|
27
|
+
ProblemDetails,
|
|
28
|
+
Representation,
|
|
29
|
+
SearchCapabilities,
|
|
30
|
+
ServiceBranding,
|
|
31
|
+
ServiceDocument,
|
|
32
|
+
ServiceOpenApi,
|
|
33
|
+
ServiceProtocols,
|
|
34
|
+
TrustProtocol,
|
|
35
|
+
is_local_resource_identifier,
|
|
36
|
+
parse_collection,
|
|
37
|
+
parse_collection_page,
|
|
38
|
+
parse_collection_search_request,
|
|
39
|
+
parse_offering,
|
|
40
|
+
parse_offering_page,
|
|
41
|
+
parse_offering_search_request,
|
|
42
|
+
parse_service_document,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
MEDIA_TYPE = "application/odp+json"
|
|
46
|
+
PROBLEM_MEDIA_TYPE = "application/problem+json"
|
|
47
|
+
_MAXIMUM_REQUEST_BYTES = 65_536
|
|
48
|
+
_MAXIMUM_RESOURCE_BYTES = 524_288
|
|
49
|
+
Validated = TypeVar("Validated")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class Request:
|
|
54
|
+
method: str
|
|
55
|
+
path: str
|
|
56
|
+
body: bytes = b""
|
|
57
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
58
|
+
query: str = ""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class Response:
|
|
63
|
+
status: int
|
|
64
|
+
headers: dict[str, str]
|
|
65
|
+
body: bytes
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class CatalogRequest:
|
|
70
|
+
accept_language: str | None = None
|
|
71
|
+
cursor: str | None = None
|
|
72
|
+
limit: int = 0
|
|
73
|
+
path: str = ""
|
|
74
|
+
representation: Representation = Representation.TERSE
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ServiceError(RuntimeError):
|
|
78
|
+
"""Base error for Service integration failures."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class CatalogError(ServiceError):
|
|
82
|
+
"""Raised when a Catalog operation cannot be completed."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RequestError(ServiceError):
|
|
86
|
+
def __init__(self, status: int, code: str, message: str) -> None:
|
|
87
|
+
super().__init__(message)
|
|
88
|
+
self.status = status
|
|
89
|
+
self.code = code
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Catalog(Protocol):
|
|
93
|
+
def operations(self) -> list[Operation]: ...
|
|
94
|
+
|
|
95
|
+
async def list_offerings(self, request: CatalogRequest) -> OfferingPage[Offering]: ...
|
|
96
|
+
|
|
97
|
+
async def get_offering(self, identifier: str, request: CatalogRequest) -> Offering | None: ...
|
|
98
|
+
|
|
99
|
+
async def search_offerings(
|
|
100
|
+
self, query: OfferingSearchRequest, request: CatalogRequest
|
|
101
|
+
) -> OfferingPage[Offering]: ...
|
|
102
|
+
|
|
103
|
+
async def list_collections(self, request: CatalogRequest) -> Page[Collection]: ...
|
|
104
|
+
|
|
105
|
+
async def get_collection(
|
|
106
|
+
self, identifier: str, request: CatalogRequest
|
|
107
|
+
) -> Collection | None: ...
|
|
108
|
+
|
|
109
|
+
async def search_collections(
|
|
110
|
+
self, query: CollectionSearchRequest, request: CatalogRequest
|
|
111
|
+
) -> Page[Collection]: ...
|
|
112
|
+
|
|
113
|
+
async def list_collection_offerings(
|
|
114
|
+
self, collection_id: str, request: CatalogRequest
|
|
115
|
+
) -> OfferingPage[Offering]: ...
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ServiceBuilder:
|
|
119
|
+
def __init__(self, name: str, description: str, language: str, endpoint_base: str) -> None:
|
|
120
|
+
self._document = ServiceDocument(
|
|
121
|
+
description=description,
|
|
122
|
+
http=HttpConfiguration(endpoint_base=endpoint_base),
|
|
123
|
+
language=language,
|
|
124
|
+
localizations=[language],
|
|
125
|
+
name=name,
|
|
126
|
+
odp_version=VERSION,
|
|
127
|
+
operations=[],
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def branding(self, value: ServiceBranding) -> ServiceBuilder:
|
|
131
|
+
return self._updated(branding=value)
|
|
132
|
+
|
|
133
|
+
def documentation_url(self, value: str) -> ServiceBuilder:
|
|
134
|
+
return self._updated(documentation_url=value)
|
|
135
|
+
|
|
136
|
+
def keywords(self, values: list[str]) -> ServiceBuilder:
|
|
137
|
+
return self._updated(keywords=values)
|
|
138
|
+
|
|
139
|
+
def localizations(self, values: list[str]) -> ServiceBuilder:
|
|
140
|
+
return self._updated(localizations=values)
|
|
141
|
+
|
|
142
|
+
def mcp(self, values: list[McpEndpoint]) -> ServiceBuilder:
|
|
143
|
+
return self._updated(mcp=values)
|
|
144
|
+
|
|
145
|
+
def openapi(self, value: ServiceOpenApi) -> ServiceBuilder:
|
|
146
|
+
return self._updated(http=self._document.http.model_copy(update={"openapi": value}))
|
|
147
|
+
|
|
148
|
+
def operation_authentication(
|
|
149
|
+
self, operation: Operation, authentication: AuthenticationRequirement
|
|
150
|
+
) -> ServiceBuilder:
|
|
151
|
+
values = [item for item in self._document.operations if item.name is not operation]
|
|
152
|
+
values.append(OperationDescriptor(authentication=authentication, name=operation))
|
|
153
|
+
return self._updated(operations=values)
|
|
154
|
+
|
|
155
|
+
def payment_origins(self, values: list[str]) -> ServiceBuilder:
|
|
156
|
+
return self._updated(payment_origins=values)
|
|
157
|
+
|
|
158
|
+
def protocols(
|
|
159
|
+
self,
|
|
160
|
+
enrollment: list[EnrollmentProtocol],
|
|
161
|
+
payments: list[PaymentProtocol],
|
|
162
|
+
trust: list[TrustProtocol] | None = None,
|
|
163
|
+
) -> ServiceBuilder:
|
|
164
|
+
values: dict[str, object] = {}
|
|
165
|
+
if enrollment:
|
|
166
|
+
values["enrollment"] = enrollment
|
|
167
|
+
if payments:
|
|
168
|
+
values["payments"] = payments
|
|
169
|
+
if trust:
|
|
170
|
+
values["trust"] = trust
|
|
171
|
+
return self._updated(protocols=ServiceProtocols.model_validate(values))
|
|
172
|
+
|
|
173
|
+
def search_capabilities(self, value: SearchCapabilities) -> ServiceBuilder:
|
|
174
|
+
return self._updated(search_capabilities=value)
|
|
175
|
+
|
|
176
|
+
def status_url(self, value: str) -> ServiceBuilder:
|
|
177
|
+
return self._updated(status_url=value)
|
|
178
|
+
|
|
179
|
+
def support_url(self, value: str) -> ServiceBuilder:
|
|
180
|
+
return self._updated(support_url=value)
|
|
181
|
+
|
|
182
|
+
def website_url(self, value: str) -> ServiceBuilder:
|
|
183
|
+
return self._updated(website_url=value)
|
|
184
|
+
|
|
185
|
+
def build(self, catalog: Catalog) -> Service:
|
|
186
|
+
return Service(self._document, catalog)
|
|
187
|
+
|
|
188
|
+
def _updated(self, **values: object) -> ServiceBuilder:
|
|
189
|
+
self._document = self._document.model_copy(update=values)
|
|
190
|
+
return self
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class Service:
|
|
194
|
+
def __init__(self, document: ServiceDocument, catalog: Catalog) -> None:
|
|
195
|
+
operations = catalog.operations()
|
|
196
|
+
if not {Operation.GET_OFFERING, Operation.LIST_OFFERINGS} <= set(operations):
|
|
197
|
+
raise ServiceError("Catalog must support list-offerings and get-offering")
|
|
198
|
+
authentication = {item.name: item.authentication for item in document.operations}
|
|
199
|
+
descriptors = [
|
|
200
|
+
OperationDescriptor(
|
|
201
|
+
authentication=authentication.get(
|
|
202
|
+
operation, AuthenticationRequirement.NOT_REQUIRED
|
|
203
|
+
),
|
|
204
|
+
name=operation,
|
|
205
|
+
)
|
|
206
|
+
for operation in operations
|
|
207
|
+
]
|
|
208
|
+
candidate = document.model_copy(update={"odp_version": VERSION, "operations": descriptors})
|
|
209
|
+
self._document = parse_service_document(_encode(candidate))
|
|
210
|
+
self._catalog = catalog
|
|
211
|
+
self._endpoint_base = self._document.http.endpoint_base.rstrip("/")
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def document(self) -> ServiceDocument:
|
|
215
|
+
return self._document
|
|
216
|
+
|
|
217
|
+
async def handle(self, request: Request) -> Response:
|
|
218
|
+
try:
|
|
219
|
+
return await self._handle(request)
|
|
220
|
+
except RequestError as error:
|
|
221
|
+
return _problem(error.status, error.code, str(error))
|
|
222
|
+
except OdpValidationError as error:
|
|
223
|
+
detail = "; ".join(f"{issue.path or '/'}: {issue.message}" for issue in error.issues)
|
|
224
|
+
return _problem(400, "INVALID_REQUEST", detail)
|
|
225
|
+
except ServiceError as error:
|
|
226
|
+
return _problem(500, "INTERNAL_ERROR", str(error))
|
|
227
|
+
|
|
228
|
+
async def _handle(self, request: Request) -> Response:
|
|
229
|
+
headers = {name.lower(): value for name, value in request.headers.items()}
|
|
230
|
+
if not _accepts_odp(headers.get("accept")):
|
|
231
|
+
return _problem(406, "NOT_ACCEPTABLE", f"Accept must allow {MEDIA_TYPE}")
|
|
232
|
+
method = request.method.upper()
|
|
233
|
+
if request.path == "/.well-known/odp":
|
|
234
|
+
if method != "GET":
|
|
235
|
+
return _problem(405, "METHOD_NOT_ALLOWED", "The Service Document requires GET")
|
|
236
|
+
return _json_response(200, self._document, _MAXIMUM_REQUEST_BYTES)
|
|
237
|
+
if not request.path.startswith(self._endpoint_base):
|
|
238
|
+
return _problem(404, "NOT_FOUND", "ODP resource not found")
|
|
239
|
+
path = request.path[len(self._endpoint_base) :]
|
|
240
|
+
operation = _path_operation(method, path)
|
|
241
|
+
if operation is not None and operation not in {
|
|
242
|
+
item.name for item in self._document.operations
|
|
243
|
+
}:
|
|
244
|
+
return _problem(404, "NOT_FOUND", "ODP operation is not supported")
|
|
245
|
+
catalog_request = _catalog_request(request, headers)
|
|
246
|
+
if (method, path) == ("GET", "/offerings"):
|
|
247
|
+
offering_page = await self._catalog.list_offerings(catalog_request)
|
|
248
|
+
return _json_response(
|
|
249
|
+
200,
|
|
250
|
+
_offering_page(offering_page, catalog_request.representation),
|
|
251
|
+
_MAXIMUM_RESOURCE_BYTES,
|
|
252
|
+
)
|
|
253
|
+
if (method, path) == ("POST", "/offerings/search"):
|
|
254
|
+
query = parse_offering_search_request(_search_body(request, headers))
|
|
255
|
+
offering_page = await self._catalog.search_offerings(query, catalog_request)
|
|
256
|
+
return _json_response(
|
|
257
|
+
200,
|
|
258
|
+
_offering_page(offering_page, catalog_request.representation),
|
|
259
|
+
_MAXIMUM_RESOURCE_BYTES,
|
|
260
|
+
)
|
|
261
|
+
if (method, path) == ("GET", "/collections"):
|
|
262
|
+
collection_page = await self._catalog.list_collections(catalog_request)
|
|
263
|
+
return _json_response(
|
|
264
|
+
200,
|
|
265
|
+
_collection_page(collection_page, catalog_request.representation),
|
|
266
|
+
_MAXIMUM_RESOURCE_BYTES,
|
|
267
|
+
)
|
|
268
|
+
if (method, path) == ("POST", "/collections/search"):
|
|
269
|
+
collection_query = parse_collection_search_request(_search_body(request, headers))
|
|
270
|
+
collection_page = await self._catalog.search_collections(
|
|
271
|
+
collection_query, catalog_request
|
|
272
|
+
)
|
|
273
|
+
return _json_response(
|
|
274
|
+
200,
|
|
275
|
+
_collection_page(collection_page, catalog_request.representation),
|
|
276
|
+
_MAXIMUM_RESOURCE_BYTES,
|
|
277
|
+
)
|
|
278
|
+
if method == "GET":
|
|
279
|
+
return await self._get_path(path, catalog_request)
|
|
280
|
+
return _problem(405, "METHOD_NOT_ALLOWED", "ODP operation uses a fixed HTTP method")
|
|
281
|
+
|
|
282
|
+
async def _get_path(self, path: str, request: CatalogRequest) -> Response:
|
|
283
|
+
if path.startswith("/offerings/"):
|
|
284
|
+
identifier = path.removeprefix("/offerings/")
|
|
285
|
+
if not is_local_resource_identifier(identifier):
|
|
286
|
+
return _problem(400, "INVALID_REQUEST", "Offering identifier is invalid")
|
|
287
|
+
offering = await self._catalog.get_offering(identifier, request)
|
|
288
|
+
if offering is None:
|
|
289
|
+
return _problem(404, "NOT_FOUND", "Offering not found")
|
|
290
|
+
if offering.id != identifier:
|
|
291
|
+
raise ServiceError("Offering identifier does not match request path")
|
|
292
|
+
return _json_response(
|
|
293
|
+
200,
|
|
294
|
+
_offering(offering, request.representation),
|
|
295
|
+
_MAXIMUM_RESOURCE_BYTES,
|
|
296
|
+
)
|
|
297
|
+
if path.startswith("/collections/"):
|
|
298
|
+
value = path.removeprefix("/collections/")
|
|
299
|
+
if value.endswith("/offerings"):
|
|
300
|
+
identifier = value.removesuffix("/offerings")
|
|
301
|
+
page = await self._catalog.list_collection_offerings(identifier, request)
|
|
302
|
+
return _json_response(
|
|
303
|
+
200,
|
|
304
|
+
_offering_page(page, request.representation),
|
|
305
|
+
_MAXIMUM_RESOURCE_BYTES,
|
|
306
|
+
)
|
|
307
|
+
collection = await self._catalog.get_collection(value, request)
|
|
308
|
+
if collection is None:
|
|
309
|
+
return _problem(404, "NOT_FOUND", "Collection not found")
|
|
310
|
+
if collection.id != value:
|
|
311
|
+
raise ServiceError("Collection identifier does not match request path")
|
|
312
|
+
return _json_response(
|
|
313
|
+
200,
|
|
314
|
+
_collection(collection, request.representation),
|
|
315
|
+
_MAXIMUM_RESOURCE_BYTES,
|
|
316
|
+
)
|
|
317
|
+
return _problem(404, "NOT_FOUND", "ODP resource not found")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _catalog_request(request: Request, headers: dict[str, str]) -> CatalogRequest:
|
|
321
|
+
parameters = dict(parse_qsl(request.query, keep_blank_values=True))
|
|
322
|
+
try:
|
|
323
|
+
representation = Representation(parameters.get("representation", "terse"))
|
|
324
|
+
limit = int(parameters.get("limit", "0"))
|
|
325
|
+
except ValueError as error:
|
|
326
|
+
raise RequestError(400, "INVALID_REQUEST", "query parameter is invalid") from error
|
|
327
|
+
if not 0 <= limit <= 100:
|
|
328
|
+
raise RequestError(400, "INVALID_REQUEST", "limit exceeds 100")
|
|
329
|
+
return CatalogRequest(
|
|
330
|
+
accept_language=headers.get("accept-language"),
|
|
331
|
+
cursor=parameters.get("cursor"),
|
|
332
|
+
limit=limit,
|
|
333
|
+
path=request.path,
|
|
334
|
+
representation=representation,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _search_body(request: Request, headers: dict[str, str]) -> bytes:
|
|
339
|
+
if len(request.body) > _MAXIMUM_REQUEST_BYTES:
|
|
340
|
+
raise RequestError(413, "REQUEST_TOO_LARGE", "request body is too large")
|
|
341
|
+
content_type = headers.get("content-type", "").split(";", 1)[0]
|
|
342
|
+
if content_type != MEDIA_TYPE:
|
|
343
|
+
raise RequestError(415, "UNSUPPORTED_MEDIA_TYPE", f"Content-Type must be {MEDIA_TYPE}")
|
|
344
|
+
return request.body
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _path_operation(method: str, path: str) -> Operation | None:
|
|
348
|
+
if (method, path) == ("GET", "/offerings"):
|
|
349
|
+
return Operation.LIST_OFFERINGS
|
|
350
|
+
if (method, path) == ("POST", "/offerings/search"):
|
|
351
|
+
return Operation.SEARCH_OFFERINGS
|
|
352
|
+
if (method, path) == ("GET", "/collections"):
|
|
353
|
+
return Operation.LIST_COLLECTIONS
|
|
354
|
+
if (method, path) == ("POST", "/collections/search"):
|
|
355
|
+
return Operation.SEARCH_COLLECTIONS
|
|
356
|
+
if method == "GET" and path.startswith("/offerings/"):
|
|
357
|
+
return Operation.GET_OFFERING
|
|
358
|
+
if method == "GET" and path.startswith("/collections/") and path.endswith("/offerings"):
|
|
359
|
+
return Operation.LIST_COLLECTION_OFFERINGS
|
|
360
|
+
if method == "GET" and path.startswith("/collections/"):
|
|
361
|
+
return Operation.GET_COLLECTION
|
|
362
|
+
return None
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _json_response(status: int, value: object, maximum_bytes: int) -> Response:
|
|
366
|
+
body = _encode(value)
|
|
367
|
+
if len(body) > maximum_bytes:
|
|
368
|
+
raise ServiceError("response body is too large")
|
|
369
|
+
return Response(status, {"content-type": MEDIA_TYPE}, body)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _offering(value: Offering, representation: Representation) -> Offering:
|
|
373
|
+
parsed = _validated(parse_offering, value)
|
|
374
|
+
_validate_offering_representation(parsed, representation)
|
|
375
|
+
return parsed
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _collection(value: Collection, representation: Representation) -> Collection:
|
|
379
|
+
parsed = _validated(parse_collection, value)
|
|
380
|
+
_validate_collection_representation(parsed, representation)
|
|
381
|
+
return parsed
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _offering_page(
|
|
385
|
+
value: OfferingPage[Offering], representation: Representation
|
|
386
|
+
) -> OfferingPage[Offering]:
|
|
387
|
+
parsed = _validated(parse_offering_page, value)
|
|
388
|
+
for offering in parsed.items:
|
|
389
|
+
_validate_offering_representation(offering, representation)
|
|
390
|
+
return parsed
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _collection_page(value: Page[Collection], representation: Representation) -> Page[Collection]:
|
|
394
|
+
parsed = _validated(parse_collection_page, value)
|
|
395
|
+
for collection in parsed.items:
|
|
396
|
+
_validate_collection_representation(collection, representation)
|
|
397
|
+
return parsed
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _validate_offering_representation(offering: Offering, representation: Representation) -> None:
|
|
401
|
+
if representation is Representation.TERSE and "actions" in offering.model_fields_set:
|
|
402
|
+
raise ServiceError("Catalog returned Actions in a Terse Offering")
|
|
403
|
+
if representation is Representation.FULL and "detail_fields" in offering.model_fields_set:
|
|
404
|
+
raise ServiceError("Catalog returned detail_fields in a Full Offering")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _validate_collection_representation(
|
|
408
|
+
collection: Collection, representation: Representation
|
|
409
|
+
) -> None:
|
|
410
|
+
if representation is Representation.FULL and "detail_fields" in collection.model_fields_set:
|
|
411
|
+
raise ServiceError("Catalog returned detail_fields in a Full Collection")
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _validated(parser: Callable[[bytes | str], Validated], value: object) -> Validated:
|
|
415
|
+
try:
|
|
416
|
+
return parser(_encode(value))
|
|
417
|
+
except ValueError as error:
|
|
418
|
+
raise ServiceError(f"Catalog returned an invalid ODP response: {error}") from error
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _problem(status: int, code: str, detail: str) -> Response:
|
|
422
|
+
value = ProblemDetails(
|
|
423
|
+
code=code,
|
|
424
|
+
detail=detail,
|
|
425
|
+
status=status,
|
|
426
|
+
title=detail,
|
|
427
|
+
type=f"https://offeringprotocol.org/problems/{code.lower().replace('_', '-')}",
|
|
428
|
+
)
|
|
429
|
+
return Response(status, {"content-type": PROBLEM_MEDIA_TYPE}, _encode(value))
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _encode(value: object) -> bytes:
|
|
433
|
+
if hasattr(value, "model_dump_json"):
|
|
434
|
+
encoded = value.model_dump_json(by_alias=True, exclude_unset=True)
|
|
435
|
+
return cast(str, encoded).encode()
|
|
436
|
+
return json.dumps(value, separators=(",", ":")).encode()
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _accepts_odp(value: str | None) -> bool:
|
|
440
|
+
if value is None:
|
|
441
|
+
return True
|
|
442
|
+
return any(
|
|
443
|
+
item.split(";", 1)[0].strip().lower() in {"*/*", MEDIA_TYPE} for item in value.split(",")
|
|
444
|
+
)
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""In-memory Catalog for small Services and runnable examples."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import hmac
|
|
8
|
+
import json
|
|
9
|
+
import secrets
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TypeVar
|
|
13
|
+
|
|
14
|
+
from offering_protocol.core import (
|
|
15
|
+
VERSION,
|
|
16
|
+
Collection,
|
|
17
|
+
CollectionSearchRequest,
|
|
18
|
+
Offering,
|
|
19
|
+
OfferingPage,
|
|
20
|
+
OfferingSearchRequest,
|
|
21
|
+
Operation,
|
|
22
|
+
Page,
|
|
23
|
+
parse_collection,
|
|
24
|
+
parse_offering,
|
|
25
|
+
)
|
|
26
|
+
from offering_protocol.service.service import CatalogError, CatalogRequest, RequestError
|
|
27
|
+
|
|
28
|
+
_DEFAULT_PAGE_LIMIT = 50
|
|
29
|
+
_CONTINUATION_LIFETIME_SECONDS = 3600
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class StaticCatalogOptions:
|
|
34
|
+
collections: tuple[Collection, ...] = ()
|
|
35
|
+
offerings: tuple[Offering, ...] = ()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class StaticCatalog:
|
|
39
|
+
def __init__(self, options: StaticCatalogOptions) -> None:
|
|
40
|
+
self._continuation_key = secrets.token_bytes(32)
|
|
41
|
+
try:
|
|
42
|
+
self._collections = tuple(
|
|
43
|
+
parse_collection(_model_bytes(item)) for item in options.collections
|
|
44
|
+
)
|
|
45
|
+
self._offerings = tuple(
|
|
46
|
+
parse_offering(_model_bytes(item)) for item in options.offerings
|
|
47
|
+
)
|
|
48
|
+
except ValueError as error:
|
|
49
|
+
raise CatalogError(f"Static Catalog contains an invalid resource: {error}") from error
|
|
50
|
+
self._collection_by_id = _unique(self._collections, "Collection")
|
|
51
|
+
self._offering_by_id = _unique(self._offerings, "Offering")
|
|
52
|
+
for offering in self._offerings:
|
|
53
|
+
if any(
|
|
54
|
+
identifier not in self._collection_by_id for identifier in offering.collection_ids
|
|
55
|
+
):
|
|
56
|
+
raise CatalogError(f"Offering {offering.id} refers to an unknown Collection")
|
|
57
|
+
|
|
58
|
+
def operations(self) -> list[Operation]:
|
|
59
|
+
values = [Operation.GET_OFFERING, Operation.LIST_OFFERINGS]
|
|
60
|
+
if self._collections:
|
|
61
|
+
values.extend(
|
|
62
|
+
[
|
|
63
|
+
Operation.GET_COLLECTION,
|
|
64
|
+
Operation.LIST_COLLECTION_OFFERINGS,
|
|
65
|
+
Operation.LIST_COLLECTIONS,
|
|
66
|
+
]
|
|
67
|
+
)
|
|
68
|
+
return values
|
|
69
|
+
|
|
70
|
+
async def list_offerings(self, request: CatalogRequest) -> OfferingPage[Offering]:
|
|
71
|
+
items, next_reference = _page(self._offerings, request, self._continuation_key)
|
|
72
|
+
return _offering_page(
|
|
73
|
+
[_represent_offering(item, request, True) for item in items], next_reference
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
async def get_offering(self, identifier: str, request: CatalogRequest) -> Offering | None:
|
|
77
|
+
offering = self._offering_by_id.get(identifier)
|
|
78
|
+
return None if offering is None else _represent_offering(offering, request, False)
|
|
79
|
+
|
|
80
|
+
async def search_offerings(
|
|
81
|
+
self, query: OfferingSearchRequest, request: CatalogRequest
|
|
82
|
+
) -> OfferingPage[Offering]:
|
|
83
|
+
del query, request
|
|
84
|
+
raise CatalogError("search-offerings is unsupported")
|
|
85
|
+
|
|
86
|
+
async def list_collections(self, request: CatalogRequest) -> Page[Collection]:
|
|
87
|
+
items, next_reference = _page(self._collections, request, self._continuation_key)
|
|
88
|
+
return _collection_page(
|
|
89
|
+
[_represent_collection(item, request, True) for item in items], next_reference
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
async def get_collection(self, identifier: str, request: CatalogRequest) -> Collection | None:
|
|
93
|
+
collection = self._collection_by_id.get(identifier)
|
|
94
|
+
return None if collection is None else _represent_collection(collection, request, False)
|
|
95
|
+
|
|
96
|
+
async def search_collections(
|
|
97
|
+
self, query: CollectionSearchRequest, request: CatalogRequest
|
|
98
|
+
) -> Page[Collection]:
|
|
99
|
+
del query, request
|
|
100
|
+
raise CatalogError("search-collections is unsupported")
|
|
101
|
+
|
|
102
|
+
async def list_collection_offerings(
|
|
103
|
+
self, collection_id: str, request: CatalogRequest
|
|
104
|
+
) -> OfferingPage[Offering]:
|
|
105
|
+
if collection_id not in self._collection_by_id:
|
|
106
|
+
raise RequestError(404, "NOT_FOUND", "Collection not found")
|
|
107
|
+
offerings = tuple(item for item in self._offerings if collection_id in item.collection_ids)
|
|
108
|
+
items, next_reference = _page(offerings, request, self._continuation_key)
|
|
109
|
+
return _offering_page(
|
|
110
|
+
[_represent_offering(item, request, True) for item in items], next_reference
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
Resource = TypeVar("Resource", Collection, Offering)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _unique(values: tuple[Resource, ...], label: str) -> dict[str, Resource]:
|
|
118
|
+
result: dict[str, Resource] = {}
|
|
119
|
+
for value in values:
|
|
120
|
+
if value.id in result:
|
|
121
|
+
raise CatalogError(f"{label} identifiers must be unique")
|
|
122
|
+
result[value.id] = value
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _model_bytes(value: Resource) -> bytes:
|
|
127
|
+
return value.model_dump_json(by_alias=True, exclude_unset=True).encode()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _offering_page(items: list[Offering], next_reference: str) -> OfferingPage[Offering]:
|
|
131
|
+
values: dict[str, object] = {"items": items, "odp_version": VERSION}
|
|
132
|
+
if next_reference:
|
|
133
|
+
values["next"] = next_reference
|
|
134
|
+
return OfferingPage[Offering].model_validate(values)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _collection_page(items: list[Collection], next_reference: str) -> Page[Collection]:
|
|
138
|
+
values: dict[str, object] = {"items": items, "odp_version": VERSION}
|
|
139
|
+
if next_reference:
|
|
140
|
+
values["next"] = next_reference
|
|
141
|
+
return Page[Collection].model_validate(values)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _page(
|
|
145
|
+
values: tuple[Resource, ...], request: CatalogRequest, continuation_key: bytes
|
|
146
|
+
) -> tuple[list[Resource], str]:
|
|
147
|
+
limit = request.limit or _DEFAULT_PAGE_LIMIT
|
|
148
|
+
offset = _decode_cursor(request, limit, continuation_key)
|
|
149
|
+
if offset > len(values):
|
|
150
|
+
raise _invalid_cursor()
|
|
151
|
+
end = min(offset + limit, len(values))
|
|
152
|
+
next_reference = (
|
|
153
|
+
_encode_cursor(request, limit, end, continuation_key) if end < len(values) else ""
|
|
154
|
+
)
|
|
155
|
+
return list(values[offset:end]), next_reference
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _represent_offering(value: Offering, request: CatalogRequest, embedded: bool) -> Offering:
|
|
159
|
+
if request.representation.value == "full":
|
|
160
|
+
return value
|
|
161
|
+
document: dict[str, object] = {
|
|
162
|
+
"id": value.id,
|
|
163
|
+
"name": value.name,
|
|
164
|
+
}
|
|
165
|
+
for name in (
|
|
166
|
+
"auth_expands",
|
|
167
|
+
"collection_ids",
|
|
168
|
+
"description",
|
|
169
|
+
"images",
|
|
170
|
+
"language",
|
|
171
|
+
"localizations",
|
|
172
|
+
"price",
|
|
173
|
+
"web_url",
|
|
174
|
+
):
|
|
175
|
+
if name in value.model_fields_set:
|
|
176
|
+
document[name] = getattr(value, name)
|
|
177
|
+
if not embedded:
|
|
178
|
+
document["odp_version"] = value.odp_version
|
|
179
|
+
return Offering.model_validate(document)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _represent_collection(value: Collection, request: CatalogRequest, embedded: bool) -> Collection:
|
|
183
|
+
if request.representation.value == "full":
|
|
184
|
+
return value
|
|
185
|
+
document: dict[str, object] = {
|
|
186
|
+
"id": value.id,
|
|
187
|
+
"name": value.name,
|
|
188
|
+
}
|
|
189
|
+
for name in (
|
|
190
|
+
"auth_expands",
|
|
191
|
+
"description",
|
|
192
|
+
"images",
|
|
193
|
+
"language",
|
|
194
|
+
"localizations",
|
|
195
|
+
"parent_ids",
|
|
196
|
+
"web_url",
|
|
197
|
+
):
|
|
198
|
+
if name in value.model_fields_set:
|
|
199
|
+
document[name] = getattr(value, name)
|
|
200
|
+
if not embedded:
|
|
201
|
+
document["odp_version"] = value.odp_version
|
|
202
|
+
return Collection.model_validate(document)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _encode_cursor(
|
|
206
|
+
request: CatalogRequest, limit: int, offset: int, continuation_key: bytes
|
|
207
|
+
) -> str:
|
|
208
|
+
value = {
|
|
209
|
+
"expires": int(time.time()) + _CONTINUATION_LIFETIME_SECONDS,
|
|
210
|
+
"limit": limit,
|
|
211
|
+
"offset": offset,
|
|
212
|
+
"path": request.path,
|
|
213
|
+
"representation": request.representation.value,
|
|
214
|
+
}
|
|
215
|
+
payload = (
|
|
216
|
+
base64.urlsafe_b64encode(json.dumps(value, separators=(",", ":"), sort_keys=True).encode())
|
|
217
|
+
.rstrip(b"=")
|
|
218
|
+
.decode()
|
|
219
|
+
)
|
|
220
|
+
signature = (
|
|
221
|
+
base64.urlsafe_b64encode(hmac.digest(continuation_key, payload.encode(), "sha256"))
|
|
222
|
+
.rstrip(b"=")
|
|
223
|
+
.decode()
|
|
224
|
+
)
|
|
225
|
+
token = f"{payload}.{signature}"
|
|
226
|
+
return (
|
|
227
|
+
f"{request.path}?cursor={token}&limit={limit}&representation={request.representation.value}"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _decode_cursor(request: CatalogRequest, limit: int, continuation_key: bytes) -> int:
|
|
232
|
+
if request.cursor is None:
|
|
233
|
+
return 0
|
|
234
|
+
try:
|
|
235
|
+
payload, signature = request.cursor.split(".")
|
|
236
|
+
signature_padding = "=" * (-len(signature) % 4)
|
|
237
|
+
supplied_signature = base64.urlsafe_b64decode(signature + signature_padding)
|
|
238
|
+
expected_signature = hmac.digest(continuation_key, payload.encode(), "sha256")
|
|
239
|
+
if not hmac.compare_digest(supplied_signature, expected_signature):
|
|
240
|
+
raise ValueError
|
|
241
|
+
payload_padding = "=" * (-len(payload) % 4)
|
|
242
|
+
value = json.loads(base64.urlsafe_b64decode(payload + payload_padding))
|
|
243
|
+
if (
|
|
244
|
+
not isinstance(value, dict)
|
|
245
|
+
or value.get("expires", 0) < int(time.time())
|
|
246
|
+
or value.get("limit") != limit
|
|
247
|
+
or value.get("path") != request.path
|
|
248
|
+
or value.get("representation") != request.representation.value
|
|
249
|
+
or not isinstance(value.get("offset"), int)
|
|
250
|
+
):
|
|
251
|
+
raise ValueError
|
|
252
|
+
return int(value["offset"])
|
|
253
|
+
except (ValueError, TypeError, json.JSONDecodeError, binascii.Error) as error:
|
|
254
|
+
raise _invalid_cursor() from error
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _invalid_cursor() -> RequestError:
|
|
258
|
+
return RequestError(410, "CONTINUATION_UNAVAILABLE", "Continuation is unavailable")
|