diff --git a/README.md b/README.md index ed7461e..5c12187 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Services and navigating their Offerings. ODP separates two levels of discovery: 1. An Agent searches the canonical Directory for Services. -2. The Agent inspects a Service's live ODP document and navigates that Service's Collections and +2. For a native ODP source, the Agent inspects the Service's live ODP document and navigates its Collections and Offerings. The Directory does not copy every Service catalog. Catalog searches go directly to each Service. @@ -77,10 +77,12 @@ asyncio.run(main()) an explicit list must be nonempty and distinct. Filters apply to the owning Service's metadata. Omit the query to browse. The default and maximum result limit are 100. -A Collection is identified by its owning Service origin and case-sensitive Collection ID. -Inspect that Service's live ODP document, then call `ServiceClient.get_collection()` for current -details. The result's `indexed_at` describes Collection freshness; `service.indexed_at` describes -parent freshness. `service.service_id` is the Directory's Service identifier. A Service result can +A Collection is identified by its owning `service.service_id` and case-sensitive Collection ID. +Different OpenAPI documents can share an API origin without being the same Directory Service. +When `service.source.type == "odp"`, inspect that Service's live ODP document, then call +`ServiceClient.get_collection()` for current details. OpenAPI Collections are Directory presentation +groups, not ODP operation targets. The result's `indexed_at` describes Collection freshness; +`service.indexed_at` describes parent freshness. `service.service_id` is the Directory's Service identifier. A Service result can have `available_through` platform attribution; a Collection's attribution is its owning `service`. Malformed known results are omitted and reported in `issues` with their original response index. @@ -88,6 +90,23 @@ Unknown future types retain their full JSON in `UnknownResult.raw`; do not treat or execute their metadata. Additional fields are available through `additional`. Directory metadata does not replace inspection of the Service's own document. +Mixed results use `DirectoryIndexedService`, separate from the native `DirectoryService` returned +by `search_services()`. Each mixed Service requires `service_id`, `service_origin`, `name`, +`indexed_at` and `source`. Imported descriptions and languages are optional; missing lists become +empty lists. Imported results do not expose native ODP operations. Native results retain ODP +validation. Unverified execution fields such as `http` and `payment_origins` are not returned. + +`DirectorySource` identifies the document used for discovery: + +- `type` is `"odp"`, `"openapi"`, or an unknown future string. Unknown formats remain readable + but must not be passed to ODP operations. +- `url` is the exact document URL, including path and query. It can differ from the API origin; + do not reconstruct it from `service_origin`. +- `x402_discovery` records supporting fixed-path x402 discovery, not proof that an endpoint + accepts payments. Advertised protocol evidence remains in `protocols`. + +The client does not fetch or execute OpenAPI documents. + Mixed search does not currently offer continuation. Missing `next` does **not** mean every match was returned. Refine the query or filters when needed. `continue_search(next)` follows one opaque same-origin reference if the server supplies one; the SDK does not invent continuations. Facets @@ -105,7 +124,32 @@ are candidate search queries, not resource identifiers. See the [runnable canonical Directory example](examples/README.md#canonical-directory-discovery). -## Search only Services +### Filter by source + +```python +from offering_protocol.directory import ( + DirectoryClient, + ResourceSearchRequest, + ServiceFilters, + SuggestionRequest, +) + + +async def discover_openapi() -> None: + filters = ServiceFilters(sources=["openapi"]) + async with DirectoryClient() as directory: + results = await directory.search(ResourceSearchRequest(query="weather", filters=filters)) + names = await directory.suggest(SuggestionRequest(prefix="we", filters=filters)) + print(results.items, names) +``` + +Omitting `sources` includes all formats. An explicit list must contain one or both distinct +`"odp"` and `"openapi"` values. Sources are alternatives, combined with other filter categories +using AND. Collections inherit their owning Service's source. Unsupported source filter values +are rejected. Native `search_services()` accepts the filter but remains ODP-only: an OpenAPI-only +filter returns no native matches. + +## Search only native ODP Services `DirectoryClient` uses the one canonical production Directory. Pass `Environment.SANDBOX` when working against InFlow's sandbox; the endpoint itself is not configurable. @@ -143,6 +187,9 @@ and trust protocols. Use `suggest_services()` to discover Service-only keyword c ### API migration +- Mixed results use `DirectoryIndexedService` with required source metadata. Missing sources are + reported as item issues, not assumed to be ODP. Native Service-only models are unchanged. +- Imported `description` and `language` can be `None`; check the source before ODP navigation. - Service-only `search()` calls become `search_services()`, and `continue_search()` calls become `continue_search_services()`. - Aggregating `search_services(request, options)` calls become `collect_services(request, options)`. @@ -385,8 +432,9 @@ Protocol models preserve additive members in `model.additional` and round-trip t `model.to_dict()`. Parsing remains strict for normative constraints and fields that prohibit unknown members. -Directory records retain additional metadata such as branding and MCP endpoints. These are discovery -hints, not authorization or authoritative routing data. The default Agent factory uses the record's +Native Service-only records retain additional metadata such as branding and MCP endpoints. Mixed +results omit unverified execution fields. Directory metadata is not authorization or authoritative +routing data. The default Agent factory uses the native record's `service_origin` and retrieves that Service's own document before making catalog requests. Handle the narrowest error that the application can act upon and use the role's base error for the diff --git a/examples/README.md b/examples/README.md index 7df0752..e8c085a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -39,6 +39,7 @@ uv run python examples/directory.py sandbox weather Use `production` for the production Directory. Omit `weather` to browse. This example requires a deployment with `/v1/directory/search`. It requests at most five mixed results, displays Service and Collection names, reports unusable or unknown results, and retrieves Collection details only -after inspecting the owning Service's advertised anonymous support. It does not enroll, pay or +for ODP sources after inspecting the owning Service's advertised anonymous support. Imported +Collections print their exact document URL without ODP calls. It does not enroll, pay or invoke Actions. Unlike the local Service example above, this uses the real Directory. The server's bounded result list does not promise every matching result is included. diff --git a/examples/directory.py b/examples/directory.py index 40de119..11e1a8b 100644 --- a/examples/directory.py +++ b/examples/directory.py @@ -25,11 +25,15 @@ async def discover(environment: Environment, query: str) -> None: for item in response.items: if isinstance(item, ServiceResult): print(f"Service: {item.service.name} ({item.service.service_origin})") + print(f"Discovery document: {item.service.source.url}") elif isinstance(item, CollectionResult): print( f"Collection: {item.collection.name} " f"({item.collection.id}, through {item.service.service_origin})" ) + if item.service.source.type != "odp": + print(f"Discovery document: {item.service.source.url}") + continue async with ServiceClient(item.service.service_origin) as service: inspection = await service.inspect() if any( diff --git a/scripts/verify-consumer.sh b/scripts/verify-consumer.sh index 6639fb4..cc07c80 100755 --- a/scripts/verify-consumer.sh +++ b/scripts/verify-consumer.sh @@ -83,6 +83,14 @@ assert directory.__name__ == "offering_protocol.directory" assert service.__name__ == "offering_protocol.service" request = directory.ResourceSearchRequest(types=["collection"]) assert request.to_dict() == {"types": ["collection"]} +filters = directory.ServiceFilters(sources=["openapi"]) +assert filters.to_dict() == {"sources": ["openapi"]} +source = directory.DirectorySource(type="openapi", url="https://example.com/api.json", x402_discovery=False) +record = directory.DirectoryIndexedService( + indexed_at="2026-09-23T12:00:00Z", name="Example", service_id="example", + service_origin="https://example.com", source=source, +) +assert record.source.url == source.url and record.operations == [] document = core.parse_service_document( b'{"description":"Consumer smoke test","http":{"endpoint_base":"/odp"},' b'"language":"en","localizations":["en"],"name":"Consumer",' diff --git a/src/offering_protocol/directory/__init__.py b/src/offering_protocol/directory/__init__.py index 0dfe585..3e30618 100644 --- a/src/offering_protocol/directory/__init__.py +++ b/src/offering_protocol/directory/__init__.py @@ -8,9 +8,11 @@ from offering_protocol.directory.models import ( CollectionResult, CollectionSummary, + DirectoryIndexedService, DirectoryIssue, DirectoryResult, DirectoryService, + DirectorySource, Environment, Facet, Facets, @@ -42,10 +44,12 @@ "CollectionSummary", "DirectoryClient", "DirectoryError", + "DirectoryIndexedService", "DirectoryIssue", "DirectoryRequestError", "DirectoryResult", "DirectoryService", + "DirectorySource", "Environment", "Facet", "Facets", diff --git a/src/offering_protocol/directory/client.py b/src/offering_protocol/directory/client.py index 117c541..01c399e 100644 --- a/src/offering_protocol/directory/client.py +++ b/src/offering_protocol/directory/client.py @@ -332,6 +332,10 @@ def _validate_search_request(request: SearchRequest) -> None: raise DirectoryError( "query must contain at most 512 characters without surrounding whitespace" ) + if request.filters is not None and request.filters.sources is not None: + sources = request.filters.sources + if not 1 <= len(sources) <= 2 or len(set(sources)) != len(sources): + raise DirectoryError("sources must contain one or two distinct odp or openapi values") if request.filters is not None and ( len(request.filters.keywords) > 32 or any(not keyword or len(keyword) > 64 for keyword in request.filters.keywords) diff --git a/src/offering_protocol/directory/models.py b/src/offering_protocol/directory/models.py index 31a5d82..a4be338 100644 --- a/src/offering_protocol/directory/models.py +++ b/src/offering_protocol/directory/models.py @@ -50,6 +50,7 @@ class ServiceFilters(OdpModel): keywords: list[str] = Field(default_factory=list) operations: list[OperationFilter] = Field(default_factory=list) payments: list[PaymentFilter] = Field(default_factory=list) + sources: list[Literal["odp", "openapi"]] | None = None trust: list[TrustProtocol] = Field(default_factory=list) @@ -84,6 +85,30 @@ def service_id(self) -> str | None: return value if isinstance(value, str) else None +class DirectorySource(OdpModel): + type: str + url: str + x402_discovery: bool + + +class DirectoryIndexedService(OdpModel): + description: str | None = None + documentation_url: str | None = None + indexed_at: str + keywords: list[str] = Field(default_factory=list) + language: str | None = None + localizations: list[str] = Field(default_factory=list) + name: str + operations: list[OperationDescriptor] = Field(default_factory=list) + protocols: ServiceProtocols | None = None + service_id: str + service_origin: str + source: DirectorySource + status_url: str | None = None + support_url: str | None = None + website_url: str | None = None + + class ServiceReference(OdpModel): service_id: str service_origin: str @@ -98,14 +123,14 @@ class CollectionSummary(OdpModel): class ServiceResult(OdpModel): type: Literal["service"] - service: DirectoryService + service: DirectoryIndexedService indexed_at: str available_through: ServiceReference | None = None class CollectionResult(OdpModel): type: Literal["collection"] - service: DirectoryService + service: DirectoryIndexedService indexed_at: str collection: CollectionSummary diff --git a/src/offering_protocol/directory/results.py b/src/offering_protocol/directory/results.py index e31e0c2..3f28011 100644 --- a/src/offering_protocol/directory/results.py +++ b/src/offering_protocol/directory/results.py @@ -20,6 +20,7 @@ ServiceResult, UnknownResult, ) +from offering_protocol.directory.sources import read_source, validate_imported_service _OBJECT = TypeAdapter(dict[str, JsonValue]) @@ -56,7 +57,7 @@ def _result(value: JsonValue) -> DirectoryResult: _text(service, "service_id", 128) _origin(service) _timestamp(service) - candidate = dict(service) + source = read_source(service.get("source")) for name in ( "branding", "http", @@ -65,7 +66,18 @@ def _result(value: JsonValue) -> DirectoryResult: "payment_origins", "search_capabilities", ): - candidate.pop(name, None) + service.pop(name, None) + if source.type == "odp": + _native_service(service) + else: + validate_imported_service(service) + raw["service"] = service + return _finish_result(raw, kind) + + +def _native_service(service: dict[str, JsonValue]) -> None: + candidate = dict(service) + candidate.pop("source") document = parse_agent_service_document( json.dumps({**candidate, "odp_version": "1.0", "http": {"endpoint_base": "/"}}) ) @@ -74,7 +86,9 @@ def _result(value: JsonValue) -> DirectoryResult: service.pop("protocols", None) else: service["protocols"] = document.protocols.to_dict() - raw["service"] = service + + +def _finish_result(raw: dict[str, JsonValue], kind: str) -> DirectoryResult: if kind == "service": if "available_through" in raw: reference = _OBJECT.validate_python(raw["available_through"]) diff --git a/src/offering_protocol/directory/sources.py b/src/offering_protocol/directory/sources.py new file mode 100644 index 0000000..71d32a8 --- /dev/null +++ b/src/offering_protocol/directory/sources.py @@ -0,0 +1,91 @@ +"""Source-aware metadata validation for mixed Directory results.""" + +from __future__ import annotations + +from ipaddress import ip_address +from urllib.parse import urlsplit + +from pydantic import JsonValue, TypeAdapter + +from offering_protocol.core import derive_service_origin, validate_value +from offering_protocol.directory.addresses import is_public +from offering_protocol.directory.models import DirectorySource + +_OBJECT = TypeAdapter(dict[str, JsonValue]) + + +def read_source(value: JsonValue) -> DirectorySource: + source = DirectorySource.model_validate(value, strict=True) + if not source.type.strip() or len(source.type) > 128: + raise ValueError("source.type must be a nonempty string of at most 128 characters") + url = source.url + if ( + len(url) > 2048 + or not url.lower().startswith("https://") + or any(character.isspace() for character in url) + or "#" in url + ): + raise ValueError("source.url must be an HTTPS document URL without a fragment") + origin = derive_service_origin(url) + host = urlsplit(origin).hostname or "" + if host.rstrip(".") == "localhost" or host.rstrip(".").endswith(".localhost"): + raise ValueError("source.url must have a public host") + try: + address = ip_address(host) + except ValueError: + return source + if not is_public(address): + raise ValueError("source.url must have a public host") + return source + + +def validate_imported_service(service: dict[str, JsonValue]) -> None: + name = service.get("name") + if not isinstance(name, str) or not name.strip() or len(name) > 128: + raise ValueError( + "Imported Service name must be a nonempty string of at most 128 characters" + ) + for field in ( + "description", + "documentation_url", + "language", + "status_url", + "support_url", + "website_url", + ): + if field in service and not isinstance(service[field], str): + raise ValueError(f"{field} must be a string") + service.pop("operations", None) + if "protocols" not in service: + return + protocols = _OBJECT.validate_python(service["protocols"]) + retained: dict[str, JsonValue] = {} + for category, known, schema in ( + ("enrollment", {"aep"}, "enrollment-protocol.schema.json"), + ("payments", {"mpp", "x402"}, "payment-protocol.schema.json"), + ("trust", {"tap"}, "trust-protocol.schema.json"), + ): + if category not in protocols: + continue + values = protocols[category] + if not isinstance(values, list) or not values: + raise ValueError(f"protocols.{category} must be a nonempty array") + selected: list[JsonValue] = [] + names: set[str] = set() + for value in values: + descriptor = _OBJECT.validate_python(value) + name = descriptor.get("name") + if not isinstance(name, str) or not name.strip() or len(name) > 128: + raise ValueError( + "Protocol name must be a nonempty string of at most 128 characters" + ) + if name not in known: + continue + if name in names: + raise ValueError(f"Duplicate {category} descriptor") + names.add(name) + validate_value(descriptor, schema, category) + selected.append(descriptor) + if selected: + retained[category] = selected + service["protocols"] = retained diff --git a/tests/test_directory_mixed.py b/tests/test_directory_mixed.py index 3990752..8d56cf4 100644 --- a/tests/test_directory_mixed.py +++ b/tests/test_directory_mixed.py @@ -27,6 +27,11 @@ def service() -> dict[str, JsonValue]: return { "service_id": "parent", + "source": { + "type": "odp", + "url": "https://api.example.com/.well-known/odp", + "x402_discovery": False, + }, "service_origin": "https://api.example.com", "indexed_at": "2026-09-18T11:00:00Z", "name": "Data", @@ -111,7 +116,7 @@ async def test_mixed_results_metadata_unknown_types_and_requests() -> None: @pytest.mark.asyncio @pytest.mark.parametrize("kind", ["service", "collection"]) -async def test_mixed_results_preserve_descriptive_service_metadata(kind: str) -> None: +async def test_mixed_results_drop_unverified_execution_metadata(kind: str) -> None: parent = service() metadata: dict[str, JsonValue] = { "mcp": [{"type": "streamable-http", "url": "/mcp"}], @@ -127,8 +132,8 @@ async def test_mixed_results_preserve_descriptive_service_metadata(kind: str) -> assert not result.issues entry = result.items[0] assert isinstance(entry, (ServiceResult, CollectionResult)) - for key, value in metadata.items(): - assert entry.service.additional[key] == value + for key in metadata: + assert key not in entry.service.additional @pytest.mark.asyncio @@ -188,7 +193,7 @@ async def test_optional_members_and_unverified_metadata() -> None: assert not result.issues parsed = result.items[0] assert isinstance(parsed, ServiceResult) - assert parsed.service.additional["http"] == {"endpoint_base": "https://untrusted.example/"} + assert "http" not in parsed.service.additional assert parsed.service.protocols is None assert parsed.available_through is not None and parsed.available_through.name is None parent = parsed.service.to_dict() diff --git a/tests/test_directory_sources.py b/tests/test_directory_sources.py new file mode 100644 index 0000000..c908936 --- /dev/null +++ b/tests/test_directory_sources.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +import copy +import json +import runpy +from pathlib import Path + +import pytest +from pydantic import JsonValue, ValidationError + +from offering_protocol.directory import ( + CollectionResult, + DirectoryClient, + DirectoryError, + DirectoryIndexedService, + DirectorySource, + ResourceSearchRequest, + SearchRequest, + ServiceFilters, + ServiceResult, + SuggestionRequest, +) +from test_directory_mixed import item, service, transport_for + + +def imported(kind: str = "service") -> dict[str, JsonValue]: + value = item(kind) + parent = service() + for field in ( + "description", + "language", + "localizations", + "keywords", + "operations", + "protocols", + ): + parent.pop(field, None) + parent["source"] = { + "type": "openapi", + "url": "https://docs.example/specs/api.json?version=3&key=a%2Fb", + "x402_discovery": True, + } + value["service"] = parent + return value + + +def change(value: dict[str, JsonValue], path: str, replacement: JsonValue) -> None: + parts = path.split("/") + for part in parts[:-1]: + child = value[part] + assert isinstance(child, dict) + value = child + value[parts[-1]] = replacement + + +@pytest.mark.asyncio +async def test_source_identity_optional_metadata_and_future_formats() -> None: + first = imported() + change(first, "service/source/extension", {"retained": True}) + for field in ( + "operations", + "http", + "branding", + "mcp", + "odp_version", + "payment_origins", + "search_capabilities", + ): + change(first, f"service/{field}", "not authoritative") + future = imported("collection") + change(future, "service/source/type", "future-format") + change(future, "service/source/url", "HTTPS://Docs.Example:443/other.json?x=1") + change(future, "service/service_id", "other-document") + change(future, "service/description", "") + change(future, "service/language", "en") + change(future, "service/localizations", ["en"]) + change(future, "service/keywords", ["weather"]) + change(future, "service/website_url", "https://example.com/") + payload = {"items": [first, imported("collection"), future]} + original = copy.deepcopy(payload) + transport = transport_for(payload) + page = await DirectoryClient(transport=transport).continue_search( + "/v1/directory/search?cursor=x" + ) + assert payload == original + assert not page.issues + one, two, three = page.items + assert isinstance(one, ServiceResult) + assert isinstance(one.service, DirectoryIndexedService) + assert isinstance(one.service.source, DirectorySource) + assert one.service.source.type == "openapi" + assert one.service.source.url == "https://docs.example/specs/api.json?version=3&key=a%2Fb" + assert one.service.source.x402_discovery is True + assert one.service.source.additional == {"extension": {"retained": True}} + assert one.service.source.to_dict()["type"] == "openapi" + assert one.service.description is None and one.service.language is None + assert one.service.operations == [] + assert one.service.keywords == one.service.localizations == [] + assert one.service.protocols is None and one.service.additional == {} + assert isinstance(two, CollectionResult) and isinstance(three, CollectionResult) + assert two.collection.id == three.collection.id + assert two.service.service_origin == three.service.service_origin + assert two.service.service_id != three.service.service_id + assert three.service.source.type == "future-format" + assert three.service.source.url == "HTTPS://Docs.Example:443/other.json?x=1" + assert three.service.description == "" and three.service.language == "en" + assert three.service.keywords == ["weather"] and three.service.localizations == ["en"] + assert three.service.website_url == "https://example.com/" + assert len(transport.requests) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "path,value", + [ + ("source", None), + ("source", []), + ("source/type", ""), + ("source/type", " "), + ("source/type", "x" * 129), + ("source/type", 1), + ("source/x402_discovery", "false"), + ("source/x402_discovery", 1), + ("source/url", None), + ("source/url", "http://example.com/spec"), + ("source/url", "https:example.com/spec"), + ("source/url", "/openapi.json"), + ("source/url", "https://user:pass@example.com/spec"), + ("source/url", "https://example.com/spec#part"), + ("source/url", "https://example.com/" + "x" * 2048), + ("source/url", "https://example.com/a\nb"), + ("source/url", "https://["), + ("source/url", "https://example.com:70000/spec"), + ("source/url", "https://localhost/spec"), + ("source/url", "https://dev.localhost./spec"), + ("source/url", "https://127.0.0.1/spec"), + ("source/url", "https://[::1]/spec"), + ("source/url", "https://10.0.0.1/spec"), + ("name", ""), + ("name", "x" * 129), + ("name", None), + ("keywords", [1]), + ("keywords", None), + ("localizations", None), + ("description", None), + ("language", 3), + ("website_url", None), + ("documentation_url", False), + ("support_url", []), + ("status_url", {}), + ("protocols", None), + ("protocols", []), + ("protocols", {"trust": []}), + ("protocols", {"trust": None}), + ("protocols", {"trust": [None]}), + ("protocols", {"trust": [{}]}), + ("protocols", {"trust": [{"name": ""}]}), + ("protocols", {"trust": [{"name": "tap"}, {"name": "tap"}]}), + ("protocols", {"payments": [{"name": "x402"}]}), + ( + "protocols", + {"payments": [{"name": "x402", "authentication": "required", "options": ["unknown"]}]}, + ), + ("protocols", {"enrollment": [{"name": "aep", "extra": True}]}), + ], +) +async def test_invalid_imported_records_are_isolated(path: str, value: JsonValue) -> None: + invalid = imported() + change(invalid, f"service/{path}", value) + page = await DirectoryClient( + transport=transport_for({"items": [invalid, imported(), item()]}) + ).search(ResourceSearchRequest()) + assert len(page.items) == 2 + assert len(page.issues) == 1 and page.issues[0].index == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("field", ["type", "url", "x402_discovery"]) +async def test_source_members_are_required(field: str) -> None: + raw = imported() + parent = raw["service"] + assert isinstance(parent, dict) + source = parent["source"] + assert isinstance(source, dict) + del source[field] + page = await DirectoryClient(transport=transport_for({"items": [raw]})).search( + ResourceSearchRequest() + ) + assert not page.items and len(page.issues) == 1 + + +@pytest.mark.asyncio +async def test_native_validation_remains_strict_and_public_ip_sources_are_readable() -> None: + invalid = item() + parent = invalid["service"] + assert isinstance(parent, dict) + del parent["language"] + missing_source = item() + parent = missing_source["service"] + assert isinstance(parent, dict) + del parent["source"] + valid = imported() + change(valid, "service/source/url", "https://8.8.8.8/spec") + page = await DirectoryClient( + transport=transport_for({"items": [invalid, missing_source, valid]}) + ).search(ResourceSearchRequest()) + assert len(page.items) == 1 and len(page.issues) == 2 + raw = imported()["service"] + native = await DirectoryClient( + transport=transport_for({"items": [raw, service()]}) + ).search_services(SearchRequest()) + assert len(native.items) == 1 and len(native.issues) == 1 + + +@pytest.mark.asyncio +async def test_protocol_evidence_without_synthesizing_enrollment() -> None: + valid = imported() + change( + valid, + "service/protocols", + { + "payments": [ + {"name": "x402", "authentication": "required", "options": ["base"]}, + {"name": "future"}, + ], + "trust": [{"name": "tap"}], + }, + ) + enrollment = imported() + change(enrollment, "service/protocols", {"enrollment": [{"name": "aep"}]}) + unknown = imported() + change(unknown, "service/protocols", {"payments": [{"name": "future"}]}) + empty = imported() + change(empty, "service/protocols", {}) + page = await DirectoryClient( + transport=transport_for({"items": [valid, enrollment, unknown, empty]}) + ).search(ResourceSearchRequest()) + assert not page.issues and len(page.items) == 4 + first = page.items[0] + assert isinstance(first, ServiceResult) and first.service.protocols is not None + assert not first.service.protocols.enrollment + assert len(first.service.protocols.payments) == len(first.service.protocols.trust) == 1 + assert first.service.protocols.payments[0].options == ["base"] + + +@pytest.mark.asyncio +async def test_source_filters_on_search_native_search_and_suggestions() -> None: + filters = ServiceFilters(sources=["odp", "openapi"], keywords=["weather"]) + original = copy.deepcopy(filters.to_dict()) + for route in ("mixed", "native", "suggest"): + transport = transport_for({"items": []}) + client = DirectoryClient(transport=transport) + if route == "mixed": + await client.search(ResourceSearchRequest(filters=filters)) + path = "/v1/directory/search" + elif route == "native": + await client.search_services(SearchRequest(filters=filters)) + path = "/v1/services/search" + else: + await client.suggest(SuggestionRequest(prefix="we", filters=filters)) + path = "/v1/directory/suggestions" + request = transport.requests[0] + assert request.method == "POST" and request.url.endswith(path) + assert json.loads(request.body)["filters"] == original + assert filters.to_dict() == original + for sources in ([], ["odp", "odp"], ["odp", "openapi", "odp"]): + invalid = ServiceFilters.model_validate({"sources": sources}) + with pytest.raises(DirectoryError, match="sources"): + await client.search(ResourceSearchRequest(filters=invalid)) + with pytest.raises(DirectoryError, match="sources"): + await client.search_services(SearchRequest(filters=invalid)) + with pytest.raises(DirectoryError, match="sources"): + await client.suggest(SuggestionRequest(prefix="we", filters=invalid)) + assert len(transport.requests) == 1 + assert ServiceFilters().to_dict() == {} + for unsupported in (["future"], ["ODP"], [None]): + with pytest.raises(ValidationError): + ServiceFilters.model_validate({"sources": unsupported}) + + +@pytest.mark.asyncio +async def test_discovery_example_does_not_call_odp_for_imported_collections( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + example = runpy.run_path(str(Path(__file__).parents[1] / "examples" / "directory.py")) + discover = example["discover"] + transport = transport_for({"items": [imported(), imported("collection")]}) + + def directory(*args: object) -> DirectoryClient: + return DirectoryClient(transport=transport) + + def unexpected_service(*args: object) -> None: + raise AssertionError("Imported discovery must not make ODP requests") + + monkeypatch.setitem(discover.__globals__, "DirectoryClient", directory) + monkeypatch.setitem(discover.__globals__, "ServiceClient", unexpected_service) + await discover(example["Environment"].PRODUCTION, "weather") + output = capsys.readouterr().out + assert output.count("Discovery document: https://docs.example/specs/api.json") == 2 + assert len(transport.requests) == 1