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.
Files changed (80) hide show
  1. offering_protocol/__init__.py +7 -0
  2. offering_protocol/agent/__init__.py +66 -0
  3. offering_protocol/agent/agent.py +135 -0
  4. offering_protocol/agent/cache.py +51 -0
  5. offering_protocol/agent/capabilities.py +262 -0
  6. offering_protocol/agent/client.py +667 -0
  7. offering_protocol/agent/details.py +227 -0
  8. offering_protocol/agent/schema.py +129 -0
  9. offering_protocol/core/__init__.py +64 -0
  10. offering_protocol/core/models.py +475 -0
  11. offering_protocol/core/references.py +135 -0
  12. offering_protocol/core/schemas/action-relation.schema.json +9 -0
  13. offering_protocol/core/schemas/action-request.schema.json +18 -0
  14. offering_protocol/core/schemas/action.schema.json +56 -0
  15. offering_protocol/core/schemas/attribute-schema-reference.schema.json +6 -0
  16. offering_protocol/core/schemas/authentication-requirement.schema.json +11 -0
  17. offering_protocol/core/schemas/capability-identifier.schema.json +9 -0
  18. offering_protocol/core/schemas/capability-link.schema.json +14 -0
  19. offering_protocol/core/schemas/collection-search-request.schema.json +45 -0
  20. offering_protocol/core/schemas/collection.schema.json +72 -0
  21. offering_protocol/core/schemas/detail-fields.schema.json +16 -0
  22. offering_protocol/core/schemas/enrollment-protocol.schema.json +15 -0
  23. offering_protocol/core/schemas/filter-capability-source.schema.json +41 -0
  24. offering_protocol/core/schemas/filter-definition-page.schema.json +25 -0
  25. offering_protocol/core/schemas/filter-definition.schema.json +63 -0
  26. offering_protocol/core/schemas/filter-expression.schema.json +45 -0
  27. offering_protocol/core/schemas/filter-operator.schema.json +14 -0
  28. offering_protocol/core/schemas/filter-type.schema.json +14 -0
  29. offering_protocol/core/schemas/filter-unit.schema.json +45 -0
  30. offering_protocol/core/schemas/http-action-target.schema.json +36 -0
  31. offering_protocol/core/schemas/invalid-parameter.schema.json +55 -0
  32. offering_protocol/core/schemas/local-resource-identifier-list.schema.json +10 -0
  33. offering_protocol/core/schemas/local-resource-identifier.schema.json +10 -0
  34. offering_protocol/core/schemas/mcp-endpoint.schema.json +29 -0
  35. offering_protocol/core/schemas/offering-search-request.schema.json +68 -0
  36. offering_protocol/core/schemas/offering-search-response.schema.json +20 -0
  37. offering_protocol/core/schemas/offering.schema.json +92 -0
  38. offering_protocol/core/schemas/openapi-action-target.schema.json +20 -0
  39. offering_protocol/core/schemas/operation-descriptor.schema.json +27 -0
  40. offering_protocol/core/schemas/page-envelope.schema.json +25 -0
  41. offering_protocol/core/schemas/page-limit.schema.json +8 -0
  42. offering_protocol/core/schemas/payment-option.schema.json +24 -0
  43. offering_protocol/core/schemas/payment-protocol.schema.json +34 -0
  44. offering_protocol/core/schemas/price-preview.schema.json +133 -0
  45. offering_protocol/core/schemas/problem-code.schema.json +9 -0
  46. offering_protocol/core/schemas/problem-details.schema.json +66 -0
  47. offering_protocol/core/schemas/protocol-version.schema.json +8 -0
  48. offering_protocol/core/schemas/refinement-bucket.schema.json +28 -0
  49. offering_protocol/core/schemas/refinement-group.schema.json +24 -0
  50. offering_protocol/core/schemas/representation.schema.json +11 -0
  51. offering_protocol/core/schemas/resource-identity.schema.json +27 -0
  52. offering_protocol/core/schemas/resource-image.schema.json +40 -0
  53. offering_protocol/core/schemas/resource-reference.schema.json +19 -0
  54. offering_protocol/core/schemas/schema-reference.schema.json +15 -0
  55. offering_protocol/core/schemas/search-capabilities.schema.json +26 -0
  56. offering_protocol/core/schemas/service-branding-image.schema.json +23 -0
  57. offering_protocol/core/schemas/service-branding.schema.json +19 -0
  58. offering_protocol/core/schemas/service-document.schema.json +337 -0
  59. offering_protocol/core/schemas/service-openapi.schema.json +15 -0
  60. offering_protocol/core/schemas/service-origin.schema.json +9 -0
  61. offering_protocol/core/schemas/service-protocols.schema.json +101 -0
  62. offering_protocol/core/schemas/sort-capability-source.schema.json +41 -0
  63. offering_protocol/core/schemas/sort-definition-page.schema.json +25 -0
  64. offering_protocol/core/schemas/sort-definition.schema.json +35 -0
  65. offering_protocol/core/schemas/sort-key.schema.json +28 -0
  66. offering_protocol/core/schemas/top-level-document.schema.json +16 -0
  67. offering_protocol/core/schemas/trust-protocol.schema.json +15 -0
  68. offering_protocol/core/validation.py +390 -0
  69. offering_protocol/directory/__init__.py +51 -0
  70. offering_protocol/directory/client.py +206 -0
  71. offering_protocol/directory/models.py +103 -0
  72. offering_protocol/directory/transport.py +145 -0
  73. offering_protocol/py.typed +1 -0
  74. offering_protocol/service/__init__.py +32 -0
  75. offering_protocol/service/service.py +444 -0
  76. offering_protocol/service/static_catalog.py +258 -0
  77. offering_protocol-0.1.0.dist-info/METADATA +362 -0
  78. offering_protocol-0.1.0.dist-info/RECORD +80 -0
  79. offering_protocol-0.1.0.dist-info/WHEEL +4 -0
  80. 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")