From 1ebfff4efe8794190805db3a0aaf031e15950baf Mon Sep 17 00:00:00 2001 From: Nas Kavian Date: Wed, 23 Sep 2026 15:30:31 -0400 Subject: [PATCH] feat(directory): support source-aware directory discovery --- README.md | 5 +- examples/README.md | 5 +- .../odp/examples/DirectoryDiscovery.java | 5 + odp-agent/README.md | 6 +- odp-directory/README.md | 54 +++- .../odp/directory/DirectoryModels.java | 38 ++- .../odp/directory/DirectoryOrigins.java | 2 +- .../odp/directory/DirectoryResults.java | 37 ++- .../odp/directory/DirectorySources.java | 122 ++++++++ .../odp/directory/DirectoryResultsTest.java | 1 + .../odp/directory/DirectorySourcesTest.java | 267 ++++++++++++++++++ .../odp/directory/DirectoryTransportTest.java | 24 ++ .../offeringprotocol/example/Consumer.java | 13 +- 13 files changed, 550 insertions(+), 29 deletions(-) create mode 100644 odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectorySources.java create mode 100644 odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectorySourcesTest.java diff --git a/README.md b/README.md index 6bdacd7..896bd86 100644 --- a/README.md +++ b/README.md @@ -105,8 +105,11 @@ every ODP module to one project unless they actually implement multiple roles. ## Agent quick start For mixed Service/Collection discovery, use `DirectoryClient.search` and `continueSearch`. +Results identify their exact discovery document through `service().source()`. Mixed search and +suggestions support `filters.sources` for ODP and OpenAPI; only ODP sources can be passed to +ODP Agent operations. Imported Collections are Directory groups, not ODP Collection targets. `suggest` returns matching target names. The existing `searchServices`, `continueSearchServices` -and `suggestServices` remain available for Service-only discovery. See the +and `suggestServices` remain available for native ODP Service-only discovery. See the [Directory guide](./odp-directory/README.md) for result types, facets, attribution and the 100-result mixed-search cap. Each Java search call returns one response; it does not traverse continuations automatically. diff --git a/examples/README.md b/examples/README.md index eac58da..889ae9c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -86,8 +86,9 @@ Full Offering agent-guide: [`DirectoryDiscovery.java`](./src/main/java/org/offeringprotocol/odp/examples/DirectoryDiscovery.java) uses the real Directory API rather than `MockDirectory`. It requests up to five mixed results, prints Service and Collection names, reports unusable items, and retrieves full Collection details -only after the owning Service advertises anonymous retrieval. It does not enroll, pay or execute -Actions. Unknown result types are reported without being treated as Services. +only for ODP sources whose Service advertises anonymous retrieval. For imported Collections it +prints the exact discovery document URL without calling ODP endpoints. It does not enroll, pay +or execute Actions. Unknown result types are reported without being treated as Services. ```sh ./mvnw -q -DskipTests install diff --git a/examples/src/main/java/org/offeringprotocol/odp/examples/DirectoryDiscovery.java b/examples/src/main/java/org/offeringprotocol/odp/examples/DirectoryDiscovery.java index 9b9f675..ce6e69a 100644 --- a/examples/src/main/java/org/offeringprotocol/odp/examples/DirectoryDiscovery.java +++ b/examples/src/main/java/org/offeringprotocol/odp/examples/DirectoryDiscovery.java @@ -31,11 +31,16 @@ public static void main(String[] arguments) { print( "Service", service.service().name() + " — " + service.service().serviceOrigin()); + print("Discovery document", service.service().source().url()); } else if (result instanceof DirectoryModels.CollectionResult collection) { print( "Collection", collection.collection().name() + " — " + collection.service().serviceOrigin()); + if (!"odp".equals(collection.service().source().type())) { + print("Discovery document", collection.service().source().url()); + continue; + } OdpServiceClient client = OdpServiceClient.create(URI.create(collection.service().serviceOrigin())); boolean anonymous = client.inspection().document().operations().stream() diff --git a/odp-agent/README.md b/odp-agent/README.md index 969ba89..bf7290e 100644 --- a/odp-agent/README.md +++ b/odp-agent/README.md @@ -52,8 +52,10 @@ OdpAgent agent = new OdpAgent( ## Inspect one Service For mixed Service/Collection discovery, call `DirectoryClient.search`. A `CollectionResult` -contains its owning Service origin and remote Collection ID. Inspect that Service and use -`getCollection` to retrieve current details. `OdpAgent.searchOfferings` remains Service-only; +contains its owning Service and Collection ID. When `service().source().type()` is `"odp"`, +inspect that Service and use `getCollection` to retrieve current details. OpenAPI and unknown +source types must not be passed to ODP operations; their Collection IDs identify Directory +groups. `OdpAgent.searchOfferings` remains native ODP Service-only; it does not treat Collection results as separate Services. See the [Directory guide](../odp-directory/README.md#search-services-and-collections) for the mixed API. diff --git a/odp-directory/README.md b/odp-directory/README.md index b363a60..244b669 100644 --- a/odp-directory/README.md +++ b/odp-directory/README.md @@ -3,8 +3,10 @@ The official Java client for discovering indexed Services and submitted Collections through the canonical Directory. It does not crawl catalogs or index Offerings. -After directory discovery, an Agent inspects each candidate's live ODP document and queries the -Service's Collections and Offerings with [`odp-agent`](../odp-agent/README.md). +Mixed search includes native ODP Services and imported OpenAPI Services. For a result whose +`service().source().type()` is `"odp"`, an Agent can inspect its live ODP document and query its +Collections and Offerings with [`odp-agent`](../odp-agent/README.md). The Directory module does +not fetch or execute OpenAPI documents. ## Install @@ -36,10 +38,13 @@ for (DirectoryModels.Result result : response.items()) { The fourth request argument is an optional list of `"service"` and/or `"collection"`; null selects both. Explicit lists must be nonempty and distinct. Filters use the owning Service's metadata. -A Collection's identity is its owning Service origin plus its case-sensitive `collection().id()`. -Inspect that Service's live document and use `OdpServiceClient.getCollection` to retrieve current -details. `indexedAt()` on the result records Collection freshness, while `service().indexedAt()` -records the parent's freshness. `service().serviceId()` identifies the local Directory Service. +A Collection's identity is its owning `service().serviceId()` plus its case-sensitive +`collection().id()`. Multiple document URLs can share the same API origin. For an ODP source, +inspect that Service's live document and use `OdpServiceClient.getCollection` to retrieve current +details. An imported OpenAPI Collection is a Directory presentation group, not an ODP +`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 is its owning `service()`. @@ -60,10 +65,41 @@ the Directory landing page. See the [runnable canonical discovery example](../examples/README.md#canonical-directory-discovery). +## Source documents and filters + +Every known mixed result carries a `service().source()` with: + +- `type()`: `"odp"`, `"openapi"`, or an unknown future format. Unknown formats remain readable; + they do not authorize ODP calls. +- `url()`: the exact primary document URL, including its path and query. It can be on a different + origin from `serviceOrigin()`. Use this value for document discovery rather than reconstructing + a URL from the API origin. +- `x402Discovery()`: whether supporting fixed-path x402 discovery was detected. This is not + proof that an endpoint accepts payment. Advertised protocol evidence remains in `protocols()`. + +Imported results require a name, Service identifier, API origin, source and indexing timestamp. +Description and language can be absent; unavailable list fields are exposed as empty lists. +Imported results do not expose native ODP operations. Source fields not recognized by this SDK +are retained in `source().additional()`. + +```java +DirectoryModels.ServiceFilters filters = new DirectoryModels.ServiceFilters( + null, null, null, null, null, List.of("openapi")); +DirectoryModels.SearchResponse response = directory.search( + new DirectoryModels.ResourceSearchRequest("weather", filters, 25, null)); +List names = directory.suggest("we", 10, filters); +``` + +The final filter argument, `sources`, accepts one or both distinct lowercase values `"odp"` and +`"openapi"`. Null omits the filter; an explicit empty list is invalid. Values are alternatives, +combined with the other filter categories using AND. Collections inherit their Service's source. +`searchServices` also accepts this filter but remains ODP-only, so an OpenAPI-only filter produces +no native Service matches. Its results do not include `source()` metadata. + ## Search only Services -`DirectoryClient.create()` uses the fixed production directory. Search accepts natural-language -text, deterministic filters, or both. +`searchServices` returns native ODP Services only. `DirectoryClient.create()` uses the fixed +production directory. Search accepts natural-language text, deterministic filters, or both. ```java import java.util.List; @@ -101,7 +137,7 @@ downloading a global vocabulary. Compatible results may advertise protocol names unknown to this library. The client filters those descriptors and preserves recognized enrollment, payment, and trust descriptors, including TAP. -## What the client checks in a result +## What the client checks in a native Service-only result A directory result is a third party's description of somebody else's Service, and an Agent connects to whatever `service_origin` names, so each result is checked before it is handed over: 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 c5f05f1..10486a1 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 @@ -10,6 +10,7 @@ import java.util.List; import java.util.Map; import org.offeringprotocol.odp.core.AuthenticationRequirement; +import org.offeringprotocol.odp.core.OdpJson; import org.offeringprotocol.odp.core.OdpJsonNode; import org.offeringprotocol.odp.core.OdpOperation; import org.offeringprotocol.odp.core.OperationDescriptor; @@ -117,7 +118,17 @@ public record ServiceFilters( List keywords, List operations, List payments, - List trust) { + List trust, + List sources) { + public ServiceFilters( + List enrollment, + List keywords, + List operations, + List payments, + List trust) { + this(enrollment, keywords, operations, payments, trust, null); + } + public ServiceFilters( List enrollment, List keywords, @@ -127,6 +138,15 @@ public ServiceFilters( } public ServiceFilters { + if (sources != null + && (sources.isEmpty() + || sources.size() > 2 + || sources.stream().distinct().count() != sources.size() + || sources.stream() + .anyMatch(source -> !"odp".equals(source) && !"openapi".equals(source)))) { + throw new IllegalArgumentException("sources must contain distinct odp or openapi values"); + } + sources = sources == null ? List.of() : List.copyOf(sources); if (trust != null && (trust.size() != 1 || trust.get(0) == null @@ -175,6 +195,22 @@ public String serviceId() { OdpJsonNode value = additional.get("service_id"); return value == null ? null : value.asString(); } + + /** The discovery document for mixed search results; absent from native Service-only results. */ + public Source source() { + OdpJsonNode value = additional.get("source"); + return value == null ? null : OdpJson.treeToValue(value, Source.class); + } + } + + public record Source( + String type, + String url, + @JsonProperty("x402_discovery") boolean x402Discovery, + @JsonAnySetter @JsonAnyGetter Map additional) { + public Source { + additional = additional == null ? Map.of() : Map.copyOf(additional); + } } public record Facet(T value, long count) {} diff --git a/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryOrigins.java b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryOrigins.java index e993aa1..262e7c8 100644 --- a/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryOrigins.java +++ b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryOrigins.java @@ -68,7 +68,7 @@ static void requireServiceOrigin(String value) { requirePublicHost(origin.getHost()); } - private static void requirePublicHost(String host) { + static void requirePublicHost(String host) { String name = host.toLowerCase(Locale.ROOT); if ("localhost".equals(name) || name.endsWith(".localhost")) { throw new IllegalArgumentException("Directory result service_origin must name a public host"); 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 31d6c06..f64b603 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 @@ -14,6 +14,7 @@ import org.offeringprotocol.odp.core.ServiceDocument; final class DirectoryResults { + private static final String SOURCE_ODP = "odp"; private static final String FIELD_FACETS = "facets"; private static final String FIELD_NEXT = "next"; private static final String FIELD_SERVICE = "service"; @@ -64,17 +65,12 @@ private static DirectoryModels.Result result(OdpJsonNode value) { text(serviceNode, FIELD_SERVICE_ID, 128); origin(serviceNode, FIELD_SERVICE_ORIGIN); instant(serviceNode, FIELD_INDEXED_AT); + DirectoryModels.Source source = DirectorySources.read(serviceNode.get("source")); serviceNode.remove(List.of("branding", "http", "mcp", "odp_version", "payment_origins", "search_capabilities")); - OdpJsonNode document = serviceNode.deepCopy(); - document.remove(List.of(FIELD_SERVICE_ID, FIELD_SERVICE_ORIGIN, FIELD_INDEXED_AT)); - document.put("odp_version", "1.0"); - document.putObject("http").put("endpoint_base", "/"); - ServiceDocument parsed = OdpJson.parseAgentServiceDocument(document.toString()); - serviceNode.set("operations", OdpJson.valueToTree(parsed.operations())); - if (parsed.protocols() == null) { - serviceNode.remove("protocols"); + if (SOURCE_ODP.equals(source.type())) { + nativeService(serviceNode); } else { - serviceNode.set("protocols", OdpJson.valueToTree(parsed.protocols())); + DirectorySources.importedService(serviceNode); } DirectoryModels.Service service = OdpJson.treeToValue(serviceNode, DirectoryModels.Service.class); Instant indexedAt = instant(value, FIELD_INDEXED_AT); @@ -87,6 +83,25 @@ private static DirectoryModels.Result result(OdpJsonNode value) { reference, additional(value, Set.of("type", FIELD_SERVICE, FIELD_INDEXED_AT, FIELD_AVAILABLE_THROUGH))); } + return collection(value, service, indexedAt); + } + + private static void nativeService(OdpJsonNode serviceNode) { + OdpJsonNode document = serviceNode.deepCopy(); + document.remove(List.of(FIELD_SERVICE_ID, FIELD_SERVICE_ORIGIN, FIELD_INDEXED_AT, "source")); + document.put("odp_version", "1.0"); + document.putObject("http").put("endpoint_base", "/"); + ServiceDocument parsed = OdpJson.parseAgentServiceDocument(document.toString()); + serviceNode.set("operations", OdpJson.valueToTree(parsed.operations())); + if (parsed.protocols() == null) { + serviceNode.remove("protocols"); + } else { + serviceNode.set("protocols", OdpJson.valueToTree(parsed.protocols())); + } + } + + private static DirectoryModels.CollectionResult collection( + OdpJsonNode value, DirectoryModels.Service service, Instant indexedAt) { OdpJsonNode collection = object(value.get(FIELD_COLLECTION), FIELD_COLLECTION); String id = text(collection, "id", 128); if (!OdpUris.isLocalResourceIdentifier(id)) { @@ -137,14 +152,14 @@ private static Instant instant(OdpJsonNode value, String name) { } } - private static OdpJsonNode object(OdpJsonNode value, String name) { + static OdpJsonNode object(OdpJsonNode value, String name) { if (value == null || !value.isObject()) { throw new IllegalArgumentException(name + " must be an object"); } return value; } - private static String text(OdpJsonNode value, String name, int maximum) { + static String text(OdpJsonNode value, String name, int maximum) { OdpJsonNode node = value.get(name); if (node == null || !node.isString() diff --git a/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectorySources.java b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectorySources.java new file mode 100644 index 0000000..b293f22 --- /dev/null +++ b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectorySources.java @@ -0,0 +1,122 @@ +package org.offeringprotocol.odp.directory; + +import java.net.URI; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.offeringprotocol.odp.core.OdpJson; +import org.offeringprotocol.odp.core.OdpJsonNode; +import org.offeringprotocol.odp.core.PaymentOption; + +final class DirectorySources { + private static final String FIELD_PAYMENTS = "payments"; + private static final String FIELD_PROTOCOLS = "protocols"; + private static final int NAME_ONLY_FIELDS = 1; + + private DirectorySources() {} + + static DirectoryModels.Source read(OdpJsonNode value) { + DirectoryResults.object(value, "source"); + DirectoryResults.text(value, "type", 128); + URI url = URI.create(DirectoryResults.text(value, "url", 2048)); + if (!"https".equalsIgnoreCase(url.getScheme()) + || url.getHost() == null + || url.getRawUserInfo() != null + || url.getRawFragment() != null + || url.getPort() > 65535) { + throw new IllegalArgumentException( + "source.url must be an HTTPS document URL without credentials or fragment"); + } + DirectoryOrigins.requirePublicHost(url.getHost()); + OdpJsonNode discovery = value.get("x402_discovery"); + if (discovery == null || !("true".equals(discovery.toString()) || "false".equals(discovery.toString()))) { + throw new IllegalArgumentException("source.x402_discovery must be a boolean"); + } + return OdpJson.treeToValue(value, DirectoryModels.Source.class); + } + + static void importedService(OdpJsonNode service) { + DirectoryResults.text(service, "name", 128); + for (String field : + List.of("description", "language", "documentation_url", "status_url", "support_url", "website_url")) { + if (service.has(field) && !service.get(field).isString()) { + throw new IllegalArgumentException(field + " must be a string"); + } + } + for (String field : List.of("keywords", "localizations")) { + if (service.has(field)) { + strings(service.get(field), field); + } + } + service.remove("operations"); + if (service.has(FIELD_PROTOCOLS)) { + OdpJsonNode protocols = DirectoryResults.object(service.get(FIELD_PROTOCOLS), FIELD_PROTOCOLS); + OdpJsonNode retained = OdpJson.parseTree("{}"); + descriptors(protocols, retained, "enrollment", Set.of("aep")); + descriptors(protocols, retained, FIELD_PAYMENTS, Set.of("mpp", "x402")); + descriptors(protocols, retained, "trust", Set.of("tap")); + service.set(FIELD_PROTOCOLS, retained); + } + } + + private static void descriptors(OdpJsonNode protocols, OdpJsonNode retained, String category, Set known) { + if (!protocols.has(category)) { + return; + } + OdpJsonNode values = protocols.get(category); + if (!values.isArray() || values.isEmpty()) { + throw new IllegalArgumentException("protocols." + category + " must be a nonempty array"); + } + List selected = new ArrayList<>(); + Set names = new HashSet<>(); + for (OdpJsonNode descriptor : values) { + DirectoryResults.object(descriptor, category); + String name = DirectoryResults.text(descriptor, "name", 128); + if (!known.contains(name)) { + continue; + } + if (!names.add(name)) { + throw new IllegalArgumentException("Duplicate " + category + " descriptor"); + } + if (FIELD_PAYMENTS.equals(category)) { + payment(descriptor); + } else if (descriptor.size() != NAME_ONLY_FIELDS) { + throw new IllegalArgumentException("Unexpected " + category + " descriptor field"); + } + selected.add(descriptor); + } + if (!selected.isEmpty()) { + retained.set(category, OdpJson.valueToTree(selected)); + } + } + + private static void payment(OdpJsonNode descriptor) { + String authentication = DirectoryResults.text(descriptor, "authentication", 128); + if (!("required".equals(authentication) || "not-required".equals(authentication)) + || !Set.of("name", "authentication", "options").containsAll(descriptor.fieldNames())) { + throw new IllegalArgumentException("Invalid payment descriptor"); + } + if (descriptor.has("options")) { + List options = strings(descriptor.get("options"), "payment options"); + if (options.isEmpty() || options.size() > 16 || new HashSet<>(options).size() != options.size()) { + throw new IllegalArgumentException("Invalid payment options"); + } + options.forEach(PaymentOption::fromValue); + } + } + + private static List strings(OdpJsonNode value, String name) { + if (!value.isArray()) { + throw new IllegalArgumentException(name + " must be an array of strings"); + } + List values = new ArrayList<>(); + for (OdpJsonNode item : value) { + if (!item.isString()) { + throw new IllegalArgumentException(name + " must contain only strings"); + } + values.add(item.asString()); + } + return values; + } +} 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 ab72b89..a4e2264 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 @@ -18,6 +18,7 @@ class DirectoryResultsTest { static final String SERVICE = """ {"service_id":"ca0304cc-ab28-43e5-af94-7bdf11b40c6e", "service_origin":"https://api.example.com","name":"Example Service", + "source":{"type":"odp","url":"https://api.example.com/.well-known/odp","x402_discovery":false}, "description":"Data services.","language":"en","localizations":["en"], "operations":[{"name":"get-offering","authentication":"not-required"}, {"name":"list-offerings","authentication":"not-required"}], diff --git a/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectorySourcesTest.java b/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectorySourcesTest.java new file mode 100644 index 0000000..baddd08 --- /dev/null +++ b/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectorySourcesTest.java @@ -0,0 +1,267 @@ +package org.offeringprotocol.odp.directory; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; +import org.offeringprotocol.odp.core.OdpJson; +import org.offeringprotocol.odp.core.OdpJsonNode; +import org.offeringprotocol.odp.core.PaymentOption; + +class DirectorySourcesTest { + private static final String DOCUMENT_URL = "https://documents.example/specs/api.json?version=3&key=a%2Fb"; + + private static OdpJsonNode imported(String type) { + OdpJsonNode result = DirectoryResultsTest.result(type); + OdpJsonNode service = result.get("service"); + service.remove(List.of("description", "language", "localizations", "keywords", "operations", "protocols")); + service.get("source").put("type", "openapi"); + service.get("source").put("url", DOCUMENT_URL); + return result; + } + + @Test + void readsOptionalImportedMetadataAndExactSourceWithoutInventingOdpCapabilities() { + for (String type : List.of("openapi", "future-format")) { + OdpJsonNode result = imported("service"); + OdpJsonNode service = result.get("service"); + service.get("source").put("type", type); + service.get("source").put("extra", "retained"); + service.get("source").set("x402_discovery", OdpJson.parseTree("true")); + for (String unverified : List.of( + "http", "branding", "mcp", "operations", "odp_version", "payment_origins", "search_capabilities")) { + service.put(unverified, "not authoritative"); + } + var response = DirectoryResults.decode(DirectoryResultsTest.response(result)); + assertTrue(response.issues().isEmpty(), response.issues().toString()); + var parsed = assertInstanceOf( + DirectoryModels.ServiceResult.class, + response.items().get(0)) + .service(); + assertEquals(type, parsed.source().type()); + assertEquals(DOCUMENT_URL, parsed.source().url()); + assertTrue(parsed.source().x402Discovery()); + assertEquals("retained", parsed.source().additional().get("extra").asString()); + assertNull(parsed.description()); + assertNull(parsed.language()); + assertNull(parsed.protocols()); + assertTrue(parsed.operations().isEmpty()); + assertTrue(parsed.localizations().isEmpty()); + assertTrue(parsed.keywords().isEmpty()); + assertEquals( + java.util.Set.of("service_id", "source"), + parsed.additional().keySet()); + assertThrows( + UnsupportedOperationException.class, + () -> parsed.source().additional().clear()); + assertEquals( + DOCUMENT_URL, + OdpJson.parseTree(OdpJson.write(parsed)).at("/source/url").asString()); + } + } + + @Test + void retainsCollectionIdentityAndValidImportedMetadata() { + OdpJsonNode first = imported("collection"); + OdpJsonNode second = imported("collection"); + second.get("service").put("service_id", "another-document"); + second.get("service").get("source").put("url", "https://documents.example/other.json"); + first.get("service").put("description", ""); + first.get("service").put("language", "en"); + first.get("service").set("localizations", OdpJson.parseTree("[\"en\"]")); + first.get("service").set("keywords", OdpJson.parseTree("[\"weather\"]")); + for (String field : List.of("documentation_url", "status_url", "support_url", "website_url")) { + first.get("service").put(field, "https://example.com/" + field); + } + var response = DirectoryResults.decode(DirectoryResultsTest.response(first, second)); + assertTrue(response.issues().isEmpty()); + var one = assertInstanceOf( + DirectoryModels.CollectionResult.class, response.items().get(0)); + var two = assertInstanceOf( + DirectoryModels.CollectionResult.class, response.items().get(1)); + assertEquals(one.service().serviceOrigin(), two.service().serviceOrigin()); + assertEquals(one.collection().id(), two.collection().id()); + assertEquals("another-document", two.service().serviceId()); + assertEquals("", one.service().description()); + assertEquals(List.of("weather"), one.service().keywords()); + assertEquals(List.of("en"), one.service().localizations()); + assertEquals("https://example.com/website_url", one.service().websiteUrl()); + String exact = "HTTPS://Documents.Example:443/specs/api.json?version=3"; + second.get("service").get("source").put("url", exact); + var uppercase = DirectoryResults.decode(DirectoryResultsTest.response(second)); + assertTrue(uppercase.issues().isEmpty()); + assertEquals( + exact, + assertInstanceOf( + DirectoryModels.CollectionResult.class, + uppercase.items().get(0)) + .service() + .source() + .url()); + } + + @Test + void rejectsMalformedSourcesAndImportedFieldsPerItem() { + List> changes = new ArrayList<>(List.of( + service -> service.remove("source"), + service -> service.set("source", OdpJson.parseTree("null")), + service -> service.get("source").remove("type"), + service -> service.get("source").put("type", " "), + service -> service.get("source").remove("url"), + service -> service.get("source").remove("x402_discovery"), + service -> service.get("source").put("x402_discovery", "false"), + service -> service.get("source").set("x402_discovery", OdpJson.parseTree("null")), + service -> service.remove("name"))); + for (String url : List.of( + "http://example.com/api.json", + "/openapi.json", + "https://user:secret@example.com/spec", + "https://example.com/spec#part", + "https://localhost/spec", + "https:/spec", + "https://127.0.0.1/spec", + "https://example.com:70000/spec", + "https://[")) { + changes.add(service -> service.get("source").put("url", url)); + } + for (String field : List.of( + "description", + "language", + "documentation_url", + "status_url", + "support_url", + "website_url", + "localizations", + "keywords")) { + changes.add(service -> service.set(field, OdpJson.parseTree("null"))); + changes.add(service -> service.set(field, OdpJson.parseTree("42"))); + } + changes.add(service -> service.set("keywords", OdpJson.parseTree("[null]"))); + changes.add(service -> service.set("localizations", OdpJson.parseTree("[1]"))); + for (var change : changes) { + OdpJsonNode invalid = imported("service"); + change.accept(invalid.get("service")); + var response = DirectoryResults.decode(DirectoryResultsTest.response(invalid, imported("service"))); + assertEquals(1, response.items().size(), invalid.toString()); + assertEquals(1, response.issues().size(), invalid.toString()); + assertEquals(0, response.issues().get(0).index()); + } + } + + @Test + void preservesOnlyValidatedRecognizedProtocolEvidence() { + OdpJsonNode result = imported("service"); + result.get("service").set("protocols", OdpJson.parseTree(""" + {"enrollment":[{"name":"future"},{"name":"aep"}], + "payments":[{"name":"x402","authentication":"required","options":["base"]}, + {"name":"mpp","authentication":"not-required"},{"name":"future"}], + "trust":[{"name":"tap"},{"name":"future"}],"future":true} + """)); + var response = DirectoryResults.decode(DirectoryResultsTest.response(result)); + assertTrue(response.issues().isEmpty(), response.issues().toString()); + var protocols = assertInstanceOf( + DirectoryModels.ServiceResult.class, response.items().get(0)) + .service() + .protocols(); + assertEquals(1, protocols.enrollment().size()); + assertEquals(2, protocols.payments().size()); + assertEquals(List.of(PaymentOption.BASE), protocols.payments().get(0).options()); + assertEquals(1, protocols.trust().size()); + for (String accepted : List.of( + "{}", + "{\"payments\":[{\"name\":\"future\"}]}", + "{\"payments\":[{\"name\":\"x402\",\"authentication\":\"required\"}]}")) { + result.get("service").set("protocols", OdpJson.parseTree(accepted)); + assertTrue( + DirectoryResults.decode(DirectoryResultsTest.response(result)) + .issues() + .isEmpty(), + accepted); + } + for (String invalid : List.of( + "null", + "[]", + "{\"trust\":null}", + "{\"trust\":[]}", + "{\"trust\":[null]}", + "{\"trust\":[{}]}", + "{\"trust\":[{\"name\":\"tap\",\"extra\":true}]}", + "{\"trust\":[{\"name\":\"tap\"},{\"name\":\"tap\"}]}")) { + result.get("service").set("protocols", OdpJson.parseTree(invalid)); + assertEquals( + 1, + DirectoryResults.decode(DirectoryResultsTest.response(result)) + .issues() + .size(), + invalid); + } + for (String invalid : List.of( + "{}", + "{\"authentication\":\"optional\"}", + "{\"authentication\":null}", + "{\"authentication\":\"not-required\",\"extra\":true}", + "{\"authentication\":\"required\",\"options\":[]}", + "{\"authentication\":\"required\",\"options\":[\"base\",\"base\"]}", + "{\"authentication\":\"required\",\"options\":" + + OdpJson.write(java.util.Collections.nCopies(17, "base")) + "}", + "{\"authentication\":\"required\",\"options\":[\"unknown\"]}")) { + OdpJsonNode payment = OdpJson.parseTree(invalid).put("name", "x402"); + result.get("service").set("protocols", OdpJson.parseTree("{\"payments\":[" + payment + "]}")); + assertEquals( + 1, + DirectoryResults.decode(DirectoryResultsTest.response(result)) + .issues() + .size(), + invalid); + } + } + + @Test + void retainsNativeValidationAndSnapshotsSourceFilters() { + OdpJsonNode nativeResult = DirectoryResultsTest.result("service"); + nativeResult.get("service").remove("language"); + assertEquals( + 1, + DirectoryResults.decode(DirectoryResultsTest.response(nativeResult)) + .issues() + .size()); + OdpJsonNode imported = imported("service").get("service"); + assertEquals( + 1, + DirectoryClient.decodeSearchPage("{\"items\":[" + imported + "]}") + .issues() + .size()); + OdpJsonNode nativeService = OdpJson.parseTree(DirectoryResultsTest.SERVICE); + nativeService.remove("source"); + assertNull(DirectoryClient.decodeSearchPage("{\"items\":[" + nativeService + "]}") + .items() + .get(0) + .source()); + List sources = new ArrayList<>(List.of("odp", "openapi")); + var filters = new DirectoryModels.ServiceFilters(null, null, null, null, null, sources); + sources.clear(); + assertEquals(List.of("odp", "openapi"), filters.sources()); + assertEquals("{\"sources\":[\"odp\",\"openapi\"]}", OdpJson.write(filters)); + assertThrows( + UnsupportedOperationException.class, () -> filters.sources().clear()); + assertEquals("{}", OdpJson.write(new DirectoryModels.ServiceFilters(null, null, null, null))); + for (List invalid : List.of( + List.of(), + List.of("ODP"), + List.of("future"), + List.of("odp", "odp"), + Arrays.asList("odp", null), + List.of("odp", "openapi", "future"))) { + assertThrows( + IllegalArgumentException.class, + () -> new DirectoryModels.ServiceFilters(null, null, null, null, null, invalid)); + } + } +} diff --git a/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryTransportTest.java b/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryTransportTest.java index b0a88be..f8571e1 100644 --- a/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryTransportTest.java +++ b/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryTransportTest.java @@ -105,6 +105,30 @@ void retainsTransportBoundariesAndRequestErrors() { assertThrows(IllegalStateException.class, () -> directory.search(request)); } + @Test + void sendsSourceFiltersOnMixedNativeAndSuggestionRequests() { + StubClient transport = new StubClient(); + DirectoryClient directory = DirectoryClient.create(DirectoryEnvironment.PRODUCTION, transport); + var filters = + new DirectoryModels.ServiceFilters(null, List.of("weather"), null, null, null, List.of("openapi")); + transport.add(200, "{\"items\":[]}", Map.of()); + directory.search(new DirectoryModels.ResourceSearchRequest("forecast", filters, 10, List.of("collection"))); + transport.add(200, "{\"items\":[]}", Map.of()); + directory.searchServices(new DirectoryModels.SearchRequest("forecast", filters, 10)); + transport.add(200, "{\"items\":[\"Weather\"]}", Map.of()); + assertEquals(List.of("Weather"), directory.suggest("we", 10, filters)); + for (String body : transport.bodies) { + var encoded = org.offeringprotocol.odp.core.OdpJson.parseTree(body); + assertEquals("[\"openapi\"]", encoded.at("/filters/sources").toString()); + assertEquals("[\"weather\"]", encoded.at("/filters/keywords").toString()); + } + assertEquals( + List.of("/v1/directory/search", "/v1/services/search", "/v1/directory/suggestions"), + transport.requests.stream() + .map(request -> request.uri().getPath()) + .toList()); + } + @Test void sendsSuggestionFiltersInJson() { StubClient transport = new StubClient(); diff --git a/testdata/consumer/src/main/java/org/offeringprotocol/example/Consumer.java b/testdata/consumer/src/main/java/org/offeringprotocol/example/Consumer.java index 442b8ea..771a640 100644 --- a/testdata/consumer/src/main/java/org/offeringprotocol/example/Consumer.java +++ b/testdata/consumer/src/main/java/org/offeringprotocol/example/Consumer.java @@ -17,11 +17,20 @@ public static void main(String[] args) { DirectoryClient directory = DirectoryClient.create(); OdpAgent agent = new OdpAgent(directory); DirectoryModels.SearchRequest request = new DirectoryModels.SearchRequest("plants", null, 10); + DirectoryModels.ServiceFilters filters = new DirectoryModels.ServiceFilters( + null, null, null, null, null, List.of("openapi")); DirectoryModels.ResourceSearchRequest mixed = new DirectoryModels.ResourceSearchRequest( - "weather", null, 10, List.of("service", "collection")); - if (!OdpJson.write(mixed).contains("\"types\":[\"service\",\"collection\"]")) { + "weather", filters, 10, List.of("service", "collection")); + if (!OdpJson.write(mixed).contains("\"types\":[\"service\",\"collection\"]") + || !OdpJson.write(mixed).contains("\"sources\":[\"openapi\"]")) { throw new IllegalStateException("Mixed Directory request encoding failed"); } + DirectoryModels.Source source = OdpJson.treeToValue(OdpJson.parseTree(""" + {"type":"openapi","url":"https://example.com/v1/openapi.json","x402_discovery":false} + """), DirectoryModels.Source.class); + if (!"openapi".equals(source.type()) || source.x402Discovery()) { + throw new IllegalStateException("Directory source decoding failed"); + } Offering offering = OdpJson.parseOffering(""" { "odp_version": "1.0",