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,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