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,66 @@
|
|
|
1
|
+
"""Agent-side Service and catalog discovery."""
|
|
2
|
+
|
|
3
|
+
from offering_protocol.agent.agent import (
|
|
4
|
+
Agent,
|
|
5
|
+
DefaultServiceClientFactory,
|
|
6
|
+
DiscoveryEvent,
|
|
7
|
+
FederatedSearchRequest,
|
|
8
|
+
ServiceClientFactory,
|
|
9
|
+
)
|
|
10
|
+
from offering_protocol.agent.cache import Cache, CacheFallbacks, CacheRecord, MemoryCache
|
|
11
|
+
from offering_protocol.agent.capabilities import (
|
|
12
|
+
CapabilityIssue,
|
|
13
|
+
CapabilityKind,
|
|
14
|
+
CapabilityScope,
|
|
15
|
+
ResolvedSortDefinition,
|
|
16
|
+
SearchCapabilityCatalog,
|
|
17
|
+
)
|
|
18
|
+
from offering_protocol.agent.client import (
|
|
19
|
+
AgentError,
|
|
20
|
+
Freshness,
|
|
21
|
+
Inspection,
|
|
22
|
+
ServiceClient,
|
|
23
|
+
ServiceRequestError,
|
|
24
|
+
TraversalOptions,
|
|
25
|
+
UnsupportedOperationError,
|
|
26
|
+
)
|
|
27
|
+
from offering_protocol.agent.details import (
|
|
28
|
+
DiscoveredAction,
|
|
29
|
+
DiscoveredHttpAction,
|
|
30
|
+
DiscoveredOpenApiAction,
|
|
31
|
+
OfferingDetails,
|
|
32
|
+
OfferingIssue,
|
|
33
|
+
OfferingIssueScope,
|
|
34
|
+
ResolvedAction,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"Agent",
|
|
39
|
+
"AgentError",
|
|
40
|
+
"Cache",
|
|
41
|
+
"CacheFallbacks",
|
|
42
|
+
"CacheRecord",
|
|
43
|
+
"CapabilityIssue",
|
|
44
|
+
"CapabilityKind",
|
|
45
|
+
"CapabilityScope",
|
|
46
|
+
"DefaultServiceClientFactory",
|
|
47
|
+
"DiscoveredAction",
|
|
48
|
+
"DiscoveredHttpAction",
|
|
49
|
+
"DiscoveredOpenApiAction",
|
|
50
|
+
"DiscoveryEvent",
|
|
51
|
+
"FederatedSearchRequest",
|
|
52
|
+
"Freshness",
|
|
53
|
+
"Inspection",
|
|
54
|
+
"MemoryCache",
|
|
55
|
+
"OfferingDetails",
|
|
56
|
+
"OfferingIssue",
|
|
57
|
+
"OfferingIssueScope",
|
|
58
|
+
"ResolvedAction",
|
|
59
|
+
"ResolvedSortDefinition",
|
|
60
|
+
"SearchCapabilityCatalog",
|
|
61
|
+
"ServiceClient",
|
|
62
|
+
"ServiceClientFactory",
|
|
63
|
+
"ServiceRequestError",
|
|
64
|
+
"TraversalOptions",
|
|
65
|
+
"UnsupportedOperationError",
|
|
66
|
+
]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Directory-to-Service federated Offering discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
from offering_protocol.agent.client import AgentError, ServiceClient, TraversalOptions
|
|
10
|
+
from offering_protocol.core import Offering, OfferingSearchRequest, Representation
|
|
11
|
+
from offering_protocol.directory import (
|
|
12
|
+
DirectoryClient,
|
|
13
|
+
DirectoryError,
|
|
14
|
+
DirectoryService,
|
|
15
|
+
Environment,
|
|
16
|
+
IterationOptions,
|
|
17
|
+
SearchRequest,
|
|
18
|
+
TransportError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ServiceClientFactory(Protocol):
|
|
23
|
+
def create(self, service: DirectoryService) -> ServiceClient: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DefaultServiceClientFactory:
|
|
27
|
+
def create(self, service: DirectoryService) -> ServiceClient:
|
|
28
|
+
return ServiceClient(service.service_origin)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class FederatedSearchRequest:
|
|
33
|
+
concurrency: int = 0
|
|
34
|
+
max_offerings_per_service: int = 0
|
|
35
|
+
max_services: int = 0
|
|
36
|
+
offerings: OfferingSearchRequest = field(default_factory=OfferingSearchRequest)
|
|
37
|
+
services: SearchRequest = field(default_factory=SearchRequest)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class DiscoveryEvent:
|
|
42
|
+
service: DirectoryService
|
|
43
|
+
offering: Offering | None = None
|
|
44
|
+
issue: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Agent:
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
environment: Environment = Environment.PRODUCTION,
|
|
51
|
+
*,
|
|
52
|
+
directory: DirectoryClient | None = None,
|
|
53
|
+
factory: ServiceClientFactory | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
self._owns_directory = directory is None
|
|
56
|
+
self.directory = directory or DirectoryClient(environment)
|
|
57
|
+
self.factory = factory or DefaultServiceClientFactory()
|
|
58
|
+
|
|
59
|
+
async def __aenter__(self) -> Agent:
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
async def __aexit__(self, *args: object) -> None:
|
|
63
|
+
await self.aclose()
|
|
64
|
+
|
|
65
|
+
async def aclose(self) -> None:
|
|
66
|
+
if self._owns_directory:
|
|
67
|
+
await self.directory.aclose()
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def environment(self) -> Environment:
|
|
71
|
+
return self.directory.environment
|
|
72
|
+
|
|
73
|
+
async def search_offerings_across_services(
|
|
74
|
+
self, request: FederatedSearchRequest
|
|
75
|
+
) -> list[DiscoveryEvent]:
|
|
76
|
+
maximum_services = _bounded(request.max_services, 10, 100, "max_services")
|
|
77
|
+
maximum_offerings = _bounded(
|
|
78
|
+
request.max_offerings_per_service, 10, 100, "max_offerings_per_service"
|
|
79
|
+
)
|
|
80
|
+
concurrency = _bounded(request.concurrency, 4, 16, "concurrency")
|
|
81
|
+
try:
|
|
82
|
+
services = await self.directory.search_services(
|
|
83
|
+
request.services,
|
|
84
|
+
IterationOptions(max_items=maximum_services, max_pages=16),
|
|
85
|
+
)
|
|
86
|
+
except (DirectoryError, TransportError) as error:
|
|
87
|
+
raise AgentError(f"ODP Directory failed: {error}") from error
|
|
88
|
+
semaphore = asyncio.Semaphore(concurrency)
|
|
89
|
+
|
|
90
|
+
async def search(service: DirectoryService) -> list[DiscoveryEvent]:
|
|
91
|
+
async with semaphore:
|
|
92
|
+
try:
|
|
93
|
+
offerings = await _search_service(
|
|
94
|
+
self.factory,
|
|
95
|
+
service,
|
|
96
|
+
request.offerings,
|
|
97
|
+
maximum_offerings,
|
|
98
|
+
)
|
|
99
|
+
return [DiscoveryEvent(service, offering) for offering in offerings]
|
|
100
|
+
except Exception as error:
|
|
101
|
+
return [DiscoveryEvent(service, issue=str(error))]
|
|
102
|
+
|
|
103
|
+
results = await asyncio.gather(*(search(service) for service in services))
|
|
104
|
+
return [event for group in results for event in group]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def _search_service(
|
|
108
|
+
factory: ServiceClientFactory,
|
|
109
|
+
service: DirectoryService,
|
|
110
|
+
request: OfferingSearchRequest,
|
|
111
|
+
maximum: int,
|
|
112
|
+
) -> list[Offering]:
|
|
113
|
+
async with factory.create(service) as client:
|
|
114
|
+
options = TraversalOptions(max_items=maximum, max_pages=16)
|
|
115
|
+
if _has_search(request):
|
|
116
|
+
return await client.search_all_offerings(request, Representation.TERSE, options)
|
|
117
|
+
return await client.list_all_offerings(Representation.TERSE, 0, options)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _has_search(request: OfferingSearchRequest) -> bool:
|
|
121
|
+
return bool(
|
|
122
|
+
request.query
|
|
123
|
+
or request.filters
|
|
124
|
+
or request.include_descendants
|
|
125
|
+
or request.sort
|
|
126
|
+
or request.refinements
|
|
127
|
+
or request.collection_id
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _bounded(value: int, fallback: int, maximum: int, name: str) -> int:
|
|
132
|
+
result = fallback if value == 0 else value
|
|
133
|
+
if not 1 <= result <= maximum:
|
|
134
|
+
raise AgentError(f"{name} must be from 1 through {maximum}")
|
|
135
|
+
return result
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Agent-side representation caching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class CacheRecord:
|
|
12
|
+
body: bytes
|
|
13
|
+
etag: str | None
|
|
14
|
+
expires: datetime
|
|
15
|
+
final_url: str
|
|
16
|
+
last_modified: str | None
|
|
17
|
+
status: int
|
|
18
|
+
stored: datetime
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Cache(Protocol):
|
|
22
|
+
def delete(self, key: str) -> None: ...
|
|
23
|
+
|
|
24
|
+
def get(self, key: str) -> CacheRecord | None: ...
|
|
25
|
+
|
|
26
|
+
def set(self, key: str, record: CacheRecord) -> None: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MemoryCache:
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._records: dict[str, CacheRecord] = {}
|
|
32
|
+
|
|
33
|
+
def delete(self, key: str) -> None:
|
|
34
|
+
self._records.pop(key, None)
|
|
35
|
+
|
|
36
|
+
def get(self, key: str) -> CacheRecord | None:
|
|
37
|
+
return self._records.get(key)
|
|
38
|
+
|
|
39
|
+
def set(self, key: str, record: CacheRecord) -> None:
|
|
40
|
+
self._records[key] = record
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class CacheFallbacks:
|
|
45
|
+
collection: timedelta = timedelta(hours=1)
|
|
46
|
+
offering: timedelta = timedelta(minutes=5)
|
|
47
|
+
service_document: timedelta = timedelta(hours=4)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def utc_now() -> datetime:
|
|
51
|
+
return datetime.now(UTC)
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Effective Service and Collection search capabilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from urllib.parse import urljoin, urlsplit
|
|
9
|
+
|
|
10
|
+
from offering_protocol.agent.client import AgentError, ServiceClient
|
|
11
|
+
from offering_protocol.core import (
|
|
12
|
+
Collection,
|
|
13
|
+
FilterDefinition,
|
|
14
|
+
Operation,
|
|
15
|
+
SearchCapabilities,
|
|
16
|
+
SortDefinition,
|
|
17
|
+
parse_filter_definition_page,
|
|
18
|
+
parse_sort_definition_page,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_MAXIMUM_CAPABILITY_PAGES = 16
|
|
22
|
+
_MAXIMUM_FILTERS = 1_024
|
|
23
|
+
_MAXIMUM_SORTS = 128
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CapabilityScope(StrEnum):
|
|
27
|
+
COLLECTION = "collection"
|
|
28
|
+
SERVICE = "service"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CapabilityKind(StrEnum):
|
|
32
|
+
FILTERS = "filters"
|
|
33
|
+
SORTS = "sorts"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class CapabilityIssue:
|
|
38
|
+
kind: CapabilityKind
|
|
39
|
+
message: str
|
|
40
|
+
scope: CapabilityScope
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class ResolvedSortDefinition:
|
|
45
|
+
definition: SortDefinition
|
|
46
|
+
filters: tuple[FilterDefinition, ...]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(slots=True)
|
|
50
|
+
class SearchCapabilityCatalog:
|
|
51
|
+
filters: dict[str, FilterDefinition] = field(default_factory=dict)
|
|
52
|
+
issues: list[CapabilityIssue] = field(default_factory=list)
|
|
53
|
+
sorts: dict[str, ResolvedSortDefinition] = field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def get_collection_search_capabilities(
|
|
57
|
+
client: ServiceClient, identifier: str
|
|
58
|
+
) -> SearchCapabilityCatalog:
|
|
59
|
+
collection = await client.get_collection(identifier)
|
|
60
|
+
return await _resolve(client, collection)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def get_offering_search_capabilities(
|
|
64
|
+
client: ServiceClient, collection_id: str | None
|
|
65
|
+
) -> SearchCapabilityCatalog:
|
|
66
|
+
collection = await client.get_collection(collection_id) if collection_id else None
|
|
67
|
+
return await _resolve(client, collection)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def _resolve(client: ServiceClient, collection: Collection | None) -> SearchCapabilityCatalog:
|
|
71
|
+
inspection = await client.inspect()
|
|
72
|
+
result = SearchCapabilityCatalog()
|
|
73
|
+
supports_search = any(
|
|
74
|
+
item.name is Operation.SEARCH_OFFERINGS for item in inspection.document.operations
|
|
75
|
+
)
|
|
76
|
+
if not supports_search:
|
|
77
|
+
if collection is not None and collection.search_capabilities is not None:
|
|
78
|
+
result.issues.append(
|
|
79
|
+
CapabilityIssue(
|
|
80
|
+
CapabilityKind.FILTERS,
|
|
81
|
+
"Collection search capabilities require search-offerings",
|
|
82
|
+
CapabilityScope.COLLECTION,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
return result
|
|
86
|
+
sorts: dict[str, SortDefinition] = {}
|
|
87
|
+
sort_scopes: dict[str, CapabilityScope] = {}
|
|
88
|
+
sources = (
|
|
89
|
+
(inspection.document.search_capabilities, CapabilityScope.SERVICE),
|
|
90
|
+
(
|
|
91
|
+
collection.search_capabilities if collection is not None else None,
|
|
92
|
+
CapabilityScope.COLLECTION,
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
for capabilities, scope in sources:
|
|
96
|
+
if capabilities is None:
|
|
97
|
+
continue
|
|
98
|
+
await _add_filters(client, result, scope, capabilities)
|
|
99
|
+
await _add_sorts(client, result, sorts, sort_scopes, scope, capabilities)
|
|
100
|
+
for identifier, definition in sorts.items():
|
|
101
|
+
filters = tuple(
|
|
102
|
+
result.filters[key.filter_id]
|
|
103
|
+
for key in definition.keys
|
|
104
|
+
if key.filter_id in result.filters
|
|
105
|
+
)
|
|
106
|
+
if len(filters) != len(definition.keys):
|
|
107
|
+
result.issues.append(
|
|
108
|
+
CapabilityIssue(
|
|
109
|
+
CapabilityKind.SORTS,
|
|
110
|
+
f"Sort {identifier} references an unavailable filter",
|
|
111
|
+
sort_scopes[identifier],
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
else:
|
|
115
|
+
result.sorts[identifier] = ResolvedSortDefinition(definition, filters)
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def _add_filters(
|
|
120
|
+
client: ServiceClient,
|
|
121
|
+
result: SearchCapabilityCatalog,
|
|
122
|
+
scope: CapabilityScope,
|
|
123
|
+
capabilities: SearchCapabilities,
|
|
124
|
+
) -> None:
|
|
125
|
+
if capabilities.filters is None:
|
|
126
|
+
return
|
|
127
|
+
try:
|
|
128
|
+
values = (
|
|
129
|
+
await _load_filters(client, capabilities.filters.linked.href)
|
|
130
|
+
if capabilities.filters.linked is not None
|
|
131
|
+
else capabilities.filters.inline
|
|
132
|
+
)
|
|
133
|
+
except AgentError as error:
|
|
134
|
+
result.issues.append(CapabilityIssue(CapabilityKind.FILTERS, str(error), scope))
|
|
135
|
+
return
|
|
136
|
+
duplicates = _duplicates((item.id for item in values), result.filters)
|
|
137
|
+
for identifier in duplicates:
|
|
138
|
+
result.filters.pop(identifier, None)
|
|
139
|
+
accepted = [item for item in values if item.id not in duplicates]
|
|
140
|
+
if len(result.filters) + len(accepted) > _MAXIMUM_FILTERS:
|
|
141
|
+
result.issues.append(
|
|
142
|
+
CapabilityIssue(
|
|
143
|
+
CapabilityKind.FILTERS,
|
|
144
|
+
"Effective filters exceed 1024 entries",
|
|
145
|
+
scope,
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
return
|
|
149
|
+
result.filters.update((item.id, item) for item in accepted)
|
|
150
|
+
_report_duplicates(duplicates, CapabilityKind.FILTERS, scope, result.issues)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def _add_sorts(
|
|
154
|
+
client: ServiceClient,
|
|
155
|
+
result: SearchCapabilityCatalog,
|
|
156
|
+
target: dict[str, SortDefinition],
|
|
157
|
+
scopes: dict[str, CapabilityScope],
|
|
158
|
+
scope: CapabilityScope,
|
|
159
|
+
capabilities: SearchCapabilities,
|
|
160
|
+
) -> None:
|
|
161
|
+
if capabilities.sorts is None:
|
|
162
|
+
return
|
|
163
|
+
try:
|
|
164
|
+
values = (
|
|
165
|
+
await _load_sorts(client, capabilities.sorts.linked.href)
|
|
166
|
+
if capabilities.sorts.linked is not None
|
|
167
|
+
else capabilities.sorts.inline
|
|
168
|
+
)
|
|
169
|
+
except AgentError as error:
|
|
170
|
+
result.issues.append(CapabilityIssue(CapabilityKind.SORTS, str(error), scope))
|
|
171
|
+
return
|
|
172
|
+
duplicates = _duplicates((item.id for item in values), target)
|
|
173
|
+
for identifier in duplicates:
|
|
174
|
+
target.pop(identifier, None)
|
|
175
|
+
scopes.pop(identifier, None)
|
|
176
|
+
accepted = [item for item in values if item.id not in duplicates]
|
|
177
|
+
if len(target) + len(accepted) > _MAXIMUM_SORTS:
|
|
178
|
+
result.issues.append(
|
|
179
|
+
CapabilityIssue(CapabilityKind.SORTS, "Effective sorts exceed 128 entries", scope)
|
|
180
|
+
)
|
|
181
|
+
return
|
|
182
|
+
target.update((item.id, item) for item in accepted)
|
|
183
|
+
scopes.update((item.id, scope) for item in accepted)
|
|
184
|
+
_report_duplicates(duplicates, CapabilityKind.SORTS, scope, result.issues)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def _load_filters(client: ServiceClient, reference: str) -> list[FilterDefinition]:
|
|
188
|
+
values: list[FilterDefinition] = []
|
|
189
|
+
next_reference = reference
|
|
190
|
+
visited: set[str] = set()
|
|
191
|
+
for _ in range(_MAXIMUM_CAPABILITY_PAGES):
|
|
192
|
+
if not next_reference:
|
|
193
|
+
return values
|
|
194
|
+
target = _resolve_reference(next_reference, client.service_origin)
|
|
195
|
+
if target in visited:
|
|
196
|
+
raise AgentError("ODP capability pagination loop detected")
|
|
197
|
+
visited.add(target)
|
|
198
|
+
body = await client._linked_odp(
|
|
199
|
+
target, client._cache_fallbacks.collection, parse_filter_definition_page
|
|
200
|
+
)
|
|
201
|
+
page = parse_filter_definition_page(body)
|
|
202
|
+
values.extend(page.items)
|
|
203
|
+
next_reference = page.next
|
|
204
|
+
if next_reference:
|
|
205
|
+
raise AgentError("ODP capability source exceeded 16 pages")
|
|
206
|
+
return values
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
async def _load_sorts(client: ServiceClient, reference: str) -> list[SortDefinition]:
|
|
210
|
+
values: list[SortDefinition] = []
|
|
211
|
+
next_reference = reference
|
|
212
|
+
visited: set[str] = set()
|
|
213
|
+
for _ in range(_MAXIMUM_CAPABILITY_PAGES):
|
|
214
|
+
if not next_reference:
|
|
215
|
+
return values
|
|
216
|
+
target = _resolve_reference(next_reference, client.service_origin)
|
|
217
|
+
if target in visited:
|
|
218
|
+
raise AgentError("ODP capability pagination loop detected")
|
|
219
|
+
visited.add(target)
|
|
220
|
+
body = await client._linked_odp(
|
|
221
|
+
target, client._cache_fallbacks.collection, parse_sort_definition_page
|
|
222
|
+
)
|
|
223
|
+
page = parse_sort_definition_page(body)
|
|
224
|
+
values.extend(page.items)
|
|
225
|
+
next_reference = page.next
|
|
226
|
+
if next_reference:
|
|
227
|
+
raise AgentError("ODP capability source exceeded 16 pages")
|
|
228
|
+
return values
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _resolve_reference(reference: str, origin: str) -> str:
|
|
232
|
+
target = urljoin(f"{origin}/", reference)
|
|
233
|
+
parsed = urlsplit(target)
|
|
234
|
+
if parsed.scheme not in {"http", "https"} or parsed.hostname is None:
|
|
235
|
+
raise AgentError("ODP capability reference must use HTTP or HTTPS")
|
|
236
|
+
return target
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _duplicates(values: Iterable[str], existing: Mapping[str, object]) -> set[str]:
|
|
240
|
+
seen: set[str] = set()
|
|
241
|
+
duplicates: set[str] = set()
|
|
242
|
+
for value in values:
|
|
243
|
+
if value in seen or value in existing:
|
|
244
|
+
duplicates.add(value)
|
|
245
|
+
seen.add(value)
|
|
246
|
+
return duplicates
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _report_duplicates(
|
|
250
|
+
duplicates: set[str],
|
|
251
|
+
kind: CapabilityKind,
|
|
252
|
+
scope: CapabilityScope,
|
|
253
|
+
issues: list[CapabilityIssue],
|
|
254
|
+
) -> None:
|
|
255
|
+
if duplicates:
|
|
256
|
+
issues.append(
|
|
257
|
+
CapabilityIssue(
|
|
258
|
+
kind,
|
|
259
|
+
f"Duplicate {kind.value}: {', '.join(sorted(duplicates))}",
|
|
260
|
+
scope,
|
|
261
|
+
)
|
|
262
|
+
)
|