Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/offering_protocol/directory/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@
OperationFilter,
PaymentFilter,
PaymentOptionFacetValue,
Publisher,
ResourceSearchRequest,
SearchPage,
SearchRequest,
SearchResponse,
ServiceFilters,
ServiceIssue,
ServiceReference,
ServiceResult,
SuggestionRequest,
UnknownResult,
Expand Down Expand Up @@ -60,13 +60,13 @@
"OperationFilter",
"PaymentFilter",
"PaymentOptionFacetValue",
"Publisher",
"ResourceSearchRequest",
"SearchPage",
"SearchRequest",
"SearchResponse",
"ServiceFilters",
"ServiceIssue",
"ServiceReference",
"ServiceResult",
"SuggestionRequest",
"Transport",
Expand Down
10 changes: 5 additions & 5 deletions src/offering_protocol/directory/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down
14 changes: 8 additions & 6 deletions src/offering_protocol/directory/results.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import json
from datetime import datetime
from urllib.parse import urlsplit

from pydantic import JsonValue, TypeAdapter

Expand Down Expand Up @@ -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)):
Expand Down
52 changes: 43 additions & 9 deletions tests/test_directory_mixed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand All @@ -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
Expand Down Expand Up @@ -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": ""}
Expand All @@ -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",
Expand Down
Loading