diff --git a/README.md b/README.md index 5c12187..2356734 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,9 @@ When `service.source.type == "odp"`, inspect that Service's live ODP document, t `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`. +have optional `publisher` attribution with `publisher_id`, `name`, and `website_url`. +The website is a display link, not a discovery or execution target. Omitted or null publisher +attribution is accepted, and additional fields are preserved. A Collection's attribution is its owning `service`. Malformed known results are omitted and reported in `issues` with their original response index. Unknown future types retain their full JSON in `UnknownResult.raw`; do not treat them as Services diff --git a/src/offering_protocol/directory/__init__.py b/src/offering_protocol/directory/__init__.py index 3e30618..ea4c944 100644 --- a/src/offering_protocol/directory/__init__.py +++ b/src/offering_protocol/directory/__init__.py @@ -20,13 +20,13 @@ OperationFilter, PaymentFilter, PaymentOptionFacetValue, + Publisher, ResourceSearchRequest, SearchPage, SearchRequest, SearchResponse, ServiceFilters, ServiceIssue, - ServiceReference, ServiceResult, SuggestionRequest, UnknownResult, @@ -60,13 +60,13 @@ "OperationFilter", "PaymentFilter", "PaymentOptionFacetValue", + "Publisher", "ResourceSearchRequest", "SearchPage", "SearchRequest", "SearchResponse", "ServiceFilters", "ServiceIssue", - "ServiceReference", "ServiceResult", "SuggestionRequest", "Transport", diff --git a/src/offering_protocol/directory/models.py b/src/offering_protocol/directory/models.py index a4be338..8d4d594 100644 --- a/src/offering_protocol/directory/models.py +++ b/src/offering_protocol/directory/models.py @@ -109,10 +109,10 @@ class DirectoryIndexedService(OdpModel): website_url: str | None = None -class ServiceReference(OdpModel): - service_id: str - service_origin: str - name: str | None = None +class Publisher(OdpModel): + publisher_id: str + name: str + website_url: str class CollectionSummary(OdpModel): @@ -125,7 +125,7 @@ class ServiceResult(OdpModel): type: Literal["service"] service: DirectoryIndexedService indexed_at: str - available_through: ServiceReference | None = None + publisher: Publisher | None = None class CollectionResult(OdpModel): diff --git a/src/offering_protocol/directory/results.py b/src/offering_protocol/directory/results.py index 3f28011..f97cc20 100644 --- a/src/offering_protocol/directory/results.py +++ b/src/offering_protocol/directory/results.py @@ -4,6 +4,7 @@ import json from datetime import datetime +from urllib.parse import urlsplit from pydantic import JsonValue, TypeAdapter @@ -90,12 +91,13 @@ def _native_service(service: dict[str, JsonValue]) -> None: 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"]) - _text(reference, "service_id", 128) - _origin(reference) - if "name" in reference: - _text(reference, "name", 128) + if raw.get("publisher") is not None: + publisher = _OBJECT.validate_python(raw["publisher"]) + _text(publisher, "publisher_id", 128) + _text(publisher, "name", 128) + website = urlsplit(_text(publisher, "website_url", 512)) + if website.scheme != "https" or not website.hostname or website.username is not None: + raise ValueError("Publisher website must be an HTTPS URL without credentials") return ServiceResult.model_validate(raw) collection = _OBJECT.validate_python(raw.get("collection")) if not is_local_resource_identifier(_text(collection, "id", 128)): diff --git a/tests/test_directory_mixed.py b/tests/test_directory_mixed.py index 8d56cf4..53f2b30 100644 --- a/tests/test_directory_mixed.py +++ b/tests/test_directory_mixed.py @@ -69,10 +69,11 @@ def transport_for(body: object) -> QueueTransport: @pytest.mark.asyncio async def test_mixed_results_metadata_unknown_types_and_requests() -> None: first = item("service") - first["available_through"] = { - "service_id": "platform", - "service_origin": "https://platform.example", + first["publisher"] = { + "publisher_id": "platform", + "website_url": "https://platform.example/catalog", "name": "Platform", + "extra": True, } first["extra"] = True future: dict[str, JsonValue] = {"type": "future", "nested": {"value": 42}} @@ -93,8 +94,10 @@ async def test_mixed_results_metadata_unknown_types_and_requests() -> None: first_result, second, third = result.items assert isinstance(first_result, ServiceResult) assert first_result.service.service_id == "parent" - assert first_result.available_through is not None - assert first_result.available_through.name == "Platform" + assert first_result.publisher is not None + assert first_result.publisher.name == "Platform" + assert first_result.publisher.additional["extra"] is True + assert first_result.publisher.website_url == "https://platform.example/catalog" assert first_result.additional["extra"] is True assert len(first_result.service.operations) == 2 assert first_result.service.protocols is not None @@ -177,9 +180,10 @@ async def test_optional_members_and_unverified_metadata() -> None: parent.pop("protocols") parent["http"] = {"endpoint_base": "https://untrusted.example/"} first["service"] = parent - first["available_through"] = { - "service_id": "platform", - "service_origin": "https://platform.example", + first["publisher"] = { + "publisher_id": "platform", + "website_url": "https://platform.example/catalog", + "name": "Platform", } empty_description = item() empty_description["collection"] = {"id": "Weather", "name": "Weather", "description": ""} @@ -195,12 +199,42 @@ async def test_optional_members_and_unverified_metadata() -> None: assert isinstance(parsed, ServiceResult) 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 + assert parsed.publisher is not None and parsed.publisher.name == "Platform" parent = parsed.service.to_dict() parent.pop("service_id") assert DirectoryService.model_validate(parent).service_id is None +@pytest.mark.asyncio +async def test_publisher_omission_null_and_additional_fields() -> None: + first = item("service") + first["publisher"] = None + first["available_through"] = {"service_id": "legacy"} + first["future_metadata"] = {"arbitrary": True} + result = await DirectoryClient( + transport=transport_for({"items": [first, item("service")]}) + ).search(ResourceSearchRequest()) + assert not result.issues + assert len(result.items) == 2 + parsed = result.items[0] + assert isinstance(parsed, ServiceResult) and parsed.publisher is None + assert parsed.additional["available_through"] == {"service_id": "legacy"} + assert parsed.additional["future_metadata"] == {"arbitrary": True} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "website", ["http://platform.example", "https://user@platform.example", "https:///missing"] +) +async def test_publisher_website_validation(website: str) -> None: + first = item("service") + first["publisher"] = {"publisher_id": "gateway", "name": "Gateway", "website_url": website} + result = await DirectoryClient( + transport=transport_for({"items": [first, item("service")]}) + ).search(ResourceSearchRequest()) + assert len(result.issues) == 1 and len(result.items) == 1 + + @pytest.mark.asyncio @pytest.mark.parametrize( "body",