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,390 @@
|
|
|
1
|
+
"""Normative schema-backed ODP parsing and validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from importlib.resources import files
|
|
10
|
+
from typing import Any, TypeVar
|
|
11
|
+
|
|
12
|
+
from jsonschema import FormatChecker
|
|
13
|
+
from jsonschema.exceptions import ValidationError as JsonSchemaValidationError
|
|
14
|
+
from jsonschema.validators import validator_for
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
from pydantic import ValidationError as ModelValidationError
|
|
17
|
+
from referencing import Registry, Resource
|
|
18
|
+
|
|
19
|
+
from offering_protocol.core.models import (
|
|
20
|
+
Collection,
|
|
21
|
+
CollectionSearchRequest,
|
|
22
|
+
FilterDefinition,
|
|
23
|
+
FilterOperator,
|
|
24
|
+
FilterType,
|
|
25
|
+
Offering,
|
|
26
|
+
OfferingPage,
|
|
27
|
+
OfferingSearchRequest,
|
|
28
|
+
Operation,
|
|
29
|
+
Page,
|
|
30
|
+
ProblemDetails,
|
|
31
|
+
ResourceIdentity,
|
|
32
|
+
ServiceDocument,
|
|
33
|
+
SortDefinition,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class ValidationIssue:
|
|
39
|
+
path: str
|
|
40
|
+
keyword: str
|
|
41
|
+
message: str
|
|
42
|
+
params: dict[str, object] = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class OdpValidationError(ValueError):
|
|
46
|
+
def __init__(self, document_type: str, issues: list[ValidationIssue]) -> None:
|
|
47
|
+
super().__init__(f"invalid ODP {document_type}")
|
|
48
|
+
self.document_type = document_type
|
|
49
|
+
self.issues = tuple(issues)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
Model = TypeVar("Model", bound=BaseModel)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_service_document(data: bytes | str) -> ServiceDocument:
|
|
56
|
+
value = _parse(data, "service-document.schema.json", "Service Document", ServiceDocument)
|
|
57
|
+
issues: list[ValidationIssue] = []
|
|
58
|
+
if "id" in value.additional:
|
|
59
|
+
issues.append(_issue("/id", "prohibited", "must not appear in a Service Document"))
|
|
60
|
+
if "web_url" in value.additional:
|
|
61
|
+
issues.append(_issue("/web_url", "prohibited", "must not appear in a Service Document"))
|
|
62
|
+
_validate_localizations(value.language, value.localizations, True, issues)
|
|
63
|
+
if sum(len(keyword) for keyword in value.keywords) > 1024:
|
|
64
|
+
issues.append(
|
|
65
|
+
_issue(
|
|
66
|
+
"/keywords",
|
|
67
|
+
"max-code-points",
|
|
68
|
+
"must contain no more than 1024 code points in total",
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
if value.search_capabilities is not None and not any(
|
|
72
|
+
operation.name is Operation.SEARCH_OFFERINGS for operation in value.operations
|
|
73
|
+
):
|
|
74
|
+
issues.append(
|
|
75
|
+
_issue(
|
|
76
|
+
"/search_capabilities",
|
|
77
|
+
"operation-support",
|
|
78
|
+
"requires the search-offerings operation",
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
_raise_refinement("Service Document", issues)
|
|
82
|
+
return value
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def parse_collection(data: bytes | str) -> Collection:
|
|
86
|
+
value = _parse(data, "collection.schema.json", "Collection", Collection)
|
|
87
|
+
_validate_representation(
|
|
88
|
+
value.language, value.localizations, [image.src for image in value.images]
|
|
89
|
+
)
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def parse_offering(data: bytes | str) -> Offering:
|
|
94
|
+
value = _parse(data, "offering.schema.json", "Offering", Offering)
|
|
95
|
+
_validate_representation(
|
|
96
|
+
value.language, value.localizations, [image.src for image in value.images]
|
|
97
|
+
)
|
|
98
|
+
return value
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse_problem_details(data: bytes | str) -> ProblemDetails:
|
|
102
|
+
return _parse(data, "problem-details.schema.json", "Problem Details", ProblemDetails)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def parse_problem_response(data: bytes | str, http_status: int) -> ProblemDetails:
|
|
106
|
+
value = parse_problem_details(data)
|
|
107
|
+
if value.status != http_status:
|
|
108
|
+
raise OdpValidationError(
|
|
109
|
+
"Problem Details",
|
|
110
|
+
[_issue("/status", "http-status", "must match the HTTP response status")],
|
|
111
|
+
)
|
|
112
|
+
return value
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def parse_resource_identity(data: bytes | str) -> ResourceIdentity:
|
|
116
|
+
return _parse(data, "resource-identity.schema.json", "resource identity", ResourceIdentity)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def parse_collection_page(data: bytes | str) -> Page[Collection]:
|
|
120
|
+
value = _parse(data, "page-envelope.schema.json", "Collection page", Page[Collection])
|
|
121
|
+
for item in value.items:
|
|
122
|
+
parse_collection(_embedded_json(item, value.odp_version))
|
|
123
|
+
return value
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def parse_offering_page(data: bytes | str) -> OfferingPage[Offering]:
|
|
127
|
+
value = _parse(
|
|
128
|
+
data,
|
|
129
|
+
"offering-search-response.schema.json",
|
|
130
|
+
"Offering page",
|
|
131
|
+
OfferingPage[Offering],
|
|
132
|
+
)
|
|
133
|
+
for item in value.items:
|
|
134
|
+
parse_offering(_embedded_json(item, value.odp_version))
|
|
135
|
+
return value
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def parse_collection_search_request(data: bytes | str) -> CollectionSearchRequest:
|
|
139
|
+
return _parse(
|
|
140
|
+
data,
|
|
141
|
+
"collection-search-request.schema.json",
|
|
142
|
+
"Collection search request",
|
|
143
|
+
CollectionSearchRequest,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def parse_offering_search_request(data: bytes | str) -> OfferingSearchRequest:
|
|
148
|
+
return _parse(
|
|
149
|
+
data,
|
|
150
|
+
"offering-search-request.schema.json",
|
|
151
|
+
"Offering search request",
|
|
152
|
+
OfferingSearchRequest,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def parse_filter_definition(data: bytes | str) -> FilterDefinition:
|
|
157
|
+
value = _parse(data, "filter-definition.schema.json", "Filter Definition", FilterDefinition)
|
|
158
|
+
ordered = {
|
|
159
|
+
FilterOperator.GREATER_THAN,
|
|
160
|
+
FilterOperator.GREATER_THAN_OR_EQUAL,
|
|
161
|
+
FilterOperator.LESS_THAN,
|
|
162
|
+
FilterOperator.LESS_THAN_OR_EQUAL,
|
|
163
|
+
}
|
|
164
|
+
issues: list[ValidationIssue] = []
|
|
165
|
+
if value.filter_type in {FilterType.STRING, FilterType.BOOLEAN} and any(
|
|
166
|
+
operator in ordered for operator in value.operators
|
|
167
|
+
):
|
|
168
|
+
issues.append(
|
|
169
|
+
_issue(
|
|
170
|
+
"/operators",
|
|
171
|
+
"operator-type",
|
|
172
|
+
"contains an operator incompatible with the Filter type",
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
if value.filter_type is FilterType.BOOLEAN and value.unit is not None:
|
|
176
|
+
issues.append(_issue("/unit", "unit-type", "must not appear on a boolean Filter"))
|
|
177
|
+
_raise_refinement("Filter Definition", issues)
|
|
178
|
+
return value
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def parse_sort_definition(data: bytes | str) -> SortDefinition:
|
|
182
|
+
return _parse(data, "sort-definition.schema.json", "Sort Definition", SortDefinition)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def parse_filter_definition_page(data: bytes | str) -> Page[FilterDefinition]:
|
|
186
|
+
value = _parse(
|
|
187
|
+
data,
|
|
188
|
+
"filter-definition-page.schema.json",
|
|
189
|
+
"Filter Definition page",
|
|
190
|
+
Page[FilterDefinition],
|
|
191
|
+
)
|
|
192
|
+
for item in value.items:
|
|
193
|
+
parse_filter_definition(_model_json(item))
|
|
194
|
+
return value
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def parse_sort_definition_page(data: bytes | str) -> Page[SortDefinition]:
|
|
198
|
+
return _parse(
|
|
199
|
+
data,
|
|
200
|
+
"sort-definition-page.schema.json",
|
|
201
|
+
"Sort Definition page",
|
|
202
|
+
Page[SortDefinition],
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def validate_value(value: object, schema_name: str, document_type: str) -> None:
|
|
207
|
+
validator = _validators().get(schema_name)
|
|
208
|
+
if validator is None:
|
|
209
|
+
raise RuntimeError(f"missing bundled schema {schema_name}")
|
|
210
|
+
issues = [_schema_issue(error) for error in validator.iter_errors(value)]
|
|
211
|
+
if issues:
|
|
212
|
+
issues.sort(key=lambda issue: (issue.path, issue.keyword, issue.message))
|
|
213
|
+
raise OdpValidationError(document_type, issues)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _parse(data: bytes | str, schema_name: str, document_type: str, model: type[Model]) -> Model:
|
|
217
|
+
try:
|
|
218
|
+
raw = json.loads(data)
|
|
219
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
220
|
+
raise OdpValidationError(document_type, [_issue("", "json", str(error))]) from error
|
|
221
|
+
validate_value(raw, schema_name, document_type)
|
|
222
|
+
try:
|
|
223
|
+
return model.model_validate(raw)
|
|
224
|
+
except ModelValidationError as error:
|
|
225
|
+
issues = [
|
|
226
|
+
ValidationIssue(
|
|
227
|
+
path="/" + "/".join(str(part) for part in item["loc"]),
|
|
228
|
+
keyword=str(item["type"]),
|
|
229
|
+
message=str(item["msg"]),
|
|
230
|
+
)
|
|
231
|
+
for item in error.errors()
|
|
232
|
+
]
|
|
233
|
+
raise OdpValidationError(document_type, issues) from error
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _model_json(value: BaseModel) -> str:
|
|
237
|
+
return value.model_dump_json(by_alias=True, exclude_unset=True)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _embedded_json(value: BaseModel, inherited_version: str) -> str:
|
|
241
|
+
document = value.model_dump(mode="json", by_alias=True, exclude_unset=True)
|
|
242
|
+
document.setdefault("odp_version", inherited_version)
|
|
243
|
+
return json.dumps(document, separators=(",", ":"))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@lru_cache(maxsize=1)
|
|
247
|
+
def _validators() -> dict[str, Any]:
|
|
248
|
+
schema_directory = files("offering_protocol.core").joinpath("schemas")
|
|
249
|
+
documents: dict[str, dict[str, object]] = {}
|
|
250
|
+
resources: list[tuple[str, Resource[dict[str, object]]]] = []
|
|
251
|
+
for entry in schema_directory.iterdir():
|
|
252
|
+
if entry.name.endswith(".schema.json"): # pragma: no branch
|
|
253
|
+
document = json.loads(entry.read_text(encoding="utf-8"))
|
|
254
|
+
documents[entry.name] = document
|
|
255
|
+
identifier = document.get("$id", f"https://offeringprotocol.org/schemas/{entry.name}")
|
|
256
|
+
resources.append((str(identifier), Resource.from_contents(document)))
|
|
257
|
+
registry = Registry().with_resources(resources)
|
|
258
|
+
validators: dict[str, Any] = {}
|
|
259
|
+
for name, document in documents.items():
|
|
260
|
+
validator_type = validator_for(document)
|
|
261
|
+
validator_type.check_schema(document)
|
|
262
|
+
validators[name] = validator_type(
|
|
263
|
+
document,
|
|
264
|
+
registry=registry,
|
|
265
|
+
format_checker=FormatChecker(),
|
|
266
|
+
)
|
|
267
|
+
return validators
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _schema_issue(error: JsonSchemaValidationError) -> ValidationIssue:
|
|
271
|
+
path = "".join(f"/{str(part).replace('~', '~0').replace('/', '~1')}" for part in error.path)
|
|
272
|
+
keyword = str(error.schema_path[-1]) if error.schema_path else "schema"
|
|
273
|
+
return ValidationIssue(path=path, keyword=keyword, message=error.message)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _validate_representation(language: str, localizations: list[str], images: list[str]) -> None:
|
|
277
|
+
issues: list[ValidationIssue] = []
|
|
278
|
+
_validate_localizations(language, localizations, False, issues)
|
|
279
|
+
if len(images) != len(set(images)):
|
|
280
|
+
issues.append(_issue("/images", "unique-image-source", "must contain unique image sources"))
|
|
281
|
+
_raise_refinement("representation", issues)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _validate_localizations(
|
|
285
|
+
language: str, localizations: list[str], require_default: bool, issues: list[ValidationIssue]
|
|
286
|
+
) -> None:
|
|
287
|
+
if language and not _is_language_tag(language):
|
|
288
|
+
issues.append(_issue("/language", "language-tag", "must be a language tag"))
|
|
289
|
+
if any(not _is_language_tag(tag) for tag in localizations):
|
|
290
|
+
issues.append(
|
|
291
|
+
_issue(
|
|
292
|
+
"/localizations",
|
|
293
|
+
"language-tag",
|
|
294
|
+
"must contain only language tags",
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
folded = [tag.casefold() for tag in localizations]
|
|
298
|
+
if len(folded) != len(set(folded)):
|
|
299
|
+
issues.append(
|
|
300
|
+
_issue(
|
|
301
|
+
"/localizations",
|
|
302
|
+
"unique-language-tag",
|
|
303
|
+
"must be unique without regard to case",
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
if (require_default or (language and localizations)) and language.casefold() not in folded:
|
|
307
|
+
issues.append(
|
|
308
|
+
_issue(
|
|
309
|
+
"/localizations",
|
|
310
|
+
"contains-default-language" if require_default else "contains-language",
|
|
311
|
+
"must contain the default language"
|
|
312
|
+
if require_default
|
|
313
|
+
else "must contain the representation language",
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
_ALPHANUMERIC = re.compile(r"^[A-Za-z0-9]+$")
|
|
319
|
+
_GRANDFATHERED_LANGUAGE_TAGS = {
|
|
320
|
+
"art-lojban",
|
|
321
|
+
"cel-gaulish",
|
|
322
|
+
"en-gb-oed",
|
|
323
|
+
"i-ami",
|
|
324
|
+
"i-bnn",
|
|
325
|
+
"i-default",
|
|
326
|
+
"i-enochian",
|
|
327
|
+
"i-hak",
|
|
328
|
+
"i-klingon",
|
|
329
|
+
"i-lux",
|
|
330
|
+
"i-mingo",
|
|
331
|
+
"i-navajo",
|
|
332
|
+
"i-pwn",
|
|
333
|
+
"i-tao",
|
|
334
|
+
"i-tay",
|
|
335
|
+
"i-tsu",
|
|
336
|
+
"no-bok",
|
|
337
|
+
"no-nyn",
|
|
338
|
+
"sgn-be-fr",
|
|
339
|
+
"sgn-be-nl",
|
|
340
|
+
"sgn-ch-de",
|
|
341
|
+
"zh-guoyu",
|
|
342
|
+
"zh-hakka",
|
|
343
|
+
"zh-min",
|
|
344
|
+
"zh-min-nan",
|
|
345
|
+
"zh-xiang",
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _is_language_tag(value: str) -> bool:
|
|
350
|
+
if not value or len(value) > 255 or not _ALPHANUMERIC.match(value.replace("-", "")):
|
|
351
|
+
return False
|
|
352
|
+
subtags = value.lower().split("-")
|
|
353
|
+
if value.lower() in _GRANDFATHERED_LANGUAGE_TAGS:
|
|
354
|
+
return True
|
|
355
|
+
if any(not subtag or len(subtag) > 8 for subtag in subtags):
|
|
356
|
+
return False
|
|
357
|
+
if subtags[0] == "x":
|
|
358
|
+
return len(subtags) > 1
|
|
359
|
+
if not (2 <= len(subtags[0]) <= 8 and subtags[0].isalpha()):
|
|
360
|
+
return False
|
|
361
|
+
|
|
362
|
+
variants: set[str] = set()
|
|
363
|
+
extensions: set[str] = set()
|
|
364
|
+
in_extension = False
|
|
365
|
+
for subtag in subtags[1:]:
|
|
366
|
+
if len(subtag) == 1:
|
|
367
|
+
in_extension = True
|
|
368
|
+
if subtag == "x":
|
|
369
|
+
return subtag != subtags[-1]
|
|
370
|
+
if subtag in extensions:
|
|
371
|
+
return False
|
|
372
|
+
extensions.add(subtag)
|
|
373
|
+
continue
|
|
374
|
+
if in_extension:
|
|
375
|
+
continue
|
|
376
|
+
is_variant = 5 <= len(subtag) <= 8 or (len(subtag) == 4 and subtag[0].isdigit())
|
|
377
|
+
if is_variant:
|
|
378
|
+
if subtag in variants:
|
|
379
|
+
return False
|
|
380
|
+
variants.add(subtag)
|
|
381
|
+
return not in_extension or len(subtags[-1]) > 1
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _issue(path: str, keyword: str, message: str) -> ValidationIssue:
|
|
385
|
+
return ValidationIssue(path=path, keyword=keyword, message=message)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _raise_refinement(document_type: str, issues: list[ValidationIssue]) -> None:
|
|
389
|
+
if issues:
|
|
390
|
+
raise OdpValidationError(document_type, issues)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Canonical Directory integration."""
|
|
2
|
+
|
|
3
|
+
from offering_protocol.directory.client import (
|
|
4
|
+
DirectoryClient,
|
|
5
|
+
DirectoryError,
|
|
6
|
+
DirectoryRequestError,
|
|
7
|
+
)
|
|
8
|
+
from offering_protocol.directory.models import (
|
|
9
|
+
DirectoryService,
|
|
10
|
+
Environment,
|
|
11
|
+
Facet,
|
|
12
|
+
Facets,
|
|
13
|
+
IterationOptions,
|
|
14
|
+
OperationFilter,
|
|
15
|
+
PaymentFilter,
|
|
16
|
+
PaymentOptionFacetValue,
|
|
17
|
+
SearchPage,
|
|
18
|
+
SearchRequest,
|
|
19
|
+
ServiceFilters,
|
|
20
|
+
SuggestionRequest,
|
|
21
|
+
)
|
|
22
|
+
from offering_protocol.directory.transport import (
|
|
23
|
+
HttpRequest,
|
|
24
|
+
HttpResponse,
|
|
25
|
+
HttpxTransport,
|
|
26
|
+
Transport,
|
|
27
|
+
TransportError,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"DirectoryClient",
|
|
32
|
+
"DirectoryError",
|
|
33
|
+
"DirectoryRequestError",
|
|
34
|
+
"DirectoryService",
|
|
35
|
+
"Environment",
|
|
36
|
+
"Facet",
|
|
37
|
+
"Facets",
|
|
38
|
+
"HttpRequest",
|
|
39
|
+
"HttpResponse",
|
|
40
|
+
"HttpxTransport",
|
|
41
|
+
"IterationOptions",
|
|
42
|
+
"OperationFilter",
|
|
43
|
+
"PaymentFilter",
|
|
44
|
+
"PaymentOptionFacetValue",
|
|
45
|
+
"SearchPage",
|
|
46
|
+
"SearchRequest",
|
|
47
|
+
"ServiceFilters",
|
|
48
|
+
"SuggestionRequest",
|
|
49
|
+
"Transport",
|
|
50
|
+
"TransportError",
|
|
51
|
+
]
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Canonical production and sandbox Directory client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from urllib.parse import urlencode, urljoin
|
|
7
|
+
|
|
8
|
+
from pydantic import ValidationError as ModelValidationError
|
|
9
|
+
|
|
10
|
+
from offering_protocol.core import derive_service_origin
|
|
11
|
+
from offering_protocol.directory.models import (
|
|
12
|
+
DirectoryService,
|
|
13
|
+
Environment,
|
|
14
|
+
IterationOptions,
|
|
15
|
+
SearchPage,
|
|
16
|
+
SearchRequest,
|
|
17
|
+
SuggestionRequest,
|
|
18
|
+
)
|
|
19
|
+
from offering_protocol.directory.transport import (
|
|
20
|
+
HttpRequest,
|
|
21
|
+
HttpResponse,
|
|
22
|
+
HttpxTransport,
|
|
23
|
+
Transport,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_MAXIMUM_REDIRECTS = 5
|
|
27
|
+
_MAXIMUM_RESPONSE_BYTES = 524_288
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DirectoryError(RuntimeError):
|
|
31
|
+
"""Base error for canonical Directory operations."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DirectoryRequestError(DirectoryError):
|
|
35
|
+
def __init__(self, status: int, message: str, headers: dict[str, str]) -> None:
|
|
36
|
+
super().__init__(f"Directory request failed with HTTP {status}: {message}")
|
|
37
|
+
self.status = status
|
|
38
|
+
self.headers = headers
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DirectoryClient:
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
environment: Environment = Environment.PRODUCTION,
|
|
45
|
+
*,
|
|
46
|
+
transport: Transport | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
self.environment = environment
|
|
49
|
+
self._owns_transport = transport is None
|
|
50
|
+
self._transport = transport or HttpxTransport()
|
|
51
|
+
|
|
52
|
+
async def __aenter__(self) -> DirectoryClient:
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
async def __aexit__(self, *args: object) -> None:
|
|
56
|
+
await self.aclose()
|
|
57
|
+
|
|
58
|
+
async def aclose(self) -> None:
|
|
59
|
+
if self._owns_transport:
|
|
60
|
+
await self._transport.aclose()
|
|
61
|
+
|
|
62
|
+
async def search(self, request: SearchRequest) -> SearchPage:
|
|
63
|
+
_validate_search_request(request)
|
|
64
|
+
response = await self._request(
|
|
65
|
+
"POST",
|
|
66
|
+
f"{self.environment.origin}/v1/services/search",
|
|
67
|
+
json.dumps(request.to_dict(), separators=(",", ":")).encode(),
|
|
68
|
+
)
|
|
69
|
+
return _parse_search_page(response.body)
|
|
70
|
+
|
|
71
|
+
async def continue_search(self, next_reference: str) -> SearchPage:
|
|
72
|
+
target = urljoin(f"{self.environment.origin}/", next_reference)
|
|
73
|
+
if derive_service_origin(target) != self.environment.origin:
|
|
74
|
+
raise DirectoryError("Directory continuation changed canonical origin")
|
|
75
|
+
response = await self._request("GET", target)
|
|
76
|
+
return _parse_search_page(response.body)
|
|
77
|
+
|
|
78
|
+
async def search_pages(
|
|
79
|
+
self, request: SearchRequest, options: IterationOptions | None = None
|
|
80
|
+
) -> list[SearchPage]:
|
|
81
|
+
options = options or IterationOptions()
|
|
82
|
+
maximum_pages = _bounded(options.max_pages, 16, 16, "max_pages")
|
|
83
|
+
pages: list[SearchPage] = []
|
|
84
|
+
page = await self.search(request)
|
|
85
|
+
for page_number in range(maximum_pages):
|
|
86
|
+
pages.append(page)
|
|
87
|
+
if not page.next:
|
|
88
|
+
break
|
|
89
|
+
if page_number + 1 < maximum_pages:
|
|
90
|
+
page = await self.continue_search(page.next)
|
|
91
|
+
return pages
|
|
92
|
+
|
|
93
|
+
async def search_services(
|
|
94
|
+
self, request: SearchRequest, options: IterationOptions | None = None
|
|
95
|
+
) -> list[DirectoryService]:
|
|
96
|
+
options = options or IterationOptions()
|
|
97
|
+
maximum_items = _bounded(options.max_items, 10_000, 10_000, "max_items")
|
|
98
|
+
services: list[DirectoryService] = []
|
|
99
|
+
for page in await self.search_pages(request, options):
|
|
100
|
+
services.extend(page.items[: maximum_items - len(services)])
|
|
101
|
+
if len(services) == maximum_items:
|
|
102
|
+
break
|
|
103
|
+
return services
|
|
104
|
+
|
|
105
|
+
async def suggest(self, request: SuggestionRequest) -> list[str]:
|
|
106
|
+
prefix = request.prefix.strip()
|
|
107
|
+
if not prefix or len(prefix) > 128:
|
|
108
|
+
raise DirectoryError("prefix must contain from 1 through 128 characters")
|
|
109
|
+
if request.limit > 25:
|
|
110
|
+
raise DirectoryError("limit must be from 1 through 25")
|
|
111
|
+
query = {"prefix": prefix}
|
|
112
|
+
if request.limit:
|
|
113
|
+
query["limit"] = str(request.limit)
|
|
114
|
+
response = await self._request(
|
|
115
|
+
"GET", f"{self.environment.origin}/v1/services/suggestions?{urlencode(query)}"
|
|
116
|
+
)
|
|
117
|
+
try:
|
|
118
|
+
suggestions = json.loads(response.body)
|
|
119
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
120
|
+
raise DirectoryError(f"invalid Directory suggestions: {error}") from error
|
|
121
|
+
if (
|
|
122
|
+
not isinstance(suggestions, list)
|
|
123
|
+
or len(suggestions) > 25
|
|
124
|
+
or any(
|
|
125
|
+
not isinstance(value, str)
|
|
126
|
+
or not value
|
|
127
|
+
or value.strip() != value
|
|
128
|
+
or len(value) > 128
|
|
129
|
+
for value in suggestions
|
|
130
|
+
)
|
|
131
|
+
):
|
|
132
|
+
raise DirectoryError("Directory suggestions are invalid")
|
|
133
|
+
return suggestions
|
|
134
|
+
|
|
135
|
+
async def _request(self, method: str, target: str, body: bytes = b"") -> HttpResponse:
|
|
136
|
+
for redirects in range(_MAXIMUM_REDIRECTS + 1):
|
|
137
|
+
headers = {"accept": "application/json"}
|
|
138
|
+
if body:
|
|
139
|
+
headers["content-type"] = "application/json"
|
|
140
|
+
response = await self._transport.send(
|
|
141
|
+
HttpRequest(method=method, url=target, headers=headers, body=body)
|
|
142
|
+
)
|
|
143
|
+
if response.status not in {301, 302, 303, 307, 308}:
|
|
144
|
+
return _consume_response(response)
|
|
145
|
+
if redirects == _MAXIMUM_REDIRECTS:
|
|
146
|
+
raise DirectoryError("Directory response exceeded five redirects")
|
|
147
|
+
location = response.headers.get("location")
|
|
148
|
+
if location is None:
|
|
149
|
+
raise DirectoryError("Directory redirect omitted Location")
|
|
150
|
+
next_target = urljoin(target, location)
|
|
151
|
+
if derive_service_origin(next_target) != derive_service_origin(target):
|
|
152
|
+
raise DirectoryError("Directory redirect changed origin")
|
|
153
|
+
if response.status == 303 or (response.status in {301, 302} and method == "POST"):
|
|
154
|
+
method = "GET"
|
|
155
|
+
body = b""
|
|
156
|
+
target = next_target
|
|
157
|
+
raise DirectoryError("Directory response exceeded its redirect limit")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _parse_search_page(body: bytes) -> SearchPage:
|
|
161
|
+
try:
|
|
162
|
+
page = SearchPage.model_validate_json(body)
|
|
163
|
+
except ModelValidationError as error:
|
|
164
|
+
raise DirectoryError(f"invalid Directory response: {error}") from error
|
|
165
|
+
if len(page.items) > 100:
|
|
166
|
+
raise DirectoryError("Directory search page exceeds 100 Services")
|
|
167
|
+
for service in page.items:
|
|
168
|
+
if derive_service_origin(service.service_origin) != service.service_origin:
|
|
169
|
+
raise DirectoryError("Directory Service origin is not canonical")
|
|
170
|
+
return page
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _validate_search_request(request: SearchRequest) -> None:
|
|
174
|
+
if request.limit > 100:
|
|
175
|
+
raise DirectoryError("limit must be from 1 through 100")
|
|
176
|
+
if request.query.strip() != request.query or len(request.query) > 512:
|
|
177
|
+
raise DirectoryError(
|
|
178
|
+
"query must contain at most 512 characters without surrounding whitespace"
|
|
179
|
+
)
|
|
180
|
+
if request.filters is not None and (
|
|
181
|
+
len(request.filters.keywords) > 32
|
|
182
|
+
or any(not keyword or len(keyword) > 64 for keyword in request.filters.keywords)
|
|
183
|
+
):
|
|
184
|
+
raise DirectoryError("keywords must contain at most 32 values of at most 64 characters")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _consume_response(response: HttpResponse) -> HttpResponse:
|
|
188
|
+
if len(response.body) > _MAXIMUM_RESPONSE_BYTES:
|
|
189
|
+
raise DirectoryError("Directory response exceeds 524288 bytes")
|
|
190
|
+
if not 200 <= response.status < 300:
|
|
191
|
+
raise DirectoryRequestError(
|
|
192
|
+
response.status,
|
|
193
|
+
response.body.decode(errors="replace"),
|
|
194
|
+
response.headers,
|
|
195
|
+
)
|
|
196
|
+
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
|
197
|
+
if content_type != "application/json":
|
|
198
|
+
raise DirectoryError("Directory response must use application/json")
|
|
199
|
+
return response
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _bounded(value: int, fallback: int, maximum: int, name: str) -> int:
|
|
203
|
+
result = fallback if value == 0 else value
|
|
204
|
+
if result < 1 or result > maximum:
|
|
205
|
+
raise DirectoryError(f"{name} must be from 1 through {maximum}")
|
|
206
|
+
return result
|