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,227 @@
1
+ """Agent-friendly Offering details and Action resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+ from urllib.parse import urljoin, urlsplit
8
+
9
+ from offering_protocol.agent.client import AgentError, ServiceClient
10
+ from offering_protocol.agent.schema import resolve_schema
11
+ from offering_protocol.core import (
12
+ Action,
13
+ ActionRelation,
14
+ ActionRequest,
15
+ AuthenticationRequirement,
16
+ Offering,
17
+ )
18
+
19
+ _MAXIMUM_OPENAPI_BYTES = 1_048_576
20
+
21
+
22
+ class OfferingIssueScope(StrEnum):
23
+ ACTION = "action"
24
+ ATTRIBUTE_SCHEMA = "attribute_schema"
25
+ ATTRIBUTES = "attributes"
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class OfferingIssue:
30
+ scope: OfferingIssueScope
31
+ message: str
32
+ action_id: str | None = None
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class DiscoveredHttpAction:
37
+ method: str
38
+ request: ActionRequest | None
39
+ response_content_types: tuple[str, ...]
40
+ url: str
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class DiscoveredOpenApiAction:
45
+ operation_id: str
46
+ url: str
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class DiscoveredAction:
51
+ authentication: AuthenticationRequirement
52
+ description: str
53
+ http: DiscoveredHttpAction | None
54
+ id: str
55
+ openapi: DiscoveredOpenApiAction | None
56
+ rel: ActionRelation
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class OfferingDetails:
61
+ actions: tuple[DiscoveredAction, ...]
62
+ attribute_schema: dict[str, object] | None
63
+ issues: tuple[OfferingIssue, ...]
64
+ offering: Offering
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class ResolvedAction:
69
+ action: DiscoveredAction
70
+ openapi_document: dict[str, object] | None = None
71
+ operation: dict[str, object] | None = None
72
+ request_schema: dict[str, object] | None = None
73
+
74
+
75
+ async def get_offering_details(client: ServiceClient, identifier: str) -> OfferingDetails:
76
+ inspection = await client.inspect()
77
+ offering = await client.get_offering(identifier)
78
+ service_openapi = (
79
+ inspection.document.http.openapi.url if inspection.document.http.openapi is not None else ""
80
+ )
81
+ actions, issues = _normalize_actions(offering.actions, client.service_origin, service_openapi)
82
+ attribute_schema: dict[str, object] | None = None
83
+ if offering.schema_ is not None:
84
+ try:
85
+ target = _resolve_https_reference(offering.schema_.url, client.service_origin)
86
+ resolved_schema = await resolve_schema(client, target)
87
+ attribute_schema = resolved_schema.schema
88
+ if not resolved_schema.validator.is_valid(offering.attributes):
89
+ offering = offering.model_copy(update={"attributes": {}})
90
+ issues.append(
91
+ OfferingIssue(
92
+ OfferingIssueScope.ATTRIBUTES,
93
+ "Offering attributes do not match their Attribute Schema",
94
+ )
95
+ )
96
+ except (AgentError, ValueError) as error:
97
+ offering = offering.model_copy(update={"attributes": {}})
98
+ issues.append(OfferingIssue(OfferingIssueScope.ATTRIBUTE_SCHEMA, str(error)))
99
+ return OfferingDetails(tuple(actions), attribute_schema, tuple(issues), offering)
100
+
101
+
102
+ async def resolve_action(client: ServiceClient, offering_id: str, action_id: str) -> ResolvedAction:
103
+ details = await get_offering_details(client, offering_id)
104
+ action = next((item for item in details.actions if item.id == action_id), None)
105
+ if action is None:
106
+ raise AgentError(f"ODP Offering does not expose usable Action {action_id}")
107
+ if action.http is not None:
108
+ request_schema = None
109
+ if action.http.request is not None and action.http.request.schema_ is not None:
110
+ target = _resolve_https_reference(
111
+ action.http.request.schema_.url, client.service_origin
112
+ )
113
+ request_schema = (await resolve_schema(client, target)).schema
114
+ return ResolvedAction(action=action, request_schema=request_schema)
115
+ if action.openapi is None:
116
+ raise AgentError("ODP Action has no usable target")
117
+ document = await client._supporting_json(
118
+ action.openapi.url,
119
+ "openapi",
120
+ "application/vnd.oai.openapi+json;version=3.1, application/json;q=0.9",
121
+ {"application/vnd.oai.openapi+json", "application/json"},
122
+ _MAXIMUM_OPENAPI_BYTES,
123
+ )
124
+ version = document.get("openapi")
125
+ if not isinstance(version, str) or not version.startswith("3.1."):
126
+ raise AgentError("ODP Action requires an OpenAPI 3.1 document")
127
+ matches = _openapi_operations(document, action.openapi.operation_id)
128
+ if len(matches) != 1:
129
+ raise AgentError(
130
+ f"ODP Action operation_id {action.openapi.operation_id} must resolve exactly once"
131
+ )
132
+ return ResolvedAction(action=action, openapi_document=document, operation=matches[0])
133
+
134
+
135
+ def _normalize_actions(
136
+ actions: list[Action], service_origin: str, service_openapi: str
137
+ ) -> tuple[list[DiscoveredAction], list[OfferingIssue]]:
138
+ counts: dict[str, int] = {}
139
+ for action in actions:
140
+ counts[action.id] = counts.get(action.id, 0) + 1
141
+ discovered: list[DiscoveredAction] = []
142
+ issues: list[OfferingIssue] = []
143
+ reported: set[str] = set()
144
+ for action in actions:
145
+ if counts[action.id] > 1:
146
+ if action.id not in reported:
147
+ reported.add(action.id)
148
+ issues.append(
149
+ OfferingIssue(
150
+ OfferingIssueScope.ACTION,
151
+ f"Duplicate Action identifier {action.id}",
152
+ action.id,
153
+ )
154
+ )
155
+ continue
156
+ try:
157
+ value = _normalize_action(action, service_origin, service_openapi)
158
+ if value is not None:
159
+ discovered.append(value)
160
+ except AgentError as error:
161
+ issues.append(OfferingIssue(OfferingIssueScope.ACTION, str(error), action.id))
162
+ return discovered, issues
163
+
164
+
165
+ def _normalize_action(
166
+ action: Action, service_origin: str, service_openapi: str
167
+ ) -> DiscoveredAction | None:
168
+ if action.http is not None:
169
+ return DiscoveredAction(
170
+ authentication=action.authentication,
171
+ description=action.description,
172
+ http=DiscoveredHttpAction(
173
+ method=action.http.method,
174
+ request=action.http.request,
175
+ response_content_types=tuple(action.http.response_content_types),
176
+ url=_resolve_http_reference(action.http.href, service_origin),
177
+ ),
178
+ id=action.id,
179
+ openapi=None,
180
+ rel=action.rel,
181
+ )
182
+ if action.openapi is not None:
183
+ target = action.openapi.url or service_openapi
184
+ if not target:
185
+ raise AgentError("OpenAPI Action has no OpenAPI document URL")
186
+ return DiscoveredAction(
187
+ authentication=action.authentication,
188
+ description=action.description,
189
+ http=None,
190
+ id=action.id,
191
+ openapi=DiscoveredOpenApiAction(
192
+ action.openapi.operation_id,
193
+ _resolve_https_reference(target, service_origin),
194
+ ),
195
+ rel=action.rel,
196
+ )
197
+ return None
198
+
199
+
200
+ def _resolve_http_reference(reference: str, base: str) -> str:
201
+ target = urljoin(f"{base}/", reference)
202
+ parsed = urlsplit(target)
203
+ if parsed.scheme not in {"http", "https"} or parsed.hostname is None:
204
+ raise AgentError("ODP Action target must use HTTP or HTTPS")
205
+ return target
206
+
207
+
208
+ def _resolve_https_reference(reference: str, base: str) -> str:
209
+ target = _resolve_http_reference(reference, base)
210
+ if urlsplit(target).scheme != "https":
211
+ raise AgentError("ODP supporting document URL must use HTTPS")
212
+ return target
213
+
214
+
215
+ def _openapi_operations(document: dict[str, object], operation_id: str) -> list[dict[str, object]]:
216
+ paths = document.get("paths")
217
+ if not isinstance(paths, dict):
218
+ raise AgentError("ODP OpenAPI document must contain paths")
219
+ matches: list[dict[str, object]] = []
220
+ for path in paths.values():
221
+ if not isinstance(path, dict):
222
+ continue
223
+ for method in ("delete", "get", "head", "options", "patch", "post", "put", "trace"):
224
+ operation = path.get(method)
225
+ if isinstance(operation, dict) and operation.get("operationId") == operation_id:
226
+ matches.append(operation)
227
+ return matches
@@ -0,0 +1,129 @@
1
+ """Bounded Attribute Schema resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from typing import Any, Protocol
8
+ from urllib.parse import urldefrag, urljoin, urlsplit
9
+
10
+ from jsonschema.validators import validator_for
11
+ from referencing import Registry, Resource
12
+
13
+ from offering_protocol.agent.client import AgentError, ServiceClient
14
+
15
+ _DIALECT = "https://json-schema.org/draft/2020-12/schema"
16
+ _MAXIMUM_DOCUMENT_BYTES = 262_144
17
+ _MAXIMUM_DOCUMENTS = 16
18
+ _MAXIMUM_DEPTH = 8
19
+ _MAXIMUM_GRAPH_BYTES = 1_048_576
20
+ _STANDARD_VOCABULARY = "https://json-schema.org/draft/2020-12/vocab/"
21
+
22
+
23
+ class SchemaValidator(Protocol):
24
+ def is_valid(self, instance: Any) -> bool: ...
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class ResolvedSchema:
29
+ schema: dict[str, object]
30
+ validator: SchemaValidator
31
+
32
+
33
+ async def resolve_schema(client: ServiceClient, target: str) -> ResolvedSchema:
34
+ root_url = _document_url(target)
35
+ documents: dict[str, dict[str, object]] = {}
36
+ graph_bytes = 0
37
+
38
+ async def load(document_url: str, depth: int) -> None:
39
+ nonlocal graph_bytes
40
+ if document_url in documents:
41
+ return
42
+ if len(documents) >= _MAXIMUM_DOCUMENTS:
43
+ raise AgentError("ODP Attribute Schema graph exceeds 16 documents")
44
+ if depth > _MAXIMUM_DEPTH:
45
+ raise AgentError("ODP Attribute Schema graph exceeds eight reference levels")
46
+ document = await client._supporting_json(
47
+ document_url,
48
+ "attribute-schema",
49
+ "application/schema+json",
50
+ {"application/schema+json"},
51
+ _MAXIMUM_DOCUMENT_BYTES,
52
+ )
53
+ _require_schema(document)
54
+ encoded = json.dumps(document, separators=(",", ":")).encode()
55
+ graph_bytes += len(encoded)
56
+ if graph_bytes > _MAXIMUM_GRAPH_BYTES:
57
+ raise AgentError("ODP Attribute Schema graph exceeds its byte limit")
58
+ documents[document_url] = document
59
+ for reference_url in _schema_references(document, document_url):
60
+ await load(reference_url, depth + 1)
61
+
62
+ await load(root_url, 0)
63
+ root = documents[root_url]
64
+ registry: Registry[Any] = Registry().with_resources(
65
+ (url, Resource.from_contents(document)) for url, document in documents.items()
66
+ )
67
+ validation_root = {"$id": root_url, **root}
68
+ validator_type = validator_for(validation_root)
69
+ validator_type.check_schema(validation_root)
70
+ return ResolvedSchema(root, validator_type(validation_root, registry=registry))
71
+
72
+
73
+ def _document_url(value: str) -> str:
74
+ target, _ = urldefrag(value)
75
+ parsed = urlsplit(target)
76
+ if (
77
+ parsed.scheme != "https"
78
+ or parsed.hostname is None
79
+ or parsed.username is not None
80
+ or parsed.password is not None
81
+ ):
82
+ raise AgentError("ODP Attribute Schema references must use HTTPS")
83
+ return target
84
+
85
+
86
+ def _require_schema(document: dict[str, object]) -> None:
87
+ if document.get("$schema") != _DIALECT:
88
+ raise AgentError("ODP Attribute Schema must declare JSON Schema Draft 2020-12")
89
+ pending: list[object] = [document]
90
+ while pending:
91
+ value = pending.pop()
92
+ if isinstance(value, list):
93
+ pending.extend(value)
94
+ elif isinstance(value, dict):
95
+ if "$dynamicRef" in value:
96
+ reference = value["$dynamicRef"]
97
+ if not isinstance(reference, str) or not reference.startswith("#"):
98
+ raise AgentError(
99
+ "ODP Attribute Schema $dynamicRef must be a fragment-only reference"
100
+ )
101
+ vocabulary = value.get("$vocabulary")
102
+ if isinstance(vocabulary, dict):
103
+ for uri, required in vocabulary.items():
104
+ if required is True and not str(uri).startswith(_STANDARD_VOCABULARY):
105
+ raise AgentError(
106
+ f"ODP Attribute Schema requires unsupported vocabulary {uri}"
107
+ )
108
+ pending.extend(value.values())
109
+
110
+
111
+ def _schema_references(document: object, retrieval_url: str) -> tuple[str, ...]:
112
+ references: list[str] = []
113
+ local_resources = {_document_url(retrieval_url)}
114
+ pending = [(document, retrieval_url)]
115
+ while pending:
116
+ value, inherited_base = pending.pop()
117
+ if isinstance(value, list):
118
+ pending.extend((child, inherited_base) for child in value)
119
+ elif isinstance(value, dict):
120
+ base = inherited_base
121
+ identifier = value.get("$id")
122
+ if isinstance(identifier, str):
123
+ base = urljoin(inherited_base, identifier)
124
+ local_resources.add(_document_url(base))
125
+ reference = value.get("$ref")
126
+ if isinstance(reference, str):
127
+ references.append(_document_url(urljoin(base, reference)))
128
+ pending.extend((child, base) for keyword, child in value.items() if keyword != "$ref")
129
+ return tuple(reference for reference in references if reference not in local_resources)
@@ -0,0 +1,64 @@
1
+ """Protocol models, validation, identities, references, and pagination."""
2
+
3
+ from offering_protocol.core.models import * # noqa: F403
4
+ from offering_protocol.core.models import __all__ as _models_all
5
+ from offering_protocol.core.references import (
6
+ ReferenceError,
7
+ build_operation_url,
8
+ create_resource_identity,
9
+ derive_service_origin,
10
+ is_local_resource_identifier,
11
+ operation_method,
12
+ operation_path,
13
+ resolve_continuation,
14
+ resolve_resource_reference,
15
+ )
16
+ from offering_protocol.core.validation import (
17
+ OdpValidationError,
18
+ ValidationIssue,
19
+ parse_collection,
20
+ parse_collection_page,
21
+ parse_collection_search_request,
22
+ parse_filter_definition,
23
+ parse_filter_definition_page,
24
+ parse_offering,
25
+ parse_offering_page,
26
+ parse_offering_search_request,
27
+ parse_problem_details,
28
+ parse_problem_response,
29
+ parse_resource_identity,
30
+ parse_service_document,
31
+ parse_sort_definition,
32
+ parse_sort_definition_page,
33
+ validate_value,
34
+ )
35
+
36
+ __all__ = [
37
+ *_models_all,
38
+ "OdpValidationError",
39
+ "ReferenceError",
40
+ "ValidationIssue",
41
+ "build_operation_url",
42
+ "create_resource_identity",
43
+ "derive_service_origin",
44
+ "is_local_resource_identifier",
45
+ "operation_method",
46
+ "operation_path",
47
+ "parse_collection",
48
+ "parse_collection_page",
49
+ "parse_collection_search_request",
50
+ "parse_filter_definition",
51
+ "parse_filter_definition_page",
52
+ "parse_offering",
53
+ "parse_offering_page",
54
+ "parse_offering_search_request",
55
+ "parse_problem_details",
56
+ "parse_problem_response",
57
+ "parse_resource_identity",
58
+ "parse_service_document",
59
+ "parse_sort_definition",
60
+ "parse_sort_definition_page",
61
+ "resolve_continuation",
62
+ "resolve_resource_reference",
63
+ "validate_value",
64
+ ]