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,475 @@
1
+ """Typed Offering Discovery Protocol models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+ from typing import Generic, TypeVar, cast
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, JsonValue
9
+
10
+ VERSION = "1.0"
11
+
12
+ __all__ = [
13
+ "VERSION",
14
+ "Action",
15
+ "ActionRelation",
16
+ "ActionRequest",
17
+ "AuthenticationRequirement",
18
+ "CapabilityLink",
19
+ "Collection",
20
+ "CollectionSearchRequest",
21
+ "EnrollmentProtocol",
22
+ "FilterCapabilitySource",
23
+ "FilterDefinition",
24
+ "FilterExpression",
25
+ "FilterOperator",
26
+ "FilterType",
27
+ "FilterUnit",
28
+ "HttpActionTarget",
29
+ "HttpConfiguration",
30
+ "InvalidParameter",
31
+ "McpEndpoint",
32
+ "McpEndpointType",
33
+ "MissingPlacement",
34
+ "OdpModel",
35
+ "Offering",
36
+ "OfferingPage",
37
+ "OfferingSearchRequest",
38
+ "OpenApiActionTarget",
39
+ "Operation",
40
+ "OperationDescriptor",
41
+ "Page",
42
+ "PaymentOption",
43
+ "PaymentProtocol",
44
+ "PricePreview",
45
+ "PriceType",
46
+ "ProblemDetails",
47
+ "Protocol",
48
+ "RefinementBucket",
49
+ "RefinementGroup",
50
+ "Representation",
51
+ "ResourceIdentity",
52
+ "ResourceImage",
53
+ "ResourceImageType",
54
+ "ResourceType",
55
+ "SchemaReference",
56
+ "SearchCapabilities",
57
+ "ServiceBranding",
58
+ "ServiceBrandingImage",
59
+ "ServiceBrandingImageType",
60
+ "ServiceDocument",
61
+ "ServiceOpenApi",
62
+ "ServiceProtocols",
63
+ "SortCapabilitySource",
64
+ "SortDefinition",
65
+ "SortDirection",
66
+ "SortKey",
67
+ "TrustProtocol",
68
+ ]
69
+
70
+
71
+ class OdpModel(BaseModel):
72
+ """Base model that preserves additive protocol members."""
73
+
74
+ model_config = ConfigDict(extra="allow", frozen=True, populate_by_name=True)
75
+
76
+ @property
77
+ def additional(self) -> dict[str, JsonValue]:
78
+ return dict(self.model_extra or {})
79
+
80
+ def to_dict(self) -> dict[str, JsonValue]:
81
+ return cast(
82
+ dict[str, JsonValue],
83
+ self.model_dump(mode="json", by_alias=True, exclude_unset=True),
84
+ )
85
+
86
+
87
+ class Representation(StrEnum):
88
+ TERSE = "terse"
89
+ FULL = "full"
90
+
91
+
92
+ class PriceType(StrEnum):
93
+ FIXED = "fixed"
94
+ FREE = "free"
95
+ METERED = "metered"
96
+ QUOTE = "quote"
97
+ RANGE = "range"
98
+ STARTING_AT = "starting_at"
99
+
100
+
101
+ class ActionRelation(StrEnum):
102
+ DOWNLOAD = "download"
103
+ INVOKE = "invoke"
104
+ PURCHASE = "purchase"
105
+ QUOTE = "quote"
106
+ RESERVE = "reserve"
107
+
108
+
109
+ class ResourceType(StrEnum):
110
+ COLLECTION = "collection"
111
+ OFFERING = "offering"
112
+
113
+
114
+ class Operation(StrEnum):
115
+ GET_COLLECTION = "get-collection"
116
+ GET_OFFERING = "get-offering"
117
+ LIST_COLLECTION_OFFERINGS = "list-collection-offerings"
118
+ LIST_COLLECTIONS = "list-collections"
119
+ LIST_OFFERINGS = "list-offerings"
120
+ SEARCH_COLLECTIONS = "search-collections"
121
+ SEARCH_OFFERINGS = "search-offerings"
122
+
123
+
124
+ class AuthenticationRequirement(StrEnum):
125
+ NOT_REQUIRED = "not-required"
126
+ OPTIONAL = "optional"
127
+ REQUIRED = "required"
128
+
129
+
130
+ class Protocol(StrEnum):
131
+ AEP = "aep"
132
+ MPP = "mpp"
133
+ TAP = "tap"
134
+ X402 = "x402"
135
+
136
+
137
+ class PaymentOption(StrEnum):
138
+ ALGORAND = "algorand"
139
+ APTOS = "aptos"
140
+ ARBITRUM = "arbitrum"
141
+ AVALANCHE = "avalanche"
142
+ BASE = "base"
143
+ CARD = "card"
144
+ ETHEREUM = "ethereum"
145
+ HEDERA = "hedera"
146
+ INFLOW = "inflow"
147
+ LIGHTNING = "lightning"
148
+ POLYGON = "polygon"
149
+ SOLANA = "solana"
150
+ STELLAR = "stellar"
151
+ STRIPE = "stripe"
152
+ TEMPO = "tempo"
153
+ TON = "ton"
154
+
155
+
156
+ class FilterType(StrEnum):
157
+ BOOLEAN = "boolean"
158
+ DATE = "date"
159
+ DATE_TIME = "date-time"
160
+ DECIMAL = "decimal"
161
+ INTEGER = "integer"
162
+ NUMBER = "number"
163
+ STRING = "string"
164
+
165
+
166
+ class FilterOperator(StrEnum):
167
+ EQUAL = "eq"
168
+ EXISTS = "exists"
169
+ GREATER_THAN = "gt"
170
+ GREATER_THAN_OR_EQUAL = "gte"
171
+ IN = "in"
172
+ LESS_THAN = "lt"
173
+ LESS_THAN_OR_EQUAL = "lte"
174
+
175
+
176
+ class SortDirection(StrEnum):
177
+ ASCENDING = "ascending"
178
+ DESCENDING = "descending"
179
+
180
+
181
+ class MissingPlacement(StrEnum):
182
+ FIRST = "first"
183
+ LAST = "last"
184
+
185
+
186
+ class ServiceBrandingImageType(StrEnum):
187
+ PNG = "image/png"
188
+ SVG = "image/svg+xml"
189
+ WEBP = "image/webp"
190
+
191
+
192
+ class ResourceImageType(StrEnum):
193
+ AVIF = "image/avif"
194
+ JPEG = "image/jpeg"
195
+ PNG = "image/png"
196
+ SVG = "image/svg+xml"
197
+ WEBP = "image/webp"
198
+
199
+
200
+ class McpEndpointType(StrEnum):
201
+ STREAMABLE_HTTP = "streamable-http"
202
+
203
+
204
+ class OperationDescriptor(OdpModel):
205
+ authentication: AuthenticationRequirement
206
+ name: Operation
207
+
208
+
209
+ class EnrollmentProtocol(OdpModel):
210
+ name: Protocol
211
+
212
+
213
+ class PaymentProtocol(OdpModel):
214
+ authentication: AuthenticationRequirement
215
+ name: Protocol
216
+ options: list[PaymentOption] = Field(default_factory=list)
217
+
218
+
219
+ class TrustProtocol(OdpModel):
220
+ name: Protocol
221
+
222
+
223
+ class ServiceProtocols(OdpModel):
224
+ enrollment: list[EnrollmentProtocol] = Field(default_factory=list)
225
+ payments: list[PaymentProtocol] = Field(default_factory=list)
226
+ trust: list[TrustProtocol] = Field(default_factory=list)
227
+
228
+
229
+ class ServiceOpenApi(OdpModel):
230
+ url: str
231
+
232
+
233
+ class HttpConfiguration(OdpModel):
234
+ endpoint_base: str
235
+ openapi: ServiceOpenApi | None = None
236
+
237
+
238
+ class CapabilityLink(OdpModel):
239
+ href: str
240
+
241
+
242
+ class FilterUnit(OdpModel):
243
+ code: str
244
+ system: str
245
+ title: str = ""
246
+
247
+
248
+ class FilterDefinition(OdpModel):
249
+ description: str
250
+ id: str
251
+ operators: list[FilterOperator]
252
+ refinable: bool = False
253
+ title: str
254
+ filter_type: FilterType = Field(alias="type")
255
+ unit: FilterUnit | None = None
256
+
257
+
258
+ class SortKey(OdpModel):
259
+ direction: SortDirection
260
+ filter_id: str
261
+ missing: MissingPlacement
262
+
263
+
264
+ class SortDefinition(OdpModel):
265
+ description: str
266
+ id: str
267
+ keys: list[SortKey]
268
+ title: str
269
+
270
+
271
+ class FilterCapabilitySource(OdpModel):
272
+ inline: list[FilterDefinition] = Field(default_factory=list)
273
+ linked: CapabilityLink | None = None
274
+
275
+
276
+ class SortCapabilitySource(OdpModel):
277
+ inline: list[SortDefinition] = Field(default_factory=list)
278
+ linked: CapabilityLink | None = None
279
+
280
+
281
+ class SearchCapabilities(OdpModel):
282
+ filters: FilterCapabilitySource | None = None
283
+ sorts: SortCapabilitySource | None = None
284
+
285
+
286
+ class ServiceBrandingImage(OdpModel):
287
+ src: str
288
+ media_type: ServiceBrandingImageType | None = Field(default=None, alias="type")
289
+
290
+
291
+ class ServiceBranding(OdpModel):
292
+ icon: ServiceBrandingImage
293
+ logo: ServiceBrandingImage
294
+
295
+
296
+ class McpEndpoint(OdpModel):
297
+ description: str = ""
298
+ name: str = ""
299
+ endpoint_type: McpEndpointType = Field(alias="type")
300
+ url: str
301
+
302
+
303
+ class ServiceDocument(OdpModel):
304
+ branding: ServiceBranding | None = None
305
+ description: str
306
+ documentation_url: str = ""
307
+ http: HttpConfiguration
308
+ keywords: list[str] = Field(default_factory=list)
309
+ language: str
310
+ localizations: list[str]
311
+ mcp: list[McpEndpoint] = Field(default_factory=list)
312
+ name: str
313
+ odp_version: str
314
+ operations: list[OperationDescriptor]
315
+ payment_origins: list[str] = Field(default_factory=list)
316
+ protocols: ServiceProtocols | None = None
317
+ search_capabilities: SearchCapabilities | None = None
318
+ status_url: str = ""
319
+ support_url: str = ""
320
+ website_url: str = ""
321
+
322
+
323
+ class ResourceImage(OdpModel):
324
+ alt: str = ""
325
+ height: int = 0
326
+ src: str
327
+ media_type: ResourceImageType | None = Field(default=None, alias="type")
328
+ width: int = 0
329
+
330
+
331
+ class Collection(OdpModel):
332
+ auth_expands: bool = False
333
+ description: str = ""
334
+ detail_fields: list[str] = Field(default_factory=list)
335
+ id: str
336
+ images: list[ResourceImage] = Field(default_factory=list)
337
+ language: str = ""
338
+ localizations: list[str] = Field(default_factory=list)
339
+ name: str
340
+ odp_version: str = ""
341
+ parent_ids: list[str] = Field(default_factory=list)
342
+ search_capabilities: SearchCapabilities | None = None
343
+ web_url: str = ""
344
+
345
+
346
+ class SchemaReference(OdpModel):
347
+ url: str
348
+
349
+
350
+ class PricePreview(OdpModel):
351
+ amount: str = ""
352
+ currency: str = ""
353
+ maximum: str = ""
354
+ minimum: str = ""
355
+ price_type: PriceType = Field(alias="type")
356
+ unit: str = ""
357
+
358
+
359
+ class ActionRequest(OdpModel):
360
+ content_type: str = ""
361
+ schema_: SchemaReference | None = Field(default=None, alias="schema")
362
+
363
+
364
+ class HttpActionTarget(OdpModel):
365
+ href: str
366
+ method: str
367
+ request: ActionRequest | None = None
368
+ response_content_types: list[str] = Field(default_factory=list)
369
+
370
+
371
+ class OpenApiActionTarget(OdpModel):
372
+ operation_id: str
373
+ url: str = ""
374
+
375
+
376
+ class Action(OdpModel):
377
+ authentication: AuthenticationRequirement
378
+ description: str = ""
379
+ http: HttpActionTarget | None = None
380
+ id: str
381
+ openapi: OpenApiActionTarget | None = None
382
+ rel: ActionRelation
383
+
384
+
385
+ class Offering(OdpModel):
386
+ actions: list[Action] = Field(default_factory=list)
387
+ auth_expands: bool = False
388
+ attributes: dict[str, JsonValue] = Field(default_factory=dict)
389
+ collection_ids: list[str] = Field(default_factory=list)
390
+ description: str = ""
391
+ detail_fields: list[str] = Field(default_factory=list)
392
+ id: str
393
+ images: list[ResourceImage] = Field(default_factory=list)
394
+ language: str = ""
395
+ localizations: list[str] = Field(default_factory=list)
396
+ name: str
397
+ odp_version: str = ""
398
+ price: PricePreview | None = None
399
+ schema_: SchemaReference | None = Field(default=None, alias="schema")
400
+ web_url: str = ""
401
+
402
+
403
+ class InvalidParameter(OdpModel):
404
+ location: str = Field(alias="in")
405
+ name: str
406
+ reason: str
407
+
408
+
409
+ class ProblemDetails(OdpModel):
410
+ code: str
411
+ detail: str = ""
412
+ instance: str = ""
413
+ invalid_params: list[InvalidParameter] = Field(default_factory=list)
414
+ status: int
415
+ title: str
416
+ problem_type: str = Field(alias="type")
417
+
418
+
419
+ class ResourceIdentity(OdpModel):
420
+ id: str
421
+ service: str
422
+ resource_type: ResourceType = Field(alias="type")
423
+
424
+ @property
425
+ def key(self) -> str:
426
+ return f"{self.service}\0{self.resource_type.value}\0{self.id}"
427
+
428
+
429
+ class CollectionSearchRequest(OdpModel):
430
+ limit: int = 0
431
+ odp_version: str = VERSION
432
+ parent_id: str | None = None
433
+ query: str = ""
434
+
435
+
436
+ class FilterExpression(OdpModel):
437
+ id: str
438
+ operator: FilterOperator
439
+ value: JsonValue
440
+
441
+
442
+ class OfferingSearchRequest(OdpModel):
443
+ collection_id: str = ""
444
+ filters: list[FilterExpression] = Field(default_factory=list)
445
+ include_descendants: bool = False
446
+ limit: int = 0
447
+ odp_version: str = VERSION
448
+ query: str = ""
449
+ refinements: list[str] = Field(default_factory=list)
450
+ sort: str = ""
451
+
452
+
453
+ class RefinementBucket(OdpModel):
454
+ count: int
455
+ count_relation: str = ""
456
+ value: JsonValue
457
+
458
+
459
+ class RefinementGroup(OdpModel):
460
+ filter_id: str
461
+ values: list[RefinementBucket]
462
+
463
+
464
+ Item = TypeVar("Item")
465
+
466
+
467
+ class Page(OdpModel, Generic[Item]):
468
+ auth_expands: bool = False
469
+ items: list[Item]
470
+ next: str = ""
471
+ odp_version: str
472
+
473
+
474
+ class OfferingPage(Page[Item], Generic[Item]):
475
+ refinements: list[RefinementGroup] = Field(default_factory=list)
@@ -0,0 +1,135 @@
1
+ """ODP URL, reference, operation-path, and identity helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ipaddress import ip_address
6
+ from typing import cast
7
+ from urllib.parse import SplitResult, urljoin, urlsplit, urlunsplit
8
+
9
+ from offering_protocol.core.models import Operation, ResourceIdentity, ResourceType
10
+
11
+
12
+ class ReferenceError(ValueError):
13
+ """Raised when an ODP URL or reference violates protocol constraints."""
14
+
15
+
16
+ _RESOURCE_OPERATIONS = {
17
+ Operation.GET_COLLECTION,
18
+ Operation.GET_OFFERING,
19
+ Operation.LIST_COLLECTION_OFFERINGS,
20
+ }
21
+
22
+ _OPERATION_PATHS = {
23
+ Operation.LIST_COLLECTIONS: "/collections",
24
+ Operation.SEARCH_COLLECTIONS: "/collections/search",
25
+ Operation.LIST_OFFERINGS: "/offerings",
26
+ Operation.SEARCH_OFFERINGS: "/offerings/search",
27
+ }
28
+
29
+
30
+ def is_local_resource_identifier(value: str) -> bool:
31
+ return (
32
+ bool(value)
33
+ and value not in {".", ".."}
34
+ and len(value) <= 128
35
+ and all(
36
+ character.isascii() and (character.isalnum() or character in "._~-")
37
+ for character in value
38
+ )
39
+ )
40
+
41
+
42
+ def operation_method(operation: Operation) -> str:
43
+ if operation in {Operation.SEARCH_COLLECTIONS, Operation.SEARCH_OFFERINGS}:
44
+ return "POST"
45
+ return "GET"
46
+
47
+
48
+ def derive_service_origin(service_url: str) -> str:
49
+ parsed = _parse_secure_url(service_url)
50
+ hostname = cast(str, parsed.hostname)
51
+ host = f"[{hostname}]" if ":" in hostname else hostname
52
+ port = parsed.port
53
+ if port is not None and not (parsed.scheme == "https" and port == 443):
54
+ host = f"{host}:{port}"
55
+ return urlunsplit((parsed.scheme, host, "", "", ""))
56
+
57
+
58
+ def resolve_resource_reference(reference: str, service_origin: str) -> str:
59
+ if reference.startswith("//"):
60
+ raise ReferenceError("ODP resource reference cannot be scheme-relative")
61
+ if not reference.startswith(
62
+ ("/", "https://", "http://localhost", "http://127.0.0.1", "http://[::1]")
63
+ ):
64
+ raise ReferenceError(
65
+ "ODP resource reference must be an origin-relative absolute path or secure absolute URL"
66
+ )
67
+ resolved = _parse_secure_url(urljoin(f"{derive_service_origin(service_origin)}/", reference))
68
+ if resolved.fragment:
69
+ raise ReferenceError("ODP resource reference cannot contain a fragment")
70
+ return resolved.geturl()
71
+
72
+
73
+ def resolve_continuation(reference: str, service_origin: str) -> str:
74
+ resolved = resolve_resource_reference(reference, service_origin)
75
+ if derive_service_origin(resolved) != derive_service_origin(service_origin):
76
+ raise ReferenceError("ODP continuation reference must remain on the Service origin")
77
+ return resolved
78
+
79
+
80
+ def build_operation_url(
81
+ endpoint_base: str,
82
+ operation: Operation,
83
+ service_origin: str,
84
+ resource_id: str | None = None,
85
+ ) -> str:
86
+ if not endpoint_base.startswith("/") or endpoint_base.startswith("//"):
87
+ raise ReferenceError("ODP endpoint base must be an origin-relative absolute path")
88
+ path = operation_path(operation, resource_id)
89
+ return resolve_resource_reference(f"{endpoint_base.rstrip('/')}{path}", service_origin)
90
+
91
+
92
+ def operation_path(operation: Operation, resource_id: str | None = None) -> str:
93
+ if operation in _RESOURCE_OPERATIONS:
94
+ if resource_id is None or not is_local_resource_identifier(resource_id):
95
+ raise ReferenceError(f"{operation.value} requires a valid local resource identifier")
96
+ elif resource_id is not None:
97
+ raise ReferenceError(f"{operation.value} does not accept a resource identifier")
98
+
99
+ if operation is Operation.GET_COLLECTION:
100
+ return f"/collections/{resource_id}"
101
+ if operation is Operation.LIST_COLLECTION_OFFERINGS:
102
+ return f"/collections/{resource_id}/offerings"
103
+ if operation is Operation.GET_OFFERING:
104
+ return f"/offerings/{resource_id}"
105
+ return _OPERATION_PATHS[operation]
106
+
107
+
108
+ def create_resource_identity(
109
+ service_document_url: str, resource_type: ResourceType, resource_id: str
110
+ ) -> ResourceIdentity:
111
+ if not is_local_resource_identifier(resource_id):
112
+ raise ReferenceError(f"{resource_type.value} requires a valid local resource identifier")
113
+ return ResourceIdentity(
114
+ id=resource_id,
115
+ service=derive_service_origin(service_document_url),
116
+ type=resource_type,
117
+ )
118
+
119
+
120
+ def _parse_secure_url(value: str) -> SplitResult:
121
+ parsed = urlsplit(value)
122
+ if parsed.username is not None or parsed.password is not None:
123
+ raise ReferenceError("ODP URL cannot contain user information")
124
+ hostname = parsed.hostname
125
+ if hostname is None:
126
+ raise ReferenceError("ODP URL must include a host")
127
+ loopback = hostname.lower() == "localhost"
128
+ if not loopback:
129
+ try:
130
+ loopback = ip_address(hostname).is_loopback
131
+ except ValueError:
132
+ loopback = False
133
+ if parsed.scheme != "https" and not (parsed.scheme == "http" and loopback):
134
+ raise ReferenceError("ODP URL must use HTTPS except on loopback hosts")
135
+ return parsed
@@ -0,0 +1,9 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://offeringprotocol.org/schemas/action-relation.schema.json",
4
+ "title": "ODP Action relation",
5
+ "type": "string",
6
+ "minLength": 1,
7
+ "maxLength": 64,
8
+ "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$"
9
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://offeringprotocol.org/schemas/action-request.schema.json",
4
+ "title": "ODP compact Action request body",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "minProperties": 1,
8
+ "properties": {
9
+ "content_type": {
10
+ "type": "string",
11
+ "minLength": 1,
12
+ "maxLength": 255
13
+ },
14
+ "schema": {
15
+ "$ref": "schema-reference.schema.json"
16
+ }
17
+ }
18
+ }
@@ -0,0 +1,56 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://offeringprotocol.org/schemas/action.schema.json",
4
+ "title": "ODP Offering Action",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "authentication",
9
+ "id",
10
+ "rel"
11
+ ],
12
+ "properties": {
13
+ "authentication": {
14
+ "$ref": "authentication-requirement.schema.json"
15
+ },
16
+ "id": {
17
+ "$ref": "local-resource-identifier.schema.json"
18
+ },
19
+ "rel": {
20
+ "$ref": "action-relation.schema.json"
21
+ },
22
+ "description": {
23
+ "type": "string",
24
+ "minLength": 1,
25
+ "maxLength": 512
26
+ },
27
+ "http": {
28
+ "$ref": "http-action-target.schema.json"
29
+ },
30
+ "openapi": {
31
+ "$ref": "openapi-action-target.schema.json"
32
+ }
33
+ },
34
+ "oneOf": [
35
+ {
36
+ "required": [
37
+ "http"
38
+ ],
39
+ "not": {
40
+ "required": [
41
+ "openapi"
42
+ ]
43
+ }
44
+ },
45
+ {
46
+ "required": [
47
+ "openapi"
48
+ ],
49
+ "not": {
50
+ "required": [
51
+ "http"
52
+ ]
53
+ }
54
+ }
55
+ ]
56
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://offeringprotocol.org/schemas/attribute-schema-reference.schema.json",
4
+ "title": "ODP Attribute Schema reference",
5
+ "$ref": "schema-reference.schema.json"
6
+ }