From 2a1aed68dba6260943b8207b450e67e83cf39b02 Mon Sep 17 00:00:00 2001 From: Nas Kavian Date: Fri, 25 Sep 2026 22:03:46 -0400 Subject: [PATCH] feat(directory): replace service attribution with publishers --- odp-directory/README.md | 4 +- .../odp/directory/DirectoryModels.java | 7 ++-- .../odp/directory/DirectoryResults.java | 31 +++++++++------ .../odp/directory/DirectoryResultsTest.java | 38 ++++++++++++------- 4 files changed, 50 insertions(+), 30 deletions(-) diff --git a/odp-directory/README.md b/odp-directory/README.md index 244b669..21e13ae 100644 --- a/odp-directory/README.md +++ b/odp-directory/README.md @@ -45,7 +45,9 @@ details. An imported OpenAPI Collection is a Directory presentation group, not a `getCollection` target. `indexedAt()` on the result records Collection freshness, while `service().indexedAt()` records the parent's freshness. `service().serviceId()` identifies the local Directory Service. -For Service results, optional `availableThrough()` identifies a platform. Collection attribution +For Service results, optional `publisher()` provides `publisherId()`, `name()`, and `websiteUrl()` for display attribution. +The website is not a discovery or execution target. Omitted or null attribution is accepted; +additional response fields are retained. Collection attribution is its owning `service()`. Known types are validated; a malformed item is omitted and reported in `response.issues()` with diff --git a/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryModels.java b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryModels.java index 10486a1..20332af 100644 --- a/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryModels.java +++ b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryModels.java @@ -44,7 +44,7 @@ public sealed interface Result permits ServiceResult, CollectionResult, UnknownR } public record ServiceResult( - Service service, Instant indexedAt, ServiceReference availableThrough, Map additional) + Service service, Instant indexedAt, Publisher publisher, Map additional) implements Result { public ServiceResult { additional = Collections.unmodifiableMap(new LinkedHashMap<>(additional)); @@ -80,9 +80,8 @@ public OdpJsonNode raw() { } } - public record ServiceReference( - String serviceId, String serviceOrigin, String name, Map additional) { - public ServiceReference { + public record Publisher(String publisherId, String name, String websiteUrl, Map additional) { + public Publisher { additional = Collections.unmodifiableMap(new LinkedHashMap<>(additional)); } } diff --git a/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryResults.java b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryResults.java index f64b603..1f3d61d 100644 --- a/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryResults.java +++ b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryResults.java @@ -22,7 +22,7 @@ final class DirectoryResults { private static final String FIELD_SERVICE_ID = "service_id"; private static final String FIELD_SERVICE_ORIGIN = "service_origin"; private static final String FIELD_INDEXED_AT = "indexed_at"; - private static final String FIELD_AVAILABLE_THROUGH = "available_through"; + private static final String FIELD_PUBLISHER = "publisher"; private static final String FIELD_NAME = "name"; private DirectoryResults() {} @@ -75,13 +75,15 @@ private static DirectoryModels.Result result(OdpJsonNode value) { DirectoryModels.Service service = OdpJson.treeToValue(serviceNode, DirectoryModels.Service.class); Instant indexedAt = instant(value, FIELD_INDEXED_AT); if (FIELD_SERVICE.equals(type)) { - DirectoryModels.ServiceReference reference = - value.has(FIELD_AVAILABLE_THROUGH) ? reference(value.get(FIELD_AVAILABLE_THROUGH)) : null; + DirectoryModels.Publisher publisher = + value.has(FIELD_PUBLISHER) && !value.get(FIELD_PUBLISHER).isNull() + ? publisher(value.get(FIELD_PUBLISHER)) + : null; return new DirectoryModels.ServiceResult( service, indexedAt, - reference, - additional(value, Set.of("type", FIELD_SERVICE, FIELD_INDEXED_AT, FIELD_AVAILABLE_THROUGH))); + publisher, + additional(value, Set.of("type", FIELD_SERVICE, FIELD_INDEXED_AT, FIELD_PUBLISHER))); } return collection(value, service, indexedAt); } @@ -126,13 +128,18 @@ private static DirectoryModels.CollectionResult collection( additional(value, Set.of("type", FIELD_SERVICE, FIELD_INDEXED_AT, FIELD_COLLECTION))); } - private static DirectoryModels.ServiceReference reference(OdpJsonNode value) { - object(value, FIELD_AVAILABLE_THROUGH); - return new DirectoryModels.ServiceReference( - text(value, FIELD_SERVICE_ID, 128), - origin(value, FIELD_SERVICE_ORIGIN), - value.has(FIELD_NAME) ? text(value, FIELD_NAME, 128) : null, - additional(value, Set.of(FIELD_SERVICE_ID, FIELD_SERVICE_ORIGIN, FIELD_NAME))); + private static DirectoryModels.Publisher publisher(OdpJsonNode value) { + object(value, FIELD_PUBLISHER); + String website = text(value, "website_url", 512); + URI uri = URI.create(website); + if (!"https".equals(uri.getScheme()) || uri.getHost() == null || uri.getRawUserInfo() != null) { + throw new IllegalArgumentException("Publisher website must be an HTTPS URL without credentials"); + } + return new DirectoryModels.Publisher( + text(value, "publisher_id", 128), + text(value, FIELD_NAME, 128), + website, + additional(value, Set.of("publisher_id", "website_url", FIELD_NAME))); } private static String origin(OdpJsonNode value, String name) { diff --git a/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryResultsTest.java b/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryResultsTest.java index a4e2264..f43d75f 100644 --- a/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryResultsTest.java +++ b/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryResultsTest.java @@ -46,8 +46,8 @@ static String response(OdpJsonNode... items) { @Test void readsKnownAndUnknownResultsWithAttributionAndFreshness() { OdpJsonNode service = result("service"); - service.set("available_through", OdpJson.parseTree(""" - {"service_id":"platform","service_origin":"https://platform.example","name":"Platform","extra":true} + service.set("publisher", OdpJson.parseTree(""" + {"publisher_id":"platform","website_url":"https://platform.example/catalog","name":"Platform","extra":true} """)); service.put("extra", "retained"); OdpJsonNode collection = result("collection"); @@ -58,10 +58,10 @@ void readsKnownAndUnknownResultsWithAttributionAndFreshness() { var first = assertInstanceOf( DirectoryModels.ServiceResult.class, decoded.items().get(0)); assertEquals("service", first.type()); - assertEquals("Platform", first.availableThrough().name()); + assertEquals("Platform", first.publisher().name()); assertEquals("ca0304cc-ab28-43e5-af94-7bdf11b40c6e", first.service().serviceId()); assertEquals("retained", first.additional().get("extra").asString()); - assertTrue(first.availableThrough().additional().get("extra").asBoolean(false)); + assertTrue(first.publisher().additional().get("extra").asBoolean(false)); assertEquals(1, first.service().protocols().trust().size()); var second = assertInstanceOf( DirectoryModels.CollectionResult.class, decoded.items().get(1)); @@ -106,13 +106,14 @@ void reportsMalformedKnownEntriesWithoutLosingValidOnes() { assertEquals(0, decoded.issues().get(0).index()); } for (String reference : List.of( - "null", "{}", "false", - "{\"service_id\":\"x\",\"service_origin\":\"https://user@platform.example\"}", - "{\"service_id\":\"x\",\"service_origin\":\"https://platform.example\",\"name\":null}")) { + "{\"publisher_id\":\"x\",\"website_url\":\"https://user@platform.example\",\"name\":\"Platform\"}", + "{\"publisher_id\":\"x\",\"website_url\":\"http://platform.example\",\"name\":\"Platform\"}", + "{\"publisher_id\":\"x\",\"website_url\":\"https:///\",\"name\":\"Platform\"}", + "{\"publisher_id\":\"x\",\"website_url\":\"https://platform.example\",\"name\":null}")) { OdpJsonNode invalid = result("service"); - invalid.set("available_through", OdpJson.parseTree(reference)); + invalid.set("publisher", OdpJson.parseTree(reference)); assertEquals(1, DirectoryResults.decode(response(invalid)).issues().size()); } } @@ -127,21 +128,32 @@ void acceptsOptionalFieldsAndRemovesUnverifiedExecutionMetadata() { {"name":"list-offerings","authentication":"not-required"}, {"name":"future-operation","authentication":"not-required"}] """)); - service.set("available_through", OdpJson.parseTree(""" - {"service_id":"x","service_origin":"https://platform.example"} + service.set("publisher", OdpJson.parseTree(""" + {"publisher_id":"x","website_url":"https://platform.example","name":"Platform"} """)); var decoded = DirectoryResults.decode(response(service)); var item = assertInstanceOf( DirectoryModels.ServiceResult.class, decoded.items().get(0)); - assertNull(item.availableThrough().name()); + assertEquals("Platform", item.publisher().name()); + assertEquals("x", item.publisher().publisherId()); + assertEquals("https://platform.example", item.publisher().websiteUrl()); assertNull(item.service().protocols()); assertNull(item.service().additional().get("http")); assertEquals(2, item.service().operations().size()); - service.remove("available_through"); + service.set("publisher", OdpJson.parseTree("null")); + service.set("available_through", OdpJson.parseTree("{\"service_id\":\"legacy\"}")); + service.set("future_metadata", OdpJson.parseTree("{\"arbitrary\":true}")); + var withoutPublisher = assertInstanceOf( + DirectoryModels.ServiceResult.class, + DirectoryResults.decode(response(service)).items().get(0)); + assertNull(withoutPublisher.publisher()); + assertTrue(withoutPublisher.additional().containsKey("available_through")); + assertTrue(withoutPublisher.additional().containsKey("future_metadata")); + service.remove("publisher"); assertNull(assertInstanceOf( DirectoryModels.ServiceResult.class, DirectoryResults.decode(response(service)).items().get(0)) - .availableThrough()); + .publisher()); for (boolean omit : List.of(true, false)) { OdpJsonNode collection = result("collection"); if (omit) {