diff --git a/README.md b/README.md index 28eb528..2c66bab 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,13 @@ 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`. +`suggest` returns matching target names. The existing `searchServices`, `continueSearchServices` +and `suggestServices` remain available for 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. + `OdpAgent` performs two-stage discovery: it searches the canonical directory and then searches the live catalogs of matching Services. A Service failure becomes an `IssueEvent` without discarding Offerings returned by other Services. diff --git a/examples/README.md b/examples/README.md index cbc4137..eac58da 100644 --- a/examples/README.md +++ b/examples/README.md @@ -81,6 +81,27 @@ Full Offering agent-guide: {...} ``` +## Canonical Directory discovery + +[`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. + +```sh +./mvnw -q -DskipTests install +./mvnw -q -f examples/pom.xml \ + -Dexec.mainClass=org.offeringprotocol.odp.examples.DirectoryDiscovery \ + -Dexec.args='sandbox weather' \ + org.codehaus.mojo:exec-maven-plugin:3.6.3:java +``` + +Use `production weather` for production, or omit the query to browse. The selected Directory must +provide `/v1/directory/search`; this example cannot run against a deployment without that endpoint. +The server may return more matches than fit in its bounded response; absence of `next` does not +mean the catalog was exhausted. Service-only Agent discovery above remains a separate example. + ## From example to application For an Agent integration: diff --git a/examples/src/main/java/org/offeringprotocol/odp/examples/DirectoryDiscovery.java b/examples/src/main/java/org/offeringprotocol/odp/examples/DirectoryDiscovery.java new file mode 100644 index 0000000..9b9f675 --- /dev/null +++ b/examples/src/main/java/org/offeringprotocol/odp/examples/DirectoryDiscovery.java @@ -0,0 +1,61 @@ +package org.offeringprotocol.odp.examples; + +import java.net.URI; +import org.offeringprotocol.odp.agent.OdpServiceClient; +import org.offeringprotocol.odp.core.AuthenticationRequirement; +import org.offeringprotocol.odp.core.OdpJson; +import org.offeringprotocol.odp.core.OdpOperation; +import org.offeringprotocol.odp.directory.DirectoryClient; +import org.offeringprotocol.odp.directory.DirectoryEnvironment; +import org.offeringprotocol.odp.directory.DirectoryModels; + +/** Mixed discovery through the canonical Directory, followed by anonymous Collection retrieval. */ +public final class DirectoryDiscovery { + private DirectoryDiscovery() {} + + public static void main(String[] arguments) { + DirectoryEnvironment environment = + switch (arguments.length == 0 ? "production" : arguments[0]) { + case "sandbox" -> DirectoryEnvironment.SANDBOX; + case "production" -> DirectoryEnvironment.PRODUCTION; + default -> throw new IllegalArgumentException("Environment must be production or sandbox"); + }; + String query = arguments.length > 1 ? arguments[1] : null; + DirectoryClient directory = DirectoryClient.create(environment); + var response = directory.search(new DirectoryModels.ResourceSearchRequest(query, null, 5, null)); + for (var issue : response.issues()) { + print("Skipped result " + issue.index(), issue.message()); + } + for (var result : response.items()) { + if (result instanceof DirectoryModels.ServiceResult service) { + print( + "Service", + service.service().name() + " — " + service.service().serviceOrigin()); + } else if (result instanceof DirectoryModels.CollectionResult collection) { + print( + "Collection", + collection.collection().name() + " — " + + collection.service().serviceOrigin()); + OdpServiceClient client = + OdpServiceClient.create(URI.create(collection.service().serviceOrigin())); + boolean anonymous = client.inspection().document().operations().stream() + .anyMatch(operation -> operation.name() == OdpOperation.GET_COLLECTION + && operation.authentication() != AuthenticationRequirement.REQUIRED); + if (anonymous) { + print( + "Full Collection", + OdpJson.write( + client.getCollection(collection.collection().id(), "full", null))); + } else { + print("Collection details", "The Service does not advertise anonymous Collection retrieval."); + } + } else { + print("Unsupported result type", result.type()); + } + } + } + + private static void print(String label, String value) { + System.out.printf("%s: %s%n", label, value); // NOPMD - Console output is the example's user interface. + } +} diff --git a/odp-agent/README.md b/odp-agent/README.md index a8de809..3ca98f7 100644 --- a/odp-agent/README.md +++ b/odp-agent/README.md @@ -51,6 +51,12 @@ 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; +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. + Creating a Service client retrieves `/.well-known/odp`, validates the document, and records the Service's advertised operations. diff --git a/odp-directory/README.md b/odp-directory/README.md index e04f200..6c1d32e 100644 --- a/odp-directory/README.md +++ b/odp-directory/README.md @@ -1,8 +1,7 @@ # ODP Directory -The official Java client for discovering candidate Services through the one canonical ODP -directory. It searches cached Service metadata; it does not search the complete catalogs owned by -those Services. +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). @@ -15,7 +14,53 @@ and exactly one JSON provider. Replace `odp-json-jackson2` with `odp-json-jackson3` in a Jackson 3 application. Add exactly one provider; it is discovered automatically at runtime. -## Search Services +## Search Services and Collections + +```java +DirectoryClient directory = DirectoryClient.create(); +DirectoryModels.SearchResponse response = directory.search( + new DirectoryModels.ResourceSearchRequest("weather forecast", null, 25, null)); + +for (DirectoryModels.Result result : response.items()) { + if (result instanceof DirectoryModels.ServiceResult service) { + System.out.printf("Service: %s (%s)%n", service.service().name(), service.service().serviceOrigin()); + } else if (result instanceof DirectoryModels.CollectionResult collection) { + System.out.printf("Collection: %s, ID %s, through %s%n", + collection.collection().name(), collection.collection().id(), collection.service().serviceOrigin()); + } else if (result instanceof DirectoryModels.UnknownResult unknown) { + System.out.printf("Unsupported result type: %s%n", unknown.type()); + } +} +``` + +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. +For Service results, optional `availableThrough()` identifies a platform. Collection attribution +is its owning `service()`. + +Known types are validated; a malformed item is omitted and reported in `response.issues()` with +its original index and reason. Other valid items remain available. Unknown future types retain +their wire type and full JSON in `UnknownResult.raw()`; do not treat them as Services or execute +their metadata. Additive fields are retained in `additional()` maps. Execution metadata from the +Directory is not authoritative: obtain current operation paths from the Service itself. + +Mixed search returns at most 100 results (the default limit), without continuation. An absent +`next()` does not promise all matches were returned. Refine the query or filters when needed. +`continueSearch(next)` supports an opaque same-origin continuation if the server supplies one; +the SDK never invents a continuation. Each call returns one response, without automatic traversal. + +Mixed facets count all matching targets, not just the returned subset: one Service and two +Collections count as three. Collection search is independent of permission to show its card on +the Directory landing page. + +See the [runnable canonical discovery example](../examples/README.md#canonical-directory-discovery). + +## Search only Services `DirectoryClient.create()` uses the fixed production directory. Search accepts natural-language text, deterministic filters, or both. @@ -71,9 +116,23 @@ The client retrieves continuations with GET, keeps them on the selected canonica redirects to five, and bounds response bodies. Applications should impose their own total page and item limit when following multiple pages. -## Keyword suggestions +## Suggestions + +```java +List names = directory.suggest("we", 10); +``` + +`suggest` matches indexed names, descriptions and keywords, and returns **names of matching +Services and Collections**, not the text that matched. Despite the `prefix` argument name, +matching uses substrings and whitespace-separated alternative terms. The server deduplicates +names and returns at most 25 (also the default). These strings are candidate queries, not resource +identifiers. They can be passed to `search`. Collection surfacing permission does not restrict them. + +`suggest(prefix, limit, filters)` accepts the same `ServiceFilters` as search and sends +POST `/v1/directory/suggestions`. Filters restrict the matching Service or a Collection's +owning Service; the response remains names only. The two-argument overload omits filters. -Suggestions let an Agent discover useful keyword vocabulary by prefix: +`suggestServices` uses GET and retains Service-only keyword-prefix suggestions: ```java List suggestions = directory.suggestServices("gp", 5); diff --git a/odp-directory/pom.xml b/odp-directory/pom.xml index 1f6534b..2148ea8 100644 --- a/odp-directory/pom.xml +++ b/odp-directory/pom.xml @@ -16,6 +16,7 @@ org.offeringprotocol.odp.directory + odp-json-jackson3 @@ -26,7 +27,7 @@ ${project.groupId} - odp-json-jackson3 + ${directory.test.json-provider} ${project.version} test diff --git a/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryClient.java b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryClient.java index 00e574d..cce1e65 100644 --- a/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryClient.java +++ b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryClient.java @@ -8,8 +8,10 @@ import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Objects; import org.offeringprotocol.odp.core.OdpJson; import org.offeringprotocol.odp.core.OdpJsonNode; @@ -17,6 +19,8 @@ /** Client for the canonical ODP directory. */ public final class DirectoryClient { + private static final String METHOD_GET = "GET"; + private static final String METHOD_POST = "POST"; private static final int MAXIMUM_BYTES = 524_288; private static final int MAXIMUM_REDIRECTS = 5; private final DirectoryEnvironment selectedEnvironment; @@ -47,27 +51,59 @@ public DirectoryEnvironment environment() { return selectedEnvironment; } + public DirectoryModels.SearchResponse search(DirectoryModels.ResourceSearchRequest request) { + Objects.requireNonNull(request, "request"); + return DirectoryResults.decode( + send(selectedEnvironment.origin().resolve("/v1/directory/search"), METHOD_POST, encode(request))); + } + + public DirectoryModels.SearchResponse continueSearch(String next) { + return DirectoryResults.decode(send(resolveContinuation(next), METHOD_GET, null)); + } + public DirectoryModels.SearchPage searchServices(DirectoryModels.SearchRequest request) { Objects.requireNonNull(request, "request"); return decodeSearchPage( - send(selectedEnvironment.origin().resolve("/v1/services/search"), "POST", encode(request))); + send(selectedEnvironment.origin().resolve("/v1/services/search"), METHOD_POST, encode(request))); } public DirectoryModels.SearchPage continueSearchServices(String next) { URI uri = resolveContinuation(next); - return decodeSearchPage(send(uri, "GET", null)); + return decodeSearchPage(send(uri, METHOD_GET, null)); } public List suggestServices(String prefix, Integer limit) { + return suggestions("/v1/services/suggestions", prefix, limit, null, false); + } + + public List suggest(String prefix, Integer limit) { + return suggest(prefix, limit, null); + } + + public List suggest(String prefix, Integer limit, DirectoryModels.ServiceFilters filters) { + return suggestions("/v1/directory/suggestions", prefix, limit, filters, true); + } + + private List suggestions( + String path, String prefix, Integer limit, DirectoryModels.ServiceFilters filters, boolean mixed) { if (prefix == null || prefix.isBlank() || prefix.length() > 128) { throw new IllegalArgumentException("prefix must contain from 1 through 128 characters"); } if (limit != null && (limit < 1 || limit > 25)) { throw new IllegalArgumentException("limit must be from 1 through 25"); } - String query = "?prefix=" + URLEncoder.encode(prefix, StandardCharsets.UTF_8) - + (limit == null ? "" : "&limit=" + limit); - String json = send(selectedEnvironment.origin().resolve("/v1/services/suggestions" + query), "GET", null); + String json; + if (mixed) { + Map body = new LinkedHashMap<>(); + body.put("prefix", prefix); + if (limit != null) body.put("limit", limit); + if (filters != null) body.put("filters", filters); + json = send(selectedEnvironment.origin().resolve(path), METHOD_POST, encode(body)); + } else { + String query = "?prefix=" + URLEncoder.encode(prefix, StandardCharsets.UTF_8) + + (limit == null ? "" : "&limit=" + limit); + json = send(selectedEnvironment.origin().resolve(path + query), METHOD_GET, null); + } try { return OdpJson.read(json, DirectoryModels.Suggestions.class).items(); } catch (IllegalArgumentException exception) { @@ -111,7 +147,7 @@ private String send(URI uri, String method, String body) { .orElseThrow(() -> new IllegalStateException("Directory redirect omitted Location")); current = requireDirectoryOrigin(current.resolve(location)); if (status == 303 || ((status == 301 || status == 302) && "POST".equals(currentMethod))) { - currentMethod = "GET"; + currentMethod = METHOD_GET; hasBody = false; } } @@ -179,18 +215,22 @@ static DirectoryModels.SearchPage decodeSearchPage(String json) { throw new IllegalArgumentException("Directory response is empty"); } DirectoryModels.SearchPage page = OdpJson.treeToValue(value, DirectoryModels.SearchPage.class); - if (page.facets() != null - && page.facets().trust().stream() - .anyMatch(facet -> facet.value() == null - || !"tap".equals(facet.value().name()))) { - throw new IllegalArgumentException("Directory trust facets are invalid"); - } + validateFacets(page.facets()); return page; } catch (IllegalArgumentException exception) { throw new IllegalArgumentException("Directory response is invalid", exception); } } + static void validateFacets(DirectoryModels.Facets facets) { + if (facets != null + && facets.trust().stream() + .anyMatch(facet -> facet.value() == null + || !"tap".equals(facet.value().name()))) { + throw new IllegalArgumentException("Directory trust facets are invalid"); + } + } + private static void normalizeServiceProtocols(OdpJsonNode value) { if (!value.isObject() || value.get("protocols") == null) { return; 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 2a8ebb2..7f55ec5 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 @@ -17,6 +17,92 @@ import org.offeringprotocol.odp.core.ServiceDocument; public interface DirectoryModels { + @JsonInclude(JsonInclude.Include.NON_EMPTY) + public record ResourceSearchRequest(String query, ServiceFilters filters, Integer limit, List types) { + public ResourceSearchRequest { + if (query != null && (query.isBlank() || query.length() > 512)) { + throw new IllegalArgumentException("query must contain from 1 through 512 characters"); + } + if (limit != null && (limit < 1 || limit > 100)) { + throw new IllegalArgumentException("limit must be from 1 through 100"); + } + if (types != null) { + if (types.isEmpty() + || types.size() > 2 + || types.stream().distinct().count() != types.size() + || types.stream().anyMatch(type -> !"service".equals(type) && !"collection".equals(type))) { + throw new IllegalArgumentException("types must contain distinct service or collection values"); + } + } + types = types == null ? List.of() : List.copyOf(types); + } + } + + public sealed interface Result permits ServiceResult, CollectionResult, UnknownResult { + String type(); + } + + public record ServiceResult( + Service service, Instant indexedAt, ServiceReference availableThrough, Map additional) + implements Result { + public ServiceResult { + additional = Collections.unmodifiableMap(new LinkedHashMap<>(additional)); + } + + @Override + public String type() { + return "service"; + } + } + + public record CollectionResult( + Service service, Instant indexedAt, CollectionSummary collection, Map additional) + implements Result { + public CollectionResult { + additional = Collections.unmodifiableMap(new LinkedHashMap<>(additional)); + } + + @Override + public String type() { + return "collection"; + } + } + + public record UnknownResult(String type, OdpJsonNode raw) implements Result { + public UnknownResult { + raw = raw.deepCopy(); + } + + @Override + public OdpJsonNode raw() { + return raw.deepCopy(); + } + } + + public record ServiceReference( + String serviceId, String serviceOrigin, String name, Map additional) { + public ServiceReference { + additional = Collections.unmodifiableMap(new LinkedHashMap<>(additional)); + } + } + + public record CollectionSummary(String id, String name, String description, Map additional) { + public CollectionSummary { + additional = Collections.unmodifiableMap(new LinkedHashMap<>(additional)); + } + } + + public record Issue(int index, String message) {} + + public record SearchResponse( + List items, String next, Facets facets, List issues, Map additional) { + public SearchResponse { + items = List.copyOf(items); + issues = List.copyOf(issues); + additional = Collections.unmodifiableMap(new LinkedHashMap<>(additional)); + } + } + public record SearchRequest(String query, ServiceFilters filters, Integer limit) { public SearchRequest { if (limit != null && (limit < 1 || limit > 100)) { @@ -84,6 +170,11 @@ public record Service( operations = operations == null ? List.of() : List.copyOf(operations); additional = additional == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(additional)); } + + public String serviceId() { + OdpJsonNode value = additional.get("service_id"); + return value == null ? null : value.asString(); + } } public record Facet(T value, long count) {} 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 new file mode 100644 index 0000000..31d6c06 --- /dev/null +++ b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryResults.java @@ -0,0 +1,167 @@ +package org.offeringprotocol.odp.directory; + +import java.net.URI; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.offeringprotocol.odp.core.OdpJson; +import org.offeringprotocol.odp.core.OdpJsonNode; +import org.offeringprotocol.odp.core.OdpUris; +import org.offeringprotocol.odp.core.ServiceDocument; + +final class DirectoryResults { + private static final String FIELD_FACETS = "facets"; + private static final String FIELD_NEXT = "next"; + private static final String FIELD_SERVICE = "service"; + private static final String FIELD_COLLECTION = "collection"; + 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_NAME = "name"; + + private DirectoryResults() {} + + static DirectoryModels.SearchResponse decode(String json) { + OdpJsonNode value = object(OdpJson.parseTree(json), "Directory response"); + OdpJsonNode items = value.get("items"); + if (items == null || !items.isArray() || items.size() > 100) { + throw new IllegalArgumentException("Directory response items are invalid"); + } + List results = new ArrayList<>(); + List issues = new ArrayList<>(); + int index = 0; + for (OdpJsonNode item : items) { + try { + results.add(result(item)); + } catch (IllegalArgumentException exception) { + issues.add(new DirectoryModels.Issue(index, exception.getMessage())); + } + index++; + } + DirectoryModels.Facets facets = value.has(FIELD_FACETS) + && !value.get(FIELD_FACETS).isNull() + ? OdpJson.treeToValue(object(value.get(FIELD_FACETS), FIELD_FACETS), DirectoryModels.Facets.class) + : null; + DirectoryClient.validateFacets(facets); + String next = value.has(FIELD_NEXT) && !value.get(FIELD_NEXT).isNull() ? text(value, FIELD_NEXT, 2048) : null; + return new DirectoryModels.SearchResponse( + results, next, facets, issues, additional(value, Set.of("items", FIELD_NEXT, FIELD_FACETS))); + } + + private static DirectoryModels.Result result(OdpJsonNode value) { + object(value, "Directory result"); + String type = text(value, "type", 128); + if (!FIELD_SERVICE.equals(type) && !FIELD_COLLECTION.equals(type)) { + return new DirectoryModels.UnknownResult(type, value); + } + OdpJsonNode serviceNode = + object(value.get(FIELD_SERVICE), FIELD_SERVICE).deepCopy(); + text(serviceNode, FIELD_SERVICE_ID, 128); + origin(serviceNode, FIELD_SERVICE_ORIGIN); + instant(serviceNode, FIELD_INDEXED_AT); + 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"); + } else { + serviceNode.set("protocols", OdpJson.valueToTree(parsed.protocols())); + } + 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; + return new DirectoryModels.ServiceResult( + service, + indexedAt, + reference, + additional(value, Set.of("type", FIELD_SERVICE, FIELD_INDEXED_AT, FIELD_AVAILABLE_THROUGH))); + } + OdpJsonNode collection = object(value.get(FIELD_COLLECTION), FIELD_COLLECTION); + String id = text(collection, "id", 128); + if (!OdpUris.isLocalResourceIdentifier(id)) { + throw new IllegalArgumentException("collection.id must be a local resource identifier"); + } + String description = null; + if (collection.has("description")) { + OdpJsonNode raw = collection.get("description"); + if (!raw.isString() || raw.asString().length() > 1024) { + throw new IllegalArgumentException("collection.description is invalid"); + } + description = raw.asString(); + } + return new DirectoryModels.CollectionResult( + service, + indexedAt, + new DirectoryModels.CollectionSummary( + id, + text(collection, FIELD_NAME, 128), + description, + additional(collection, Set.of("id", FIELD_NAME, "description"))), + 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 String origin(OdpJsonNode value, String name) { + String text = text(value, name, 2048); + URI uri = URI.create(text); + if (!"https".equals(uri.getScheme()) || !text.equals(OdpUris.deriveServiceOrigin(uri))) { + throw new IllegalArgumentException(name + " must be a canonical HTTPS origin"); + } + return text; + } + + private static Instant instant(OdpJsonNode value, String name) { + try { + return Instant.parse(text(value, name, 64)); + } catch (DateTimeParseException exception) { + throw new IllegalArgumentException(name + " must be a date-time", exception); + } + } + + private 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) { + OdpJsonNode node = value.get(name); + if (node == null + || !node.isString() + || node.asString().isBlank() + || node.asString().length() > maximum) { + throw new IllegalArgumentException(name + " is invalid"); + } + return node.asString(); + } + + private static Map additional(OdpJsonNode value, Set known) { + Map additional = new LinkedHashMap<>(); + value.forEachEntry((name, member) -> { + if (!known.contains(name)) { + additional.put(name, member.deepCopy()); + } + }); + return additional; + } +} 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 new file mode 100644 index 0000000..ab72b89 --- /dev/null +++ b/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryResultsTest.java @@ -0,0 +1,204 @@ +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; + +class DirectoryResultsTest { + static final String SERVICE = """ + {"service_id":"ca0304cc-ab28-43e5-af94-7bdf11b40c6e", + "service_origin":"https://api.example.com","name":"Example Service", + "description":"Data services.","language":"en","localizations":["en"], + "operations":[{"name":"get-offering","authentication":"not-required"}, + {"name":"list-offerings","authentication":"not-required"}], + "protocols":{"trust":[{"name":"tap"},{"name":"future"}]}, + "indexed_at":"2026-09-18T11:00:00Z"} + """; + + static OdpJsonNode result(String type) { + OdpJsonNode node = OdpJson.parseTree(""" + {"type":"collection","indexed_at":"2026-09-18T12:00:00Z", + "collection":{"id":"Weather","name":"Weather forecasts","description":"Forecasts."}} + """); + node.put("type", type); + node.set("service", OdpJson.parseTree(SERVICE)); + if ("service".equals(type)) { + node.remove("collection"); + } + return node; + } + + static String response(OdpJsonNode... items) { + return "{\"items\":" + OdpJson.write(Arrays.asList(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.put("extra", "retained"); + OdpJsonNode collection = result("collection"); + collection.get("collection").put("extra", "retained"); + OdpJsonNode unknown = OdpJson.parseTree("{\"type\":\"future\",\"nested\":{\"field\":1}}"); + var decoded = DirectoryResults.decode(response(service, collection, unknown)); + assertTrue(decoded.issues().isEmpty()); + var first = assertInstanceOf( + DirectoryModels.ServiceResult.class, decoded.items().get(0)); + assertEquals("service", first.type()); + assertEquals("Platform", first.availableThrough().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)); + assertEquals(1, first.service().protocols().trust().size()); + var second = assertInstanceOf( + DirectoryModels.CollectionResult.class, decoded.items().get(1)); + assertEquals("collection", second.type()); + assertEquals("Weather", second.collection().id()); + assertEquals("retained", second.collection().additional().get("extra").asString()); + assertEquals("2026-09-18T12:00:00Z", second.indexedAt().toString()); + assertEquals("2026-09-18T11:00:00Z", second.service().indexedAt().toString()); + var third = assertInstanceOf( + DirectoryModels.UnknownResult.class, decoded.items().get(2)); + assertEquals(unknown.toString(), third.raw().toString()); + third.raw().put("changed", "not retained"); + assertEquals(unknown.toString(), third.raw().toString()); + assertThrows(UnsupportedOperationException.class, () -> decoded.items().clear()); + assertThrows( + UnsupportedOperationException.class, () -> first.additional().clear()); + } + + @Test + void reportsMalformedKnownEntriesWithoutLosingValidOnes() { + List> changes = List.of( + node -> node.remove("type"), + node -> node.put("type", " "), + node -> node.remove("service"), + node -> node.get("service").remove("service_id"), + node -> node.get("service").put("service_origin", "http://api.example.com"), + node -> node.get("service").put("service_origin", "https://api.example.com/path"), + node -> node.get("service").remove("operations"), + node -> node.put("indexed_at", "yesterday"), + node -> node.remove("indexed_at"), + node -> node.remove("collection"), + node -> node.get("collection").put("id", "../outside"), + node -> node.get("collection").put("name", ""), + node -> node.get("collection").set("description", OdpJson.parseTree("null")), + node -> node.get("collection").put("description", "x".repeat(1025))); + for (var change : changes) { + OdpJsonNode invalid = result("collection"); + change.accept(invalid); + var decoded = DirectoryResults.decode(response(invalid, result("collection"))); + assertEquals(1, decoded.items().size(), invalid.toString()); + assertEquals(1, decoded.issues().size(), invalid.toString()); + 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}")) { + OdpJsonNode invalid = result("service"); + invalid.set("available_through", OdpJson.parseTree(reference)); + assertEquals(1, DirectoryResults.decode(response(invalid)).issues().size()); + } + } + + @Test + void acceptsOptionalFieldsAndRemovesUnverifiedExecutionMetadata() { + OdpJsonNode service = result("service"); + service.get("service").remove("protocols"); + service.get("service").put("http", "unverified"); + service.get("service").set("operations", OdpJson.parseTree(""" + [{"name":"get-offering","authentication":"not-required"}, + {"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"} + """)); + var decoded = DirectoryResults.decode(response(service)); + var item = assertInstanceOf( + DirectoryModels.ServiceResult.class, decoded.items().get(0)); + assertNull(item.availableThrough().name()); + assertNull(item.service().protocols()); + assertNull(item.service().additional().get("http")); + assertEquals(2, item.service().operations().size()); + service.remove("available_through"); + assertNull(assertInstanceOf( + DirectoryModels.ServiceResult.class, + DirectoryResults.decode(response(service)).items().get(0)) + .availableThrough()); + for (boolean omit : List.of(true, false)) { + OdpJsonNode collection = result("collection"); + if (omit) { + collection.get("collection").remove("description"); + } else { + collection.get("collection").put("description", ""); + } + assertTrue(DirectoryResults.decode(response(collection)).issues().isEmpty()); + } + } + + @Test + void validatesEnvelopeAndPreservesFacetsAndContinuation() { + for (String body : List.of( + "null", + "[]", + "{}", + "{\"items\":null}", + "{\"items\":{}}", + "{\"items\":[],\"next\":false}", + "{\"items\":[],\"facets\":false}")) { + assertThrows(IllegalArgumentException.class, () -> DirectoryResults.decode(body), body); + } + assertThrows( + IllegalArgumentException.class, + () -> DirectoryResults.decode(response( + java.util.Collections.nCopies(101, result("service")).toArray(OdpJsonNode[]::new)))); + var decoded = DirectoryResults.decode(""" + {"items":[],"next":"/v1/directory/search?cursor=opaque","extra":true, + "facets":{"keywords":[{"value":"weather","count":12}],"trust":[{"value":{"name":"tap"},"count":3}]}} + """); + assertEquals("/v1/directory/search?cursor=opaque", decoded.next()); + assertEquals(12, decoded.facets().keywords().get(0).count()); + assertEquals(3, decoded.facets().trust().get(0).count()); + assertTrue(decoded.additional().get("extra").asBoolean(false)); + } + + @Test + void validatesAndSnapshotsMixedRequests() { + List types = new ArrayList<>(List.of("service", "collection")); + var request = new DirectoryModels.ResourceSearchRequest("weather", null, 10, types); + types.clear(); + assertEquals(List.of("service", "collection"), request.types()); + assertEquals( + "{\"query\":\"weather\",\"limit\":10,\"types\":[\"service\",\"collection\"]}", OdpJson.write(request)); + assertEquals("{}", OdpJson.write(new DirectoryModels.ResourceSearchRequest(null, null, null, null))); + for (List invalid : List.of( + List.of(), List.of("future"), List.of("service", "service"), Arrays.asList("service", null))) { + assertThrows( + IllegalArgumentException.class, + () -> new DirectoryModels.ResourceSearchRequest(null, null, null, invalid)); + } + assertThrows( + IllegalArgumentException.class, () -> new DirectoryModels.ResourceSearchRequest(" ", null, null, null)); + assertThrows( + IllegalArgumentException.class, + () -> new DirectoryModels.ResourceSearchRequest("x".repeat(513), null, null, null)); + assertThrows( + IllegalArgumentException.class, () -> new DirectoryModels.ResourceSearchRequest(null, null, 101, null)); + } +} 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 new file mode 100644 index 0000000..6f8ab95 --- /dev/null +++ b/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryTransportTest.java @@ -0,0 +1,281 @@ +package org.offeringprotocol.odp.directory; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.Authenticator; +import java.net.CookieHandler; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.Flow; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSession; +import org.junit.jupiter.api.Test; + +class DirectoryTransportTest { + @Test + void usesMixedAndServiceOnlyRoutesWithOpaqueContinuation() { + StubClient transport = new StubClient(); + DirectoryClient directory = DirectoryClient.create(DirectoryEnvironment.SANDBOX, transport); + transport.add(200, DirectoryResultsTest.response(DirectoryResultsTest.result("collection")), Map.of()); + assertEquals( + 1, + directory + .search(new DirectoryModels.ResourceSearchRequest("we", null, 10, null)) + .items() + .size()); + transport.add(200, "{\"items\":[],\"next\":\"/v1/directory/search?cursor=opaque\"}", Map.of()); + assertEquals( + "/v1/directory/search?cursor=opaque", + directory.continueSearch("/v1/directory/search?cursor=opaque").next()); + transport.add(200, "{\"items\":[" + DirectoryResultsTest.SERVICE + "]}", Map.of()); + assertEquals( + 1, + directory + .searchServices(new DirectoryModels.SearchRequest("we", null, 10)) + .items() + .size()); + transport.add(200, "{\"items\":[\"Weather forecasts\"]}", Map.of()); + assertEquals(List.of("Weather forecasts"), directory.suggest("we", 10)); + transport.add(200, "{\"items\":[\"weather\"]}", Map.of()); + assertEquals(List.of("weather"), directory.suggestServices("we", 10)); + assertEquals( + List.of("POST", "GET", "POST", "POST", "GET"), + transport.requests.stream().map(HttpRequest::method).toList()); + assertEquals( + List.of( + "/v1/directory/search", + "/v1/directory/search", + "/v1/services/search", + "/v1/directory/suggestions", + "/v1/services/suggestions"), + transport.requests.stream() + .map(request -> request.uri().getPath()) + .toList()); + assertTrue(transport.requests.stream() + .allMatch(request -> "sandbox.inflowpay.ai".equals(request.uri().getHost()))); + assertEquals("cursor=opaque", transport.requests.get(1).uri().getRawQuery()); + assertEquals("{\"prefix\":\"we\",\"limit\":10}", transport.bodies.get(3)); + assertEquals("{\"query\":\"we\",\"limit\":10}", transport.bodies.get(0)); + assertEquals("", transport.bodies.get(1)); + } + + @Test + void retainsTransportBoundariesAndRequestErrors() { + StubClient transport = new StubClient(); + DirectoryClient directory = DirectoryClient.create(DirectoryEnvironment.PRODUCTION, transport); + var request = new DirectoryModels.ResourceSearchRequest(null, null, null, null); + assertThrows(IllegalArgumentException.class, () -> directory.continueSearch("https://other.example/")); + assertThrows(IllegalArgumentException.class, () -> directory.continueSearch(" ")); + assertThrows(IllegalArgumentException.class, () -> directory.suggest("", 10)); + assertThrows(IllegalArgumentException.class, () -> directory.suggest("we", 26)); + assertTrue(transport.requests.isEmpty()); + transport.add(307, "", Map.of("Location", List.of("https://other.example/"))); + assertThrows(IllegalArgumentException.class, () -> directory.search(request)); + assertEquals(1, transport.requests.size()); + transport.add(303, "", Map.of("Location", List.of("/redirected"))); + transport.add(200, "{\"items\":[]}", Map.of()); + assertTrue(directory.search(request).items().isEmpty()); + assertEquals("GET", transport.requests.get(2).method()); + assertEquals("", transport.bodies.get(2)); + transport.add(429, "rate limited", Map.of("Retry-After", List.of("5"))); + var error = assertThrows(DirectoryRequestException.class, () -> directory.search(request)); + assertEquals(429, error.status()); + transport.add(200, "x".repeat(524_289), Map.of()); + assertThrows(IllegalStateException.class, () -> directory.search(request)); + transport.add(200, "{\"items\":[]}", Map.of("Content-Type", List.of("text/html"))); + assertThrows(IllegalStateException.class, () -> directory.search(request)); + } + + @Test + void sendsSuggestionFiltersInJson() { + StubClient transport = new StubClient(); + DirectoryClient directory = DirectoryClient.create(DirectoryEnvironment.PRODUCTION, transport); + transport.add(200, "{\"items\":[\"Weather\"]}", Map.of()); + var filters = new DirectoryModels.ServiceFilters(null, List.of("weather"), null, null); + assertEquals(List.of("Weather"), directory.suggest("we", null, filters)); + assertEquals("{\"prefix\":\"we\",\"filters\":{\"keywords\":[\"weather\"]}}", transport.bodies.get(0)); + assertEquals( + "application/json", + transport.requests.get(0).headers().firstValue("Content-Type").orElseThrow()); + assertEquals( + "https://api.inflowpay.ai/v1/directory/suggestions", + transport.requests.get(0).uri().toString()); + } + + private record Reply(int status, String body, Map> headers) {} + + private static final class StubClient extends HttpClient { + private final HttpClient defaults = HttpClient.newHttpClient(); + private final Deque replies = new ArrayDeque<>(); + private final List requests = new ArrayList<>(); + private final List bodies = new ArrayList<>(); + + void add(int status, String body, Map> headers) { + replies.add(new Reply(status, body, headers)); + } + + @Override + public HttpResponse send(HttpRequest request, HttpResponse.BodyHandler handler) + throws IOException, InterruptedException { + requests.add(request); + var requestBody = HttpResponse.BodySubscribers.ofString(StandardCharsets.UTF_8); + request.bodyPublisher() + .orElseGet(HttpRequest.BodyPublishers::noBody) + .subscribe(new Flow.Subscriber<>() { + public void onSubscribe(Flow.Subscription subscription) { + requestBody.onSubscribe(subscription); + } + + public void onNext(ByteBuffer value) { + requestBody.onNext(List.of(value)); + } + + public void onError(Throwable error) { + requestBody.onError(error); + } + + public void onComplete() { + requestBody.onComplete(); + } + }); + bodies.add(requestBody.getBody().toCompletableFuture().join()); + Reply reply = replies.remove(); + Map> values = new java.util.LinkedHashMap<>(reply.headers()); + values.putIfAbsent("Content-Type", List.of("application/json")); + HttpHeaders headers = HttpHeaders.of(values, (name, value) -> true); + HttpResponse.ResponseInfo info = new HttpResponse.ResponseInfo() { + public int statusCode() { + return reply.status(); + } + + public HttpHeaders headers() { + return headers; + } + + public Version version() { + return Version.HTTP_1_1; + } + }; + var subscriber = handler.apply(info); + subscriber.onSubscribe(new Flow.Subscription() { + public void request(long amount) {} + + public void cancel() {} + }); + subscriber.onNext(List.of(ByteBuffer.wrap(reply.body().getBytes(StandardCharsets.UTF_8)))); + subscriber.onComplete(); + T body = subscriber.getBody().toCompletableFuture().join(); + return new HttpResponse<>() { + public int statusCode() { + return reply.status(); + } + + public HttpRequest request() { + return request; + } + + public Optional> previousResponse() { + return Optional.empty(); + } + + public HttpHeaders headers() { + return headers; + } + + public T body() { + return body; + } + + public Optional sslSession() { + return Optional.empty(); + } + + public URI uri() { + return request.uri(); + } + + public Version version() { + return Version.HTTP_1_1; + } + }; + } + + @Override + public Optional cookieHandler() { + return defaults.cookieHandler(); + } + + @Override + public Optional connectTimeout() { + return defaults.connectTimeout(); + } + + @Override + public Redirect followRedirects() { + return Redirect.NEVER; + } + + @Override + public Optional proxy() { + return defaults.proxy(); + } + + @Override + public SSLContext sslContext() { + return defaults.sslContext(); + } + + @Override + public SSLParameters sslParameters() { + return defaults.sslParameters(); + } + + @Override + public Optional authenticator() { + return defaults.authenticator(); + } + + @Override + public Version version() { + return defaults.version(); + } + + @Override + public Optional executor() { + return defaults.executor(); + } + + @Override + public CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler handler) { + throw new UnsupportedOperationException(); + } + + @Override + public CompletableFuture> sendAsync( + HttpRequest request, + HttpResponse.BodyHandler handler, + HttpResponse.PushPromiseHandler pushHandler) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/odp-json-jackson2/pom.xml b/odp-json-jackson2/pom.xml index 47ed0d6..c527933 100644 --- a/odp-json-jackson2/pom.xml +++ b/odp-json-jackson2/pom.xml @@ -39,6 +39,11 @@ jackson-databind ${jackson2.version} + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + ${jackson2.version} + com.networknt json-schema-validator diff --git a/odp-json-jackson2/src/main/java/org/offeringprotocol/odp/json/jackson2/Jackson2JsonProvider.java b/odp-json-jackson2/src/main/java/org/offeringprotocol/odp/json/jackson2/Jackson2JsonProvider.java index 7660fdc..2700a5b 100644 --- a/odp-json-jackson2/src/main/java/org/offeringprotocol/odp/json/jackson2/Jackson2JsonProvider.java +++ b/odp-json-jackson2/src/main/java/org/offeringprotocol/odp/json/jackson2/Jackson2JsonProvider.java @@ -15,6 +15,7 @@ import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.networknt.schema.InputFormat; import com.networknt.schema.Schema; import com.networknt.schema.SchemaLocation; @@ -247,6 +248,7 @@ public Jackson2JsonProvider() { .addMixIn(ServiceDocument.class, ServiceDocumentMixin.class) .addMixIn(ServiceDocument.Builder.class, BuilderMixin.class) .registerModule(module) + .registerModule(new JavaTimeModule()) .setSerializationInclusion(JsonInclude.Include.NON_NULL); } diff --git a/odp-json-jackson2/src/test/java/org/offeringprotocol/odp/json/jackson2/Jackson2JsonProviderTest.java b/odp-json-jackson2/src/test/java/org/offeringprotocol/odp/json/jackson2/Jackson2JsonProviderTest.java index b8bac1d..46e0d4e 100644 --- a/odp-json-jackson2/src/test/java/org/offeringprotocol/odp/json/jackson2/Jackson2JsonProviderTest.java +++ b/odp-json-jackson2/src/test/java/org/offeringprotocol/odp/json/jackson2/Jackson2JsonProviderTest.java @@ -3,11 +3,19 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import java.time.Instant; import org.junit.jupiter.api.Test; import org.offeringprotocol.odp.core.OdpJson; import org.offeringprotocol.odp.core.OdpValidationException; class Jackson2JsonProviderTest { + @Test + void decodesDirectoryTimestamps() { + assertEquals( + Instant.parse("2026-09-18T12:30:00Z"), + OdpJson.treeToValue(OdpJson.parseTree("\"2026-09-18T12:30:00Z\""), Instant.class)); + } + @Test void supportsTheOdpJsonContract() { String document = """ 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 02ea005..442b8ea 100644 --- a/testdata/consumer/src/main/java/org/offeringprotocol/example/Consumer.java +++ b/testdata/consumer/src/main/java/org/offeringprotocol/example/Consumer.java @@ -17,6 +17,11 @@ 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.ResourceSearchRequest mixed = new DirectoryModels.ResourceSearchRequest( + "weather", null, 10, List.of("service", "collection")); + if (!OdpJson.write(mixed).contains("\"types\":[\"service\",\"collection\"]")) { + throw new IllegalStateException("Mixed Directory request encoding failed"); + } Offering offering = OdpJson.parseOffering(""" { "odp_version": "1.0",