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,667 @@
|
|
|
1
|
+
"""Agent-side ODP Service client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass, replace
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
from email.utils import parsedate_to_datetime
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from typing import TYPE_CHECKING, cast
|
|
13
|
+
from urllib.parse import parse_qsl, urlencode, urljoin, urlsplit, urlunsplit
|
|
14
|
+
|
|
15
|
+
from offering_protocol.agent.cache import Cache, CacheFallbacks, CacheRecord, MemoryCache, utc_now
|
|
16
|
+
from offering_protocol.core import (
|
|
17
|
+
Collection,
|
|
18
|
+
CollectionSearchRequest,
|
|
19
|
+
Offering,
|
|
20
|
+
OfferingPage,
|
|
21
|
+
OfferingSearchRequest,
|
|
22
|
+
Operation,
|
|
23
|
+
Page,
|
|
24
|
+
Representation,
|
|
25
|
+
ServiceDocument,
|
|
26
|
+
build_operation_url,
|
|
27
|
+
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,
|
|
34
|
+
resolve_continuation,
|
|
35
|
+
)
|
|
36
|
+
from offering_protocol.directory.transport import (
|
|
37
|
+
HttpRequest,
|
|
38
|
+
HttpResponse,
|
|
39
|
+
HttpxTransport,
|
|
40
|
+
Transport,
|
|
41
|
+
TransportError,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from offering_protocol.agent.capabilities import SearchCapabilityCatalog
|
|
46
|
+
from offering_protocol.agent.details import OfferingDetails, ResolvedAction
|
|
47
|
+
|
|
48
|
+
MEDIA_TYPE = "application/odp+json"
|
|
49
|
+
_MAXIMUM_DOCUMENT_BYTES = 65_536
|
|
50
|
+
_MAXIMUM_RESOURCE_BYTES = 524_288
|
|
51
|
+
_MAXIMUM_REDIRECTS = 5
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Freshness(StrEnum):
|
|
55
|
+
FETCHED = "fetched"
|
|
56
|
+
FRESH = "fresh"
|
|
57
|
+
REVALIDATED = "revalidated"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class Inspection:
|
|
62
|
+
document: ServiceDocument
|
|
63
|
+
final_url: str
|
|
64
|
+
freshness: Freshness
|
|
65
|
+
requested_url: str
|
|
66
|
+
service_origin: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class TraversalOptions:
|
|
71
|
+
max_items: int = 10_000
|
|
72
|
+
max_pages: int = 16
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AgentError(RuntimeError):
|
|
76
|
+
"""Base error for Service discovery operations."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class UnsupportedOperationError(AgentError):
|
|
80
|
+
def __init__(self, operation: Operation) -> None:
|
|
81
|
+
super().__init__(f"ODP Service does not advertise {operation.value}")
|
|
82
|
+
self.operation = operation
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ServiceRequestError(AgentError):
|
|
86
|
+
def __init__(self, status: int, message: str, headers: dict[str, str]) -> None:
|
|
87
|
+
super().__init__(f"ODP request failed with HTTP {status}: {message}")
|
|
88
|
+
self.status = status
|
|
89
|
+
self.headers = headers
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class _FetchedResponse:
|
|
94
|
+
body: bytes
|
|
95
|
+
final_url: str
|
|
96
|
+
freshness: Freshness
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ServiceClient:
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
service_url: str,
|
|
103
|
+
*,
|
|
104
|
+
accept_language: str | None = None,
|
|
105
|
+
allow_local_network: bool = False,
|
|
106
|
+
cache: Cache | None = None,
|
|
107
|
+
cache_fallbacks: CacheFallbacks | None = None,
|
|
108
|
+
cache_partition: str = "anonymous",
|
|
109
|
+
supporting_transport: Transport | None = None,
|
|
110
|
+
transport: Transport | None = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
self.service_origin = derive_service_origin(service_url)
|
|
113
|
+
self._accept_language = accept_language
|
|
114
|
+
self._cache = cache or MemoryCache()
|
|
115
|
+
self._cache_fallbacks = cache_fallbacks or CacheFallbacks()
|
|
116
|
+
self._cache_partition = cache_partition
|
|
117
|
+
self._owns_transport = transport is None
|
|
118
|
+
self._transport = transport or HttpxTransport(allow_local_network=allow_local_network)
|
|
119
|
+
self._supporting_transport = supporting_transport or self._transport
|
|
120
|
+
|
|
121
|
+
async def __aenter__(self) -> ServiceClient:
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
async def __aexit__(self, *args: object) -> None:
|
|
125
|
+
await self.aclose()
|
|
126
|
+
|
|
127
|
+
async def aclose(self) -> None:
|
|
128
|
+
if self._owns_transport:
|
|
129
|
+
await self._transport.aclose()
|
|
130
|
+
|
|
131
|
+
async def inspect(self) -> Inspection:
|
|
132
|
+
requested_url = f"{self.service_origin}/.well-known/odp"
|
|
133
|
+
response = await self._request_cached(
|
|
134
|
+
"GET",
|
|
135
|
+
requested_url,
|
|
136
|
+
b"",
|
|
137
|
+
_MAXIMUM_DOCUMENT_BYTES,
|
|
138
|
+
self._cache_fallbacks.service_document,
|
|
139
|
+
parse_service_document,
|
|
140
|
+
)
|
|
141
|
+
return Inspection(
|
|
142
|
+
document=parse_service_document(response.body),
|
|
143
|
+
final_url=response.final_url,
|
|
144
|
+
freshness=response.freshness,
|
|
145
|
+
requested_url=requested_url,
|
|
146
|
+
service_origin=self.service_origin,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
async def get_offering_details(self, identifier: str) -> OfferingDetails:
|
|
150
|
+
from offering_protocol.agent.details import get_offering_details
|
|
151
|
+
|
|
152
|
+
return await get_offering_details(self, identifier)
|
|
153
|
+
|
|
154
|
+
async def resolve_action(self, offering_id: str, action_id: str) -> ResolvedAction:
|
|
155
|
+
from offering_protocol.agent.details import resolve_action
|
|
156
|
+
|
|
157
|
+
return await resolve_action(self, offering_id, action_id)
|
|
158
|
+
|
|
159
|
+
async def get_collection_search_capabilities(self, identifier: str) -> SearchCapabilityCatalog:
|
|
160
|
+
from offering_protocol.agent.capabilities import get_collection_search_capabilities
|
|
161
|
+
|
|
162
|
+
return await get_collection_search_capabilities(self, identifier)
|
|
163
|
+
|
|
164
|
+
async def get_offering_search_capabilities(
|
|
165
|
+
self, collection_id: str | None = None
|
|
166
|
+
) -> SearchCapabilityCatalog:
|
|
167
|
+
from offering_protocol.agent.capabilities import get_offering_search_capabilities
|
|
168
|
+
|
|
169
|
+
return await get_offering_search_capabilities(self, collection_id)
|
|
170
|
+
|
|
171
|
+
async def list_collections(
|
|
172
|
+
self, representation: Representation = Representation.TERSE, limit: int = 0
|
|
173
|
+
) -> Page[Collection]:
|
|
174
|
+
body = await self._get_page(Operation.LIST_COLLECTIONS, None, representation, limit)
|
|
175
|
+
page = parse_collection_page(body)
|
|
176
|
+
for item in page.items:
|
|
177
|
+
parse_collection(_encode(item))
|
|
178
|
+
return page
|
|
179
|
+
|
|
180
|
+
async def get_collection(self, identifier: str) -> Collection:
|
|
181
|
+
body = await self._get_page(Operation.GET_COLLECTION, identifier, Representation.FULL, 0)
|
|
182
|
+
return parse_collection(body)
|
|
183
|
+
|
|
184
|
+
async def search_collections(
|
|
185
|
+
self,
|
|
186
|
+
request: CollectionSearchRequest,
|
|
187
|
+
representation: Representation = Representation.TERSE,
|
|
188
|
+
) -> Page[Collection]:
|
|
189
|
+
body = await self._post_search(
|
|
190
|
+
Operation.SEARCH_COLLECTIONS, request.to_dict(), representation
|
|
191
|
+
)
|
|
192
|
+
page = parse_collection_page(body)
|
|
193
|
+
for item in page.items:
|
|
194
|
+
parse_collection(_encode(item))
|
|
195
|
+
return page
|
|
196
|
+
|
|
197
|
+
async def list_offerings(
|
|
198
|
+
self, representation: Representation = Representation.TERSE, limit: int = 0
|
|
199
|
+
) -> OfferingPage[Offering]:
|
|
200
|
+
body = await self._get_page(Operation.LIST_OFFERINGS, None, representation, limit)
|
|
201
|
+
return parse_offering_page(body)
|
|
202
|
+
|
|
203
|
+
async def list_collection_offerings(
|
|
204
|
+
self,
|
|
205
|
+
collection_id: str,
|
|
206
|
+
representation: Representation = Representation.TERSE,
|
|
207
|
+
limit: int = 0,
|
|
208
|
+
) -> OfferingPage[Offering]:
|
|
209
|
+
body = await self._get_page(
|
|
210
|
+
Operation.LIST_COLLECTION_OFFERINGS, collection_id, representation, limit
|
|
211
|
+
)
|
|
212
|
+
return parse_offering_page(body)
|
|
213
|
+
|
|
214
|
+
async def get_offering(self, identifier: str) -> Offering:
|
|
215
|
+
body = await self._get_page(Operation.GET_OFFERING, identifier, Representation.FULL, 0)
|
|
216
|
+
return parse_offering(body)
|
|
217
|
+
|
|
218
|
+
async def search_offerings(
|
|
219
|
+
self,
|
|
220
|
+
request: OfferingSearchRequest,
|
|
221
|
+
representation: Representation = Representation.TERSE,
|
|
222
|
+
) -> OfferingPage[Offering]:
|
|
223
|
+
body = await self._post_search(
|
|
224
|
+
Operation.SEARCH_OFFERINGS, request.to_dict(), representation
|
|
225
|
+
)
|
|
226
|
+
return parse_offering_page(body)
|
|
227
|
+
|
|
228
|
+
async def continue_collections(self, next_reference: str) -> Page[Collection]:
|
|
229
|
+
target = resolve_continuation(next_reference, self.service_origin)
|
|
230
|
+
response = await self._request_cached(
|
|
231
|
+
"GET",
|
|
232
|
+
target,
|
|
233
|
+
b"",
|
|
234
|
+
_MAXIMUM_RESOURCE_BYTES,
|
|
235
|
+
self._cache_fallbacks.collection,
|
|
236
|
+
parse_collection_page,
|
|
237
|
+
)
|
|
238
|
+
return parse_collection_page(response.body)
|
|
239
|
+
|
|
240
|
+
async def continue_offerings(self, next_reference: str) -> OfferingPage[Offering]:
|
|
241
|
+
target = resolve_continuation(next_reference, self.service_origin)
|
|
242
|
+
response = await self._request_cached(
|
|
243
|
+
"GET",
|
|
244
|
+
target,
|
|
245
|
+
b"",
|
|
246
|
+
_MAXIMUM_RESOURCE_BYTES,
|
|
247
|
+
self._cache_fallbacks.offering,
|
|
248
|
+
parse_offering_page,
|
|
249
|
+
)
|
|
250
|
+
return parse_offering_page(response.body)
|
|
251
|
+
|
|
252
|
+
async def list_all_collections(
|
|
253
|
+
self,
|
|
254
|
+
representation: Representation = Representation.TERSE,
|
|
255
|
+
limit: int = 0,
|
|
256
|
+
options: TraversalOptions | None = None,
|
|
257
|
+
) -> list[Collection]:
|
|
258
|
+
maximum_items, maximum_pages = _traversal_bounds(options or TraversalOptions())
|
|
259
|
+
page = await self.list_collections(representation, limit)
|
|
260
|
+
result: list[Collection] = []
|
|
261
|
+
for page_number in range(maximum_pages):
|
|
262
|
+
result.extend(page.items[: maximum_items - len(result)])
|
|
263
|
+
if len(result) == maximum_items or not page.next:
|
|
264
|
+
break
|
|
265
|
+
if page_number + 1 < maximum_pages:
|
|
266
|
+
page = await self.continue_collections(page.next)
|
|
267
|
+
return result
|
|
268
|
+
|
|
269
|
+
async def list_all_offerings(
|
|
270
|
+
self,
|
|
271
|
+
representation: Representation = Representation.TERSE,
|
|
272
|
+
limit: int = 0,
|
|
273
|
+
options: TraversalOptions | None = None,
|
|
274
|
+
) -> list[Offering]:
|
|
275
|
+
resolved_options = options or TraversalOptions()
|
|
276
|
+
_traversal_bounds(resolved_options)
|
|
277
|
+
page = await self.list_offerings(representation, limit)
|
|
278
|
+
return await self._collect_offerings(page, resolved_options)
|
|
279
|
+
|
|
280
|
+
async def search_all_offerings(
|
|
281
|
+
self,
|
|
282
|
+
request: OfferingSearchRequest,
|
|
283
|
+
representation: Representation = Representation.TERSE,
|
|
284
|
+
options: TraversalOptions | None = None,
|
|
285
|
+
) -> list[Offering]:
|
|
286
|
+
resolved_options = options or TraversalOptions()
|
|
287
|
+
_traversal_bounds(resolved_options)
|
|
288
|
+
page = await self.search_offerings(request, representation)
|
|
289
|
+
return await self._collect_offerings(page, resolved_options)
|
|
290
|
+
|
|
291
|
+
async def _collect_offerings(
|
|
292
|
+
self, page: OfferingPage[Offering], options: TraversalOptions
|
|
293
|
+
) -> list[Offering]:
|
|
294
|
+
result: list[Offering] = []
|
|
295
|
+
maximum_items, maximum_pages = _traversal_bounds(options)
|
|
296
|
+
for page_number in range(maximum_pages):
|
|
297
|
+
result.extend(page.items[: maximum_items - len(result)])
|
|
298
|
+
if len(result) == maximum_items or not page.next:
|
|
299
|
+
break
|
|
300
|
+
if page_number + 1 < maximum_pages:
|
|
301
|
+
page = await self.continue_offerings(page.next)
|
|
302
|
+
return result
|
|
303
|
+
|
|
304
|
+
async def _get_page(
|
|
305
|
+
self,
|
|
306
|
+
operation: Operation,
|
|
307
|
+
identifier: str | None,
|
|
308
|
+
representation: Representation,
|
|
309
|
+
limit: int,
|
|
310
|
+
) -> bytes:
|
|
311
|
+
inspection = await self._require_operation(operation)
|
|
312
|
+
target = build_operation_url(
|
|
313
|
+
inspection.document.http.endpoint_base,
|
|
314
|
+
operation,
|
|
315
|
+
self.service_origin,
|
|
316
|
+
identifier,
|
|
317
|
+
)
|
|
318
|
+
query = {"representation": representation.value}
|
|
319
|
+
if limit:
|
|
320
|
+
query["limit"] = str(limit)
|
|
321
|
+
target = _append_query(target, query)
|
|
322
|
+
fallback = (
|
|
323
|
+
self._cache_fallbacks.collection
|
|
324
|
+
if operation
|
|
325
|
+
in {
|
|
326
|
+
Operation.GET_COLLECTION,
|
|
327
|
+
Operation.LIST_COLLECTIONS,
|
|
328
|
+
Operation.SEARCH_COLLECTIONS,
|
|
329
|
+
}
|
|
330
|
+
else self._cache_fallbacks.offering
|
|
331
|
+
)
|
|
332
|
+
parser = _operation_parser(operation)
|
|
333
|
+
response = await self._request_cached(
|
|
334
|
+
"GET", target, b"", _MAXIMUM_RESOURCE_BYTES, fallback, parser
|
|
335
|
+
)
|
|
336
|
+
return response.body
|
|
337
|
+
|
|
338
|
+
async def _post_search(
|
|
339
|
+
self, operation: Operation, value: Mapping[str, object], representation: Representation
|
|
340
|
+
) -> bytes:
|
|
341
|
+
inspection = await self._require_operation(operation)
|
|
342
|
+
target = build_operation_url(
|
|
343
|
+
inspection.document.http.endpoint_base, operation, self.service_origin, None
|
|
344
|
+
)
|
|
345
|
+
target = _append_query(target, {"representation": representation.value})
|
|
346
|
+
fallback = (
|
|
347
|
+
self._cache_fallbacks.collection
|
|
348
|
+
if operation is Operation.SEARCH_COLLECTIONS
|
|
349
|
+
else self._cache_fallbacks.offering
|
|
350
|
+
)
|
|
351
|
+
response = await self._request_cached(
|
|
352
|
+
"POST",
|
|
353
|
+
target,
|
|
354
|
+
json.dumps(value, separators=(",", ":")).encode(),
|
|
355
|
+
_MAXIMUM_RESOURCE_BYTES,
|
|
356
|
+
fallback,
|
|
357
|
+
_operation_parser(operation),
|
|
358
|
+
)
|
|
359
|
+
return response.body
|
|
360
|
+
|
|
361
|
+
async def _require_operation(self, operation: Operation) -> Inspection:
|
|
362
|
+
inspection = await self.inspect()
|
|
363
|
+
if not any(item.name is operation for item in inspection.document.operations):
|
|
364
|
+
raise UnsupportedOperationError(operation)
|
|
365
|
+
return inspection
|
|
366
|
+
|
|
367
|
+
async def _request_cached(
|
|
368
|
+
self,
|
|
369
|
+
method: str,
|
|
370
|
+
target: str,
|
|
371
|
+
body: bytes,
|
|
372
|
+
maximum_bytes: int,
|
|
373
|
+
fallback: timedelta,
|
|
374
|
+
parser: object,
|
|
375
|
+
) -> _FetchedResponse:
|
|
376
|
+
key = self._cache_key(method, target, body)
|
|
377
|
+
cached = self._cache.get(key)
|
|
378
|
+
now = utc_now()
|
|
379
|
+
if cached is not None and now < cached.expires:
|
|
380
|
+
return _FetchedResponse(cached.body, cached.final_url, Freshness.FRESH)
|
|
381
|
+
headers: dict[str, str] = {}
|
|
382
|
+
request_target = target
|
|
383
|
+
if cached is not None:
|
|
384
|
+
if derive_service_origin(cached.final_url) == derive_service_origin(target):
|
|
385
|
+
request_target = cached.final_url
|
|
386
|
+
if cached.etag:
|
|
387
|
+
headers["if-none-match"] = cached.etag
|
|
388
|
+
if cached.last_modified:
|
|
389
|
+
headers["if-modified-since"] = cached.last_modified
|
|
390
|
+
response, final_url = await self._request_raw(method, request_target, body, headers)
|
|
391
|
+
if response.status == 304:
|
|
392
|
+
if cached is None:
|
|
393
|
+
raise AgentError("ODP response returned 304 without a cached representation")
|
|
394
|
+
if _no_store(response.headers):
|
|
395
|
+
self._cache.delete(key)
|
|
396
|
+
return _FetchedResponse(cached.body, cached.final_url, Freshness.REVALIDATED)
|
|
397
|
+
lifetime = cached.expires - cached.stored
|
|
398
|
+
expires = (
|
|
399
|
+
_expiration(response.headers, fallback, now)
|
|
400
|
+
if _has_freshness(response.headers)
|
|
401
|
+
else now + max(lifetime, timedelta())
|
|
402
|
+
)
|
|
403
|
+
record = replace(cached, expires=expires, final_url=final_url, stored=now)
|
|
404
|
+
self._cache.set(key, record)
|
|
405
|
+
return _FetchedResponse(record.body, record.final_url, Freshness.REVALIDATED)
|
|
406
|
+
response = _consume(response, maximum_bytes)
|
|
407
|
+
_invoke_parser(parser, response.body)
|
|
408
|
+
if _cacheable(method, response.headers, fallback):
|
|
409
|
+
self._cache.set(
|
|
410
|
+
key,
|
|
411
|
+
CacheRecord(
|
|
412
|
+
body=response.body,
|
|
413
|
+
etag=response.headers.get("etag"),
|
|
414
|
+
expires=_expiration(response.headers, fallback, now),
|
|
415
|
+
final_url=final_url,
|
|
416
|
+
last_modified=response.headers.get("last-modified"),
|
|
417
|
+
status=response.status,
|
|
418
|
+
stored=now,
|
|
419
|
+
),
|
|
420
|
+
)
|
|
421
|
+
else:
|
|
422
|
+
self._cache.delete(key)
|
|
423
|
+
return _FetchedResponse(response.body, final_url, Freshness.FETCHED)
|
|
424
|
+
|
|
425
|
+
async def _request_raw(
|
|
426
|
+
self, method: str, target: str, body: bytes, conditional: dict[str, str]
|
|
427
|
+
) -> tuple[HttpResponse, str]:
|
|
428
|
+
redirect_origin = derive_service_origin(target)
|
|
429
|
+
for redirects in range(_MAXIMUM_REDIRECTS + 1):
|
|
430
|
+
headers = {"accept": MEDIA_TYPE, **conditional}
|
|
431
|
+
if self._accept_language:
|
|
432
|
+
headers["accept-language"] = self._accept_language
|
|
433
|
+
if body:
|
|
434
|
+
headers["content-type"] = MEDIA_TYPE
|
|
435
|
+
try:
|
|
436
|
+
response = await self._transport.send(HttpRequest(method, target, headers, body))
|
|
437
|
+
except TransportError as error:
|
|
438
|
+
raise AgentError(f"ODP Service request failed: {error}") from error
|
|
439
|
+
if response.status not in {301, 302, 303, 307, 308}:
|
|
440
|
+
return response, target
|
|
441
|
+
if redirects == _MAXIMUM_REDIRECTS:
|
|
442
|
+
raise AgentError("ODP response exceeded five redirects")
|
|
443
|
+
location = response.headers.get("location")
|
|
444
|
+
if location is None:
|
|
445
|
+
raise AgentError("ODP redirect omitted Location")
|
|
446
|
+
next_target = urljoin(target, location)
|
|
447
|
+
if derive_service_origin(next_target) != redirect_origin:
|
|
448
|
+
raise AgentError("ODP redirect changed Service origin")
|
|
449
|
+
if response.status == 303 or (response.status in {301, 302} and method == "POST"):
|
|
450
|
+
method, body = "GET", b""
|
|
451
|
+
target = next_target
|
|
452
|
+
raise AgentError("ODP response exceeded its redirect limit") # pragma: no cover
|
|
453
|
+
|
|
454
|
+
async def _linked_odp(self, target: str, fallback: timedelta, parser: object) -> bytes:
|
|
455
|
+
response = await self._request_cached(
|
|
456
|
+
"GET", target, b"", _MAXIMUM_RESOURCE_BYTES, fallback, parser
|
|
457
|
+
)
|
|
458
|
+
return response.body
|
|
459
|
+
|
|
460
|
+
async def _supporting_json(
|
|
461
|
+
self,
|
|
462
|
+
target: str,
|
|
463
|
+
resource_class: str,
|
|
464
|
+
accept: str,
|
|
465
|
+
media_types: set[str],
|
|
466
|
+
maximum_bytes: int,
|
|
467
|
+
) -> dict[str, object]:
|
|
468
|
+
current = target
|
|
469
|
+
if not _is_https_url(current):
|
|
470
|
+
raise AgentError("ODP supporting document URL must use HTTPS")
|
|
471
|
+
key = f"anonymous:{resource_class}\nGET\n{target}\n{accept}"
|
|
472
|
+
cached = self._cache.get(key)
|
|
473
|
+
now = utc_now()
|
|
474
|
+
if cached is not None and now < cached.expires:
|
|
475
|
+
return _decode_json_object(cached.body)
|
|
476
|
+
conditional: dict[str, str] = {}
|
|
477
|
+
if cached is not None:
|
|
478
|
+
if cached.etag:
|
|
479
|
+
conditional["if-none-match"] = cached.etag
|
|
480
|
+
if cached.last_modified:
|
|
481
|
+
conditional["if-modified-since"] = cached.last_modified
|
|
482
|
+
for redirects in range(_MAXIMUM_REDIRECTS + 1):
|
|
483
|
+
try:
|
|
484
|
+
response = await self._supporting_transport.send(
|
|
485
|
+
HttpRequest("GET", current, {"accept": accept, **conditional})
|
|
486
|
+
)
|
|
487
|
+
except TransportError as error:
|
|
488
|
+
raise AgentError(f"ODP supporting document request failed: {error}") from error
|
|
489
|
+
if response.status in {301, 302, 303, 307, 308}:
|
|
490
|
+
if redirects == _MAXIMUM_REDIRECTS:
|
|
491
|
+
raise AgentError("ODP supporting document exceeded five redirects")
|
|
492
|
+
location = response.headers.get("location")
|
|
493
|
+
if location is None:
|
|
494
|
+
raise AgentError("ODP supporting document redirect omitted Location")
|
|
495
|
+
current = urljoin(current, location)
|
|
496
|
+
if not _is_https_url(current):
|
|
497
|
+
raise AgentError("ODP supporting document redirect must use HTTPS")
|
|
498
|
+
continue
|
|
499
|
+
if response.status == 304:
|
|
500
|
+
if cached is None:
|
|
501
|
+
raise AgentError(
|
|
502
|
+
"ODP supporting document returned 304 without a cached representation"
|
|
503
|
+
)
|
|
504
|
+
if _no_store(response.headers):
|
|
505
|
+
self._cache.delete(key)
|
|
506
|
+
else:
|
|
507
|
+
lifetime = cached.expires - cached.stored
|
|
508
|
+
expires = (
|
|
509
|
+
_expiration(response.headers, timedelta(), now)
|
|
510
|
+
if _has_freshness(response.headers)
|
|
511
|
+
else now + max(lifetime, timedelta())
|
|
512
|
+
)
|
|
513
|
+
self._cache.set(
|
|
514
|
+
key,
|
|
515
|
+
replace(cached, expires=expires, final_url=current, stored=now),
|
|
516
|
+
)
|
|
517
|
+
return _decode_json_object(cached.body)
|
|
518
|
+
if not 200 <= response.status < 300:
|
|
519
|
+
raise ServiceRequestError(
|
|
520
|
+
response.status,
|
|
521
|
+
f"ODP supporting document returned HTTP {response.status}",
|
|
522
|
+
response.headers,
|
|
523
|
+
)
|
|
524
|
+
if len(response.body) > maximum_bytes:
|
|
525
|
+
raise AgentError("ODP supporting document exceeds its byte limit")
|
|
526
|
+
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
|
527
|
+
if content_type not in media_types:
|
|
528
|
+
raise AgentError("ODP supporting document has an unsupported media type")
|
|
529
|
+
value = _decode_json_object(response.body)
|
|
530
|
+
if _cacheable("GET", response.headers, timedelta()):
|
|
531
|
+
self._cache.set(
|
|
532
|
+
key,
|
|
533
|
+
CacheRecord(
|
|
534
|
+
body=response.body,
|
|
535
|
+
etag=response.headers.get("etag"),
|
|
536
|
+
expires=_expiration(response.headers, timedelta(), now),
|
|
537
|
+
final_url=current,
|
|
538
|
+
last_modified=response.headers.get("last-modified"),
|
|
539
|
+
status=response.status,
|
|
540
|
+
stored=now,
|
|
541
|
+
),
|
|
542
|
+
)
|
|
543
|
+
else:
|
|
544
|
+
self._cache.delete(key)
|
|
545
|
+
return value
|
|
546
|
+
raise AgentError("ODP supporting document exceeded its redirect limit") # pragma: no cover
|
|
547
|
+
|
|
548
|
+
def _cache_key(self, method: str, target: str, body: bytes) -> str:
|
|
549
|
+
digest = hashlib.sha256(body).hexdigest()
|
|
550
|
+
return "\n".join(
|
|
551
|
+
(self._cache_partition, method, target, self._accept_language or "", digest)
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _invoke_parser(parser: object, body: bytes) -> None:
|
|
556
|
+
if not callable(parser):
|
|
557
|
+
raise TypeError("ODP parser is not callable")
|
|
558
|
+
try:
|
|
559
|
+
parser(body)
|
|
560
|
+
except ValueError as error:
|
|
561
|
+
raise AgentError(str(error)) from error
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _operation_parser(operation: Operation) -> object:
|
|
565
|
+
if operation is Operation.GET_COLLECTION:
|
|
566
|
+
return parse_collection
|
|
567
|
+
if operation is Operation.GET_OFFERING:
|
|
568
|
+
return parse_offering
|
|
569
|
+
if operation in {Operation.LIST_COLLECTIONS, Operation.SEARCH_COLLECTIONS}:
|
|
570
|
+
return parse_collection_page
|
|
571
|
+
return parse_offering_page
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _encode(value: object) -> bytes:
|
|
575
|
+
if not hasattr(value, "model_dump_json"):
|
|
576
|
+
raise TypeError("ODP model is not serializable")
|
|
577
|
+
encoded = value.model_dump_json(by_alias=True, exclude_unset=True)
|
|
578
|
+
return cast(str, encoded).encode()
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _append_query(target: str, values: dict[str, str]) -> str:
|
|
582
|
+
parts = urlsplit(target)
|
|
583
|
+
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
|
584
|
+
query.update(values)
|
|
585
|
+
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _is_https_url(value: str) -> bool:
|
|
589
|
+
parsed = urlsplit(value)
|
|
590
|
+
return parsed.scheme == "https" and parsed.hostname is not None
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _decode_json_object(data: bytes) -> dict[str, object]:
|
|
594
|
+
try:
|
|
595
|
+
value = json.loads(data)
|
|
596
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
597
|
+
raise AgentError(f"ODP supporting document is invalid JSON: {error}") from error
|
|
598
|
+
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
|
599
|
+
raise AgentError("ODP supporting document must be a JSON object")
|
|
600
|
+
return cast(dict[str, object], value)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _consume(response: HttpResponse, maximum_bytes: int) -> HttpResponse:
|
|
604
|
+
if len(response.body) > maximum_bytes:
|
|
605
|
+
raise AgentError("ODP response exceeds its byte limit")
|
|
606
|
+
if not 200 <= response.status < 300:
|
|
607
|
+
try:
|
|
608
|
+
problem = parse_problem_response(response.body, response.status)
|
|
609
|
+
message = problem.detail or problem.title
|
|
610
|
+
except ValueError:
|
|
611
|
+
message = response.body.decode(errors="replace")
|
|
612
|
+
raise ServiceRequestError(response.status, message, response.headers)
|
|
613
|
+
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
|
614
|
+
if content_type != MEDIA_TYPE:
|
|
615
|
+
raise AgentError(f"ODP response must use {MEDIA_TYPE}")
|
|
616
|
+
return response
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _traversal_bounds(options: TraversalOptions) -> tuple[int, int]:
|
|
620
|
+
if not 1 <= options.max_items <= 10_000 or not 1 <= options.max_pages <= 16:
|
|
621
|
+
raise AgentError("traversal exceeds 10000 items or 16 pages")
|
|
622
|
+
return options.max_items, options.max_pages
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _cache_directives(headers: dict[str, str]) -> dict[str, str]:
|
|
626
|
+
result: dict[str, str] = {}
|
|
627
|
+
for value in headers.get("cache-control", "").split(","):
|
|
628
|
+
value = value.strip()
|
|
629
|
+
if value:
|
|
630
|
+
name, _, setting = value.partition("=")
|
|
631
|
+
result[name.lower()] = setting.strip('"')
|
|
632
|
+
return result
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _no_store(headers: dict[str, str]) -> bool:
|
|
636
|
+
return "no-store" in _cache_directives(headers)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _has_freshness(headers: dict[str, str]) -> bool:
|
|
640
|
+
directives = _cache_directives(headers)
|
|
641
|
+
return bool({"max-age", "no-cache", "no-store"} & directives.keys()) or "expires" in headers
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _cacheable(method: str, headers: dict[str, str], fallback: timedelta) -> bool:
|
|
645
|
+
vary = {value.strip().lower() for value in headers.get("vary", "").split(",") if value.strip()}
|
|
646
|
+
if not vary <= {"accept", "accept-language", "content-type"} or _no_store(headers):
|
|
647
|
+
return False
|
|
648
|
+
directives = _cache_directives(headers)
|
|
649
|
+
explicit = "max-age" in directives or "expires" in headers
|
|
650
|
+
return (method == "GET" and (fallback > timedelta() or "no-cache" in directives)) or explicit
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _expiration(headers: dict[str, str], fallback: timedelta, now: datetime) -> datetime:
|
|
654
|
+
directives = _cache_directives(headers)
|
|
655
|
+
if "no-cache" in directives:
|
|
656
|
+
return now
|
|
657
|
+
try:
|
|
658
|
+
duration = max(0, int(directives["max-age"]) - int(headers.get("age", "0")))
|
|
659
|
+
return now + timedelta(seconds=duration)
|
|
660
|
+
except (KeyError, ValueError):
|
|
661
|
+
pass
|
|
662
|
+
if "expires" in headers:
|
|
663
|
+
try:
|
|
664
|
+
return parsedate_to_datetime(headers["expires"])
|
|
665
|
+
except (TypeError, ValueError):
|
|
666
|
+
pass
|
|
667
|
+
return now + fallback
|