offering-protocol 0.1.0__tar.gz → 0.2.0__tar.gz

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 (81) hide show
  1. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/PKG-INFO +11 -4
  2. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/README.md +10 -3
  3. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/pyproject.toml +3 -3
  4. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/agent/client.py +47 -8
  5. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/agent/details.py +1 -1
  6. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/__init__.py +2 -0
  7. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/models.py +1 -1
  8. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/validation.py +365 -1
  9. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/directory/client.py +48 -3
  10. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/directory/models.py +14 -7
  11. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/service/service.py +15 -7
  12. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/.gitignore +0 -0
  13. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/LICENSE +0 -0
  14. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/__init__.py +0 -0
  15. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/agent/__init__.py +0 -0
  16. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/agent/agent.py +0 -0
  17. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/agent/cache.py +0 -0
  18. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/agent/capabilities.py +0 -0
  19. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/agent/schema.py +0 -0
  20. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/references.py +0 -0
  21. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/action-relation.schema.json +0 -0
  22. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/action-request.schema.json +0 -0
  23. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/action.schema.json +0 -0
  24. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/attribute-schema-reference.schema.json +0 -0
  25. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/authentication-requirement.schema.json +0 -0
  26. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/capability-identifier.schema.json +0 -0
  27. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/capability-link.schema.json +0 -0
  28. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/collection-search-request.schema.json +0 -0
  29. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/collection.schema.json +0 -0
  30. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/detail-fields.schema.json +0 -0
  31. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/enrollment-protocol.schema.json +0 -0
  32. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/filter-capability-source.schema.json +0 -0
  33. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/filter-definition-page.schema.json +0 -0
  34. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/filter-definition.schema.json +0 -0
  35. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/filter-expression.schema.json +0 -0
  36. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/filter-operator.schema.json +0 -0
  37. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/filter-type.schema.json +0 -0
  38. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/filter-unit.schema.json +0 -0
  39. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/http-action-target.schema.json +0 -0
  40. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/invalid-parameter.schema.json +0 -0
  41. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/local-resource-identifier-list.schema.json +0 -0
  42. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/local-resource-identifier.schema.json +0 -0
  43. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/mcp-endpoint.schema.json +0 -0
  44. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/offering-search-request.schema.json +0 -0
  45. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/offering-search-response.schema.json +0 -0
  46. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/offering.schema.json +0 -0
  47. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/openapi-action-target.schema.json +0 -0
  48. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/operation-descriptor.schema.json +0 -0
  49. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/page-envelope.schema.json +0 -0
  50. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/page-limit.schema.json +0 -0
  51. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/payment-option.schema.json +0 -0
  52. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/payment-protocol.schema.json +0 -0
  53. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/price-preview.schema.json +0 -0
  54. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/problem-code.schema.json +0 -0
  55. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/problem-details.schema.json +0 -0
  56. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/protocol-version.schema.json +0 -0
  57. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/refinement-bucket.schema.json +0 -0
  58. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/refinement-group.schema.json +0 -0
  59. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/representation.schema.json +0 -0
  60. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/resource-identity.schema.json +0 -0
  61. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/resource-image.schema.json +0 -0
  62. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/resource-reference.schema.json +0 -0
  63. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/schema-reference.schema.json +0 -0
  64. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/search-capabilities.schema.json +0 -0
  65. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/service-branding-image.schema.json +0 -0
  66. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/service-branding.schema.json +0 -0
  67. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/service-document.schema.json +0 -0
  68. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/service-openapi.schema.json +0 -0
  69. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/service-origin.schema.json +0 -0
  70. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/service-protocols.schema.json +0 -0
  71. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/sort-capability-source.schema.json +0 -0
  72. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/sort-definition-page.schema.json +0 -0
  73. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/sort-definition.schema.json +0 -0
  74. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/sort-key.schema.json +0 -0
  75. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/top-level-document.schema.json +0 -0
  76. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/core/schemas/trust-protocol.schema.json +0 -0
  77. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/directory/__init__.py +0 -0
  78. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/directory/transport.py +0 -0
  79. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/py.typed +0 -0
  80. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/service/__init__.py +0 -0
  81. {offering_protocol-0.1.0 → offering_protocol-0.2.0}/src/offering_protocol/service/static_catalog.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: offering-protocol
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Official Python SDK for the Offering Discovery Protocol
5
5
  Project-URL: Documentation, https://www.offeringprotocol.org/
6
6
  Project-URL: Issues, https://github.com/offering-protocol/odp-python/issues
@@ -94,8 +94,9 @@ async def main() -> None:
94
94
  asyncio.run(main())
95
95
  ```
96
96
 
97
- Use `search_services()` when the application wants bounded automatic pagination. Use `suggest()` to
98
- discover keyword completions supported by the Directory.
97
+ Use `search_services()` when the application wants bounded automatic pagination. Search responses
98
+ provide facets for enrollment protocols, keywords, operations, payment protocols, payment options,
99
+ and trust protocols. Use `suggest()` to discover keyword completions supported by the Directory.
99
100
 
100
101
  ## Inspect and navigate a Service
101
102
 
@@ -266,7 +267,9 @@ service = (
266
267
  Every Service integration must implement `list-offerings` and `get-offering`. `StaticCatalog` is the
267
268
  small-Service implementation: it adds Collection operations when Collections are provided and uses
268
269
  integrity-protected, stateless continuations that expire after one hour. Larger Services can
269
- implement the typed `Catalog` protocol over their existing indexed catalog and search infrastructure.
270
+ subclass the typed `Catalog` protocol over their existing indexed catalog and search infrastructure.
271
+ The protocol supplies rejecting defaults for optional operations; override an optional method and
272
+ include its matching `Operation` only when the Service implements it.
270
273
 
271
274
  Service responses are validated against the bundled normative schemas before they are returned.
272
275
  The handler enforces fixed operation paths and methods, ODP media types, request and response byte
@@ -281,6 +284,10 @@ its Actions can advertise enrollment, payment, and trust protocols, but ODP does
281
284
  credentials, invoke Actions, submit payments, or implement trust protocols. Applications compose
282
285
  the appropriate protocol clients around an Action resolved through ODP.
283
286
 
287
+ `parse_service_document` is the strict current-version Service parser. Agent inspection and
288
+ Directory results filter unrecognized enrollment, payment, and trust descriptors while retaining
289
+ strict validation for recognized descriptors.
290
+
284
291
  ## Errors and validation
285
292
 
286
293
  Each role exposes typed errors:
@@ -65,8 +65,9 @@ async def main() -> None:
65
65
  asyncio.run(main())
66
66
  ```
67
67
 
68
- Use `search_services()` when the application wants bounded automatic pagination. Use `suggest()` to
69
- discover keyword completions supported by the Directory.
68
+ Use `search_services()` when the application wants bounded automatic pagination. Search responses
69
+ provide facets for enrollment protocols, keywords, operations, payment protocols, payment options,
70
+ and trust protocols. Use `suggest()` to discover keyword completions supported by the Directory.
70
71
 
71
72
  ## Inspect and navigate a Service
72
73
 
@@ -237,7 +238,9 @@ service = (
237
238
  Every Service integration must implement `list-offerings` and `get-offering`. `StaticCatalog` is the
238
239
  small-Service implementation: it adds Collection operations when Collections are provided and uses
239
240
  integrity-protected, stateless continuations that expire after one hour. Larger Services can
240
- implement the typed `Catalog` protocol over their existing indexed catalog and search infrastructure.
241
+ subclass the typed `Catalog` protocol over their existing indexed catalog and search infrastructure.
242
+ The protocol supplies rejecting defaults for optional operations; override an optional method and
243
+ include its matching `Operation` only when the Service implements it.
241
244
 
242
245
  Service responses are validated against the bundled normative schemas before they are returned.
243
246
  The handler enforces fixed operation paths and methods, ODP media types, request and response byte
@@ -252,6 +255,10 @@ its Actions can advertise enrollment, payment, and trust protocols, but ODP does
252
255
  credentials, invoke Actions, submit payments, or implement trust protocols. Applications compose
253
256
  the appropriate protocol clients around an Action resolved through ODP.
254
257
 
258
+ `parse_service_document` is the strict current-version Service parser. Agent inspection and
259
+ Directory results filter unrecognized enrollment, payment, and trust descriptors while retaining
260
+ strict validation for recognized descriptors.
261
+
255
262
  ## Errors and validation
256
263
 
257
264
  Each role exposes typed errors:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "offering-protocol"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Official Python SDK for the Offering Discovery Protocol"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -41,12 +41,12 @@ Repository = "https://github.com/offering-protocol/odp-python"
41
41
 
42
42
  [dependency-groups]
43
43
  dev = [
44
- "build==1.5.0",
44
+ "build==1.6.1",
45
45
  "mypy==2.3.1",
46
46
  "pytest==9.1.1",
47
47
  "pytest-asyncio==1.4.0",
48
48
  "pytest-cov==7.1.0",
49
- "ruff==0.16.4",
49
+ "ruff==0.16.7",
50
50
  "twine==7.0.0",
51
51
  "types-jsonschema==4.26.0.20260518",
52
52
  ]
@@ -21,18 +21,30 @@ from offering_protocol.core import (
21
21
  OfferingSearchRequest,
22
22
  Operation,
23
23
  Page,
24
+ ProblemDetails,
24
25
  Representation,
25
26
  ServiceDocument,
26
27
  build_operation_url,
27
28
  derive_service_origin,
28
- parse_collection,
29
- parse_collection_page,
30
- parse_offering,
31
- parse_offering_page,
32
- parse_problem_response,
33
- parse_service_document,
29
+ parse_agent_service_document,
34
30
  resolve_continuation,
35
31
  )
32
+ from offering_protocol.core import (
33
+ parse_collection as parse_collection_strict,
34
+ )
35
+ from offering_protocol.core import (
36
+ parse_collection_page as parse_collection_page_strict,
37
+ )
38
+ from offering_protocol.core import (
39
+ parse_offering as parse_offering_strict,
40
+ )
41
+ from offering_protocol.core import (
42
+ parse_offering_page as parse_offering_page_strict,
43
+ )
44
+ from offering_protocol.core import (
45
+ parse_problem_response as parse_problem_response_strict,
46
+ )
47
+ from offering_protocol.core.validation import _normalize_agent_response
36
48
  from offering_protocol.directory.transport import (
37
49
  HttpRequest,
38
50
  HttpResponse,
@@ -136,10 +148,10 @@ class ServiceClient:
136
148
  b"",
137
149
  _MAXIMUM_DOCUMENT_BYTES,
138
150
  self._cache_fallbacks.service_document,
139
- parse_service_document,
151
+ parse_agent_service_document,
140
152
  )
141
153
  return Inspection(
142
- document=parse_service_document(response.body),
154
+ document=parse_agent_service_document(response.body),
143
155
  final_url=response.final_url,
144
156
  freshness=response.freshness,
145
157
  requested_url=requested_url,
@@ -571,6 +583,33 @@ def _operation_parser(operation: Operation) -> object:
571
583
  return parse_offering_page
572
584
 
573
585
 
586
+ def parse_collection(data: bytes | str) -> Collection:
587
+ return parse_collection_strict(_normalize_body(data, "collection"))
588
+
589
+
590
+ def parse_offering(data: bytes | str) -> Offering:
591
+ return parse_offering_strict(_normalize_body(data, "offering"))
592
+
593
+
594
+ def parse_collection_page(data: bytes | str) -> Page[Collection]:
595
+ return parse_collection_page_strict(_normalize_body(data, "collection-page"))
596
+
597
+
598
+ def parse_offering_page(data: bytes | str) -> OfferingPage[Offering]:
599
+ return parse_offering_page_strict(_normalize_body(data, "offering-page"))
600
+
601
+
602
+ def parse_problem_response(data: bytes | str, status: int) -> ProblemDetails:
603
+ return parse_problem_response_strict(_normalize_body(data, "problem"), status)
604
+
605
+
606
+ def _normalize_body(data: bytes | str, kind: str) -> str:
607
+ raw = json.loads(data)
608
+ if not isinstance(raw, dict):
609
+ return data.decode() if isinstance(data, bytes) else data
610
+ return json.dumps(_normalize_agent_response(raw, kind), separators=(",", ":"))
611
+
612
+
574
613
  def _encode(value: object) -> bytes:
575
614
  if not hasattr(value, "model_dump_json"):
576
615
  raise TypeError("ODP model is not serializable")
@@ -53,7 +53,7 @@ class DiscoveredAction:
53
53
  http: DiscoveredHttpAction | None
54
54
  id: str
55
55
  openapi: DiscoveredOpenApiAction | None
56
- rel: ActionRelation
56
+ rel: ActionRelation | str
57
57
 
58
58
 
59
59
  @dataclass(frozen=True, slots=True)
@@ -16,6 +16,7 @@ from offering_protocol.core.references import (
16
16
  from offering_protocol.core.validation import (
17
17
  OdpValidationError,
18
18
  ValidationIssue,
19
+ parse_agent_service_document,
19
20
  parse_collection,
20
21
  parse_collection_page,
21
22
  parse_collection_search_request,
@@ -44,6 +45,7 @@ __all__ = [
44
45
  "is_local_resource_identifier",
45
46
  "operation_method",
46
47
  "operation_path",
48
+ "parse_agent_service_document",
47
49
  "parse_collection",
48
50
  "parse_collection_page",
49
51
  "parse_collection_search_request",
@@ -379,7 +379,7 @@ class Action(OdpModel):
379
379
  http: HttpActionTarget | None = None
380
380
  id: str
381
381
  openapi: OpenApiActionTarget | None = None
382
- rel: ActionRelation
382
+ rel: ActionRelation | str
383
383
 
384
384
 
385
385
  class Offering(OdpModel):
@@ -82,6 +82,363 @@ def parse_service_document(data: bytes | str) -> ServiceDocument:
82
82
  return value
83
83
 
84
84
 
85
+ def parse_agent_service_document(data: bytes | str) -> ServiceDocument:
86
+ try:
87
+ raw = json.loads(data)
88
+ except (UnicodeDecodeError, json.JSONDecodeError):
89
+ return parse_service_document(data)
90
+ if isinstance(raw, dict):
91
+ raw = _normalize_agent_response(raw, "service-document")
92
+ return parse_service_document(json.dumps(raw, separators=(",", ":")))
93
+
94
+
95
+ def _normalize_agent_response(value: dict[str, Any], kind: str) -> dict[str, Any]:
96
+ normalized = dict(value)
97
+ if kind == "service-document":
98
+ _filter_agent_protocols(normalized)
99
+ protocols = normalized.get("protocols")
100
+ if isinstance(protocols, dict):
101
+ _filter_unknown_authentication(protocols, "payments")
102
+ _filter_named_list(
103
+ normalized,
104
+ "operations",
105
+ {
106
+ "get-collection",
107
+ "get-offering",
108
+ "list-collection-offerings",
109
+ "list-collections",
110
+ "list-offerings",
111
+ "search-collections",
112
+ "search-offerings",
113
+ },
114
+ )
115
+ _filter_unknown_authentication(normalized, "operations")
116
+ _filter_typed_list(normalized, "mcp", {"streamable-http"})
117
+ _filter_closed_object_list(normalized, "operations", {"authentication", "name"})
118
+ _filter_closed_object_list(normalized, "mcp", {"description", "name", "type", "url"})
119
+ _filter_payment_options(normalized)
120
+ _normalize_branding(normalized)
121
+ _normalize_search_capabilities(normalized)
122
+ elif kind in {"collection", "offering"}:
123
+ _filter_typed_list(
124
+ normalized,
125
+ "images",
126
+ {"image/avif", "image/jpeg", "image/png", "image/svg+xml", "image/webp"},
127
+ )
128
+ _strip_object_list(normalized, "images", {"alt", "height", "src", "type", "width"})
129
+ _normalize_search_capabilities(normalized)
130
+ if kind == "offering":
131
+ _normalize_offering(normalized)
132
+ elif kind in {"collection-page", "offering-page"} and isinstance(normalized.get("items"), list):
133
+ item_kind = "offering" if kind == "offering-page" else "collection"
134
+ normalized["items"] = [
135
+ _normalize_agent_response(item, item_kind) if isinstance(item, dict) else item
136
+ for item in normalized["items"]
137
+ ]
138
+ elif kind in {"filter-page", "sort-page"} and isinstance(normalized.get("items"), list):
139
+ predicate = _known_filter if kind == "filter-page" else _known_sort
140
+ normalized["items"] = [item for item in normalized["items"] if predicate(item)]
141
+ elif kind == "problem" and isinstance(normalized.get("invalid_params"), list):
142
+ normalized["invalid_params"] = [
143
+ item
144
+ for item in normalized["invalid_params"]
145
+ if not (
146
+ isinstance(item, dict)
147
+ and isinstance(item.get("in"), str)
148
+ and item["in"] not in {"body", "header", "path", "query"}
149
+ )
150
+ ]
151
+ return normalized
152
+
153
+
154
+ def _filter_named_list(value: dict[str, Any], member: str, recognized: set[str]) -> None:
155
+ items = value.get(member)
156
+ if not isinstance(items, list):
157
+ return
158
+ filtered = [
159
+ item
160
+ for item in items
161
+ if not (
162
+ isinstance(item, dict)
163
+ and isinstance(item.get("name"), str)
164
+ and item["name"] not in recognized
165
+ )
166
+ ]
167
+ if filtered:
168
+ value[member] = filtered
169
+ else:
170
+ value.pop(member, None)
171
+
172
+
173
+ def _filter_typed_list(value: dict[str, Any], member: str, recognized: set[str]) -> None:
174
+ items = value.get(member)
175
+ if not isinstance(items, list):
176
+ return
177
+ filtered = [
178
+ item
179
+ for item in items
180
+ if not (
181
+ isinstance(item, dict)
182
+ and isinstance(item.get("type"), str)
183
+ and item["type"] not in recognized
184
+ )
185
+ ]
186
+ if filtered:
187
+ value[member] = filtered
188
+ else:
189
+ value.pop(member, None)
190
+
191
+
192
+ def _filter_closed_object_list(value: dict[str, Any], member: str, allowed: set[str]) -> None:
193
+ items = value.get(member)
194
+ if not isinstance(items, list):
195
+ return
196
+ filtered = [
197
+ item for item in items if not isinstance(item, dict) or not set(item).difference(allowed)
198
+ ]
199
+ if filtered:
200
+ value[member] = filtered
201
+ else:
202
+ value.pop(member, None)
203
+
204
+
205
+ def _filter_unknown_authentication(value: dict[str, Any], member: str) -> None:
206
+ items = value.get(member)
207
+ if not isinstance(items, list):
208
+ return
209
+ filtered = [
210
+ item
211
+ for item in items
212
+ if not isinstance(item, dict) or not _has_unknown_authentication(item)
213
+ ]
214
+ if filtered:
215
+ value[member] = filtered
216
+ else:
217
+ value.pop(member, None)
218
+
219
+
220
+ def _has_unknown_authentication(value: dict[str, Any]) -> bool:
221
+ authentication = value.get("authentication")
222
+ return isinstance(authentication, str) and authentication not in {
223
+ "not-required",
224
+ "optional",
225
+ "required",
226
+ }
227
+
228
+
229
+ def _strip_object_list(value: dict[str, Any], member: str, allowed: set[str]) -> None:
230
+ items = value.get(member)
231
+ if not isinstance(items, list):
232
+ return
233
+ value[member] = [
234
+ {key: entry for key, entry in item.items() if key in allowed}
235
+ if isinstance(item, dict)
236
+ else item
237
+ for item in items
238
+ ]
239
+
240
+
241
+ def _filter_payment_options(value: dict[str, Any]) -> None:
242
+ protocols = value.get("protocols")
243
+ if not isinstance(protocols, dict) or not isinstance(protocols.get("payments"), list):
244
+ return
245
+ recognized = {
246
+ "algorand",
247
+ "aptos",
248
+ "arbitrum",
249
+ "avalanche",
250
+ "base",
251
+ "card",
252
+ "ethereum",
253
+ "hedera",
254
+ "inflow",
255
+ "lightning",
256
+ "polygon",
257
+ "solana",
258
+ "stellar",
259
+ "stripe",
260
+ "tempo",
261
+ "ton",
262
+ }
263
+ for payment in protocols["payments"]:
264
+ if not isinstance(payment, dict) or not isinstance(payment.get("options"), list):
265
+ continue
266
+ options = [
267
+ option
268
+ for option in payment["options"]
269
+ if not isinstance(option, str) or option in recognized
270
+ ]
271
+ if options:
272
+ payment["options"] = options
273
+ else:
274
+ payment.pop("options", None)
275
+
276
+
277
+ def _normalize_branding(value: dict[str, Any]) -> None:
278
+ branding = value.get("branding")
279
+ if not isinstance(branding, dict):
280
+ return
281
+ normalized = {key: entry for key, entry in branding.items() if key in {"icon", "logo"}}
282
+ for member in ("icon", "logo"):
283
+ image = normalized.get(member)
284
+ if (
285
+ isinstance(image, dict)
286
+ and isinstance(image.get("type"), str)
287
+ and image["type"] not in {"image/png", "image/svg+xml", "image/webp"}
288
+ ):
289
+ normalized.pop(member, None)
290
+ elif isinstance(image, dict):
291
+ normalized[member] = {
292
+ key: entry for key, entry in image.items() if key in {"src", "type"}
293
+ }
294
+ if normalized:
295
+ value["branding"] = normalized
296
+ else:
297
+ value.pop("branding", None)
298
+
299
+
300
+ def _normalize_search_capabilities(value: dict[str, Any]) -> None:
301
+ capabilities = value.get("search_capabilities")
302
+ if not isinstance(capabilities, dict):
303
+ return
304
+ normalized = dict(capabilities)
305
+ for member, predicate in (("filters", _known_filter), ("sorts", _known_sort)):
306
+ source = normalized.get(member)
307
+ if not isinstance(source, dict) or not isinstance(source.get("inline"), list):
308
+ continue
309
+ items = [item for item in source["inline"] if predicate(item)]
310
+ if items:
311
+ normalized[member] = {**source, "inline": items}
312
+ else:
313
+ normalized.pop(member, None)
314
+ if normalized:
315
+ value["search_capabilities"] = normalized
316
+ else:
317
+ value.pop("search_capabilities", None)
318
+
319
+
320
+ def _normalize_offering(value: dict[str, Any]) -> None:
321
+ schema = value.get("schema")
322
+ if isinstance(schema, dict) and set(schema).difference({"url"}):
323
+ value.pop("schema", None)
324
+ price = value.get("price")
325
+ if (
326
+ isinstance(price, dict)
327
+ and isinstance(price.get("type"), str)
328
+ and price["type"] not in {"fixed", "free", "metered", "quote", "range", "starting_at"}
329
+ ):
330
+ value.pop("price", None)
331
+ actions = value.get("actions")
332
+ if not isinstance(actions, list):
333
+ return
334
+ filtered = []
335
+ for action in actions:
336
+ if isinstance(action, dict) and _has_unknown_authentication(action):
337
+ continue
338
+ if isinstance(action, dict) and set(action).difference(
339
+ {"authentication", "description", "http", "id", "openapi", "rel"}
340
+ ):
341
+ continue
342
+ http = action.get("http") if isinstance(action, dict) else None
343
+ if isinstance(http, dict) and set(http).difference(
344
+ {"href", "method", "request", "response_content_types"}
345
+ ):
346
+ continue
347
+ request = http.get("request") if isinstance(http, dict) else None
348
+ if isinstance(request, dict) and set(request).difference({"content_type", "schema"}):
349
+ continue
350
+ schema = request.get("schema") if isinstance(request, dict) else None
351
+ if isinstance(schema, dict) and set(schema).difference({"url"}):
352
+ continue
353
+ openapi = action.get("openapi") if isinstance(action, dict) else None
354
+ if isinstance(openapi, dict) and set(openapi).difference({"operation_id", "url"}):
355
+ continue
356
+ method = http.get("method") if isinstance(http, dict) else None
357
+ if isinstance(method, str) and method not in {"GET", "POST"}:
358
+ continue
359
+ filtered.append(action)
360
+ if filtered:
361
+ value["actions"] = filtered
362
+ else:
363
+ value.pop("actions", None)
364
+
365
+
366
+ def _known_filter(value: Any) -> bool:
367
+ if not isinstance(value, dict):
368
+ return True
369
+ if isinstance(value.get("type"), str) and value["type"] not in {
370
+ "boolean",
371
+ "date",
372
+ "date-time",
373
+ "decimal",
374
+ "integer",
375
+ "number",
376
+ "string",
377
+ }:
378
+ return False
379
+ operators = value.get("operators")
380
+ if isinstance(operators, list) and any(
381
+ isinstance(operator, str)
382
+ and operator not in {"eq", "exists", "gt", "gte", "in", "lt", "lte"}
383
+ for operator in operators
384
+ ):
385
+ return False
386
+ unit = value.get("unit")
387
+ return not (
388
+ isinstance(unit, dict)
389
+ and isinstance(unit.get("system"), str)
390
+ and unit["system"] not in {"service", "ucum"}
391
+ )
392
+
393
+
394
+ def _known_sort(value: Any) -> bool:
395
+ if not isinstance(value, dict) or not isinstance(value.get("keys"), list):
396
+ return True
397
+ return not any(
398
+ isinstance(key, dict)
399
+ and (
400
+ (
401
+ isinstance(key.get("direction"), str)
402
+ and key["direction"] not in {"ascending", "descending"}
403
+ )
404
+ or (isinstance(key.get("missing"), str) and key["missing"] not in {"first", "last"})
405
+ )
406
+ for key in value["keys"]
407
+ )
408
+
409
+
410
+ def _filter_agent_protocols(document: dict[str, Any]) -> None:
411
+ protocols = document.get("protocols")
412
+ if not isinstance(protocols, dict):
413
+ return
414
+ _filter_agent_protocol_category(protocols, "enrollment", {"aep"})
415
+ _filter_agent_protocol_category(protocols, "payments", {"mpp", "x402"})
416
+ _filter_agent_protocol_category(protocols, "trust", {"tap"})
417
+ if not protocols:
418
+ document.pop("protocols")
419
+
420
+
421
+ def _filter_agent_protocol_category(
422
+ protocols: dict[str, Any], category: str, recognized: set[str]
423
+ ) -> None:
424
+ descriptors = protocols.get(category)
425
+ if not isinstance(descriptors, list):
426
+ return
427
+ filtered = [
428
+ descriptor
429
+ for descriptor in descriptors
430
+ if not (
431
+ isinstance(descriptor, dict)
432
+ and isinstance(descriptor.get("name"), str)
433
+ and descriptor["name"] not in recognized
434
+ )
435
+ ]
436
+ if descriptors and not filtered:
437
+ protocols.pop(category)
438
+ else:
439
+ protocols[category] = filtered
440
+
441
+
85
442
  def parse_collection(data: bytes | str) -> Collection:
86
443
  value = _parse(data, "collection.schema.json", "Collection", Collection)
87
444
  _validate_representation(
@@ -99,7 +456,14 @@ def parse_offering(data: bytes | str) -> Offering:
99
456
 
100
457
 
101
458
  def parse_problem_details(data: bytes | str) -> ProblemDetails:
102
- return _parse(data, "problem-details.schema.json", "Problem Details", ProblemDetails)
459
+ value = _parse(data, "problem-details.schema.json", "Problem Details", ProblemDetails)
460
+ expected_type = "https://offeringprotocol.org/problems/" + value.code.lower().replace("_", "-")
461
+ if value.problem_type != expected_type:
462
+ raise OdpValidationError(
463
+ "Problem Details",
464
+ [_issue("/type", "problem-type", "must correspond to the problem code")],
465
+ )
466
+ return value
103
467
 
104
468
 
105
469
  def parse_problem_response(data: bytes | str, http_status: int) -> ProblemDetails:
@@ -7,7 +7,11 @@ from urllib.parse import urlencode, urljoin
7
7
 
8
8
  from pydantic import ValidationError as ModelValidationError
9
9
 
10
- from offering_protocol.core import derive_service_origin
10
+ from offering_protocol.core import (
11
+ OdpValidationError,
12
+ derive_service_origin,
13
+ parse_agent_service_document,
14
+ )
11
15
  from offering_protocol.directory.models import (
12
16
  DirectoryService,
13
17
  Environment,
@@ -159,17 +163,52 @@ class DirectoryClient:
159
163
 
160
164
  def _parse_search_page(body: bytes) -> SearchPage:
161
165
  try:
162
- page = SearchPage.model_validate_json(body)
163
- except ModelValidationError as error:
166
+ raw = json.loads(body)
167
+ if isinstance(raw, dict) and isinstance(raw.get("items"), list):
168
+ for item in raw["items"]:
169
+ if isinstance(item, dict) and "protocols" in item:
170
+ _normalize_service_protocols(item)
171
+ page = SearchPage.model_validate(raw)
172
+ except (
173
+ UnicodeDecodeError,
174
+ json.JSONDecodeError,
175
+ ModelValidationError,
176
+ OdpValidationError,
177
+ ) as error:
164
178
  raise DirectoryError(f"invalid Directory response: {error}") from error
165
179
  if len(page.items) > 100:
166
180
  raise DirectoryError("Directory search page exceeds 100 Services")
181
+ if page.facets is not None and any(
182
+ facet.value.name.value != "tap" for facet in page.facets.trust
183
+ ):
184
+ raise DirectoryError("Directory trust facets are invalid")
167
185
  for service in page.items:
168
186
  if derive_service_origin(service.service_origin) != service.service_origin:
169
187
  raise DirectoryError("Directory Service origin is not canonical")
170
188
  return page
171
189
 
172
190
 
191
+ def _normalize_service_protocols(item: dict[str, object]) -> None:
192
+ candidate = {
193
+ "description": "Directory protocol validation",
194
+ "http": {"endpoint_base": "/"},
195
+ "language": "en",
196
+ "localizations": ["en"],
197
+ "name": "Directory Service",
198
+ "odp_version": "1.0",
199
+ "operations": [
200
+ {"authentication": "not-required", "name": "get-offering"},
201
+ {"authentication": "not-required", "name": "list-offerings"},
202
+ ],
203
+ "protocols": item["protocols"],
204
+ }
205
+ document = parse_agent_service_document(json.dumps(candidate, separators=(",", ":")))
206
+ if document.protocols is None:
207
+ item.pop("protocols")
208
+ else:
209
+ item["protocols"] = document.protocols.model_dump(mode="json", exclude_defaults=True)
210
+
211
+
173
212
  def _validate_search_request(request: SearchRequest) -> None:
174
213
  if request.limit > 100:
175
214
  raise DirectoryError("limit must be from 1 through 100")
@@ -182,6 +221,12 @@ def _validate_search_request(request: SearchRequest) -> None:
182
221
  or any(not keyword or len(keyword) > 64 for keyword in request.filters.keywords)
183
222
  ):
184
223
  raise DirectoryError("keywords must contain at most 32 values of at most 64 characters")
224
+ if (
225
+ request.filters is not None
226
+ and "trust" in request.filters.model_fields_set
227
+ and (len(request.filters.trust) != 1 or request.filters.trust[0].name.value != "tap")
228
+ ):
229
+ raise DirectoryError("trust must contain exactly one tap descriptor")
185
230
 
186
231
 
187
232
  def _consume_response(response: HttpResponse) -> HttpResponse:
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from enum import StrEnum
6
+ from typing import Generic, TypeVar
6
7
 
7
8
  from pydantic import Field
8
9
 
@@ -13,10 +14,14 @@ from offering_protocol.core.models import (
13
14
  Operation,
14
15
  OperationDescriptor,
15
16
  PaymentOption,
17
+ PaymentProtocol,
16
18
  Protocol,
17
19
  ServiceProtocols,
20
+ TrustProtocol,
18
21
  )
19
22
 
23
+ FacetValue = TypeVar("FacetValue")
24
+
20
25
 
21
26
  class Environment(StrEnum):
22
27
  PRODUCTION = "production"
@@ -45,6 +50,7 @@ class ServiceFilters(OdpModel):
45
50
  keywords: list[str] = Field(default_factory=list)
46
51
  operations: list[OperationFilter] = Field(default_factory=list)
47
52
  payments: list[PaymentFilter] = Field(default_factory=list)
53
+ trust: list[TrustProtocol] = Field(default_factory=list)
48
54
 
49
55
 
50
56
  class SearchRequest(OdpModel):
@@ -74,17 +80,18 @@ class PaymentOptionFacetValue(OdpModel):
74
80
  option: PaymentOption
75
81
 
76
82
 
77
- class Facet(OdpModel):
83
+ class Facet(OdpModel, Generic[FacetValue]):
78
84
  count: int
79
- value: object
85
+ value: FacetValue
80
86
 
81
87
 
82
88
  class Facets(OdpModel):
83
- enrollment: list[Facet] = Field(default_factory=list)
84
- keywords: list[Facet] = Field(default_factory=list)
85
- operations: list[Facet] = Field(default_factory=list)
86
- payment_options: list[Facet] = Field(default_factory=list)
87
- payments: list[Facet] = Field(default_factory=list)
89
+ enrollment: list[Facet[EnrollmentProtocol]] = Field(default_factory=list)
90
+ keywords: list[Facet[str]] = Field(default_factory=list)
91
+ operations: list[Facet[OperationDescriptor]] = Field(default_factory=list)
92
+ payment_options: list[Facet[PaymentOptionFacetValue]] = Field(default_factory=list)
93
+ payments: list[Facet[PaymentProtocol]] = Field(default_factory=list)
94
+ trust: list[Facet[TrustProtocol]] = Field(default_factory=list)
88
95
 
89
96
 
90
97
  class SearchPage(OdpModel):
@@ -98,21 +98,29 @@ class Catalog(Protocol):
98
98
 
99
99
  async def search_offerings(
100
100
  self, query: OfferingSearchRequest, request: CatalogRequest
101
- ) -> OfferingPage[Offering]: ...
101
+ ) -> OfferingPage[Offering]:
102
+ del query, request
103
+ raise CatalogError("Offering search is not supported")
102
104
 
103
- async def list_collections(self, request: CatalogRequest) -> Page[Collection]: ...
105
+ async def list_collections(self, request: CatalogRequest) -> Page[Collection]:
106
+ del request
107
+ raise CatalogError("Collection listing is not supported")
104
108
 
105
- async def get_collection(
106
- self, identifier: str, request: CatalogRequest
107
- ) -> Collection | None: ...
109
+ async def get_collection(self, identifier: str, request: CatalogRequest) -> Collection | None:
110
+ del identifier, request
111
+ raise CatalogError("Collection retrieval is not supported")
108
112
 
109
113
  async def search_collections(
110
114
  self, query: CollectionSearchRequest, request: CatalogRequest
111
- ) -> Page[Collection]: ...
115
+ ) -> Page[Collection]:
116
+ del query, request
117
+ raise CatalogError("Collection search is not supported")
112
118
 
113
119
  async def list_collection_offerings(
114
120
  self, collection_id: str, request: CatalogRequest
115
- ) -> OfferingPage[Offering]: ...
121
+ ) -> OfferingPage[Offering]:
122
+ del collection_id, request
123
+ raise CatalogError("Collection Offering listing is not supported")
116
124
 
117
125
 
118
126
  class ServiceBuilder: