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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
}
}
6 changes: 6 additions & 0 deletions odp-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
71 changes: 65 additions & 6 deletions odp-directory/README.md
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -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.
Expand Down Expand Up @@ -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<String> 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<String> suggestions = directory.suggestServices("gp", 5);
Expand Down
3 changes: 2 additions & 1 deletion odp-directory/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

<properties>
<automatic.module.name>org.offeringprotocol.odp.directory</automatic.module.name>
<directory.test.json-provider>odp-json-jackson3</directory.test.json-provider>
</properties>

<dependencies>
Expand All @@ -26,7 +27,7 @@
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>odp-json-jackson3</artifactId>
<artifactId>${directory.test.json-provider}</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,19 @@
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;
import org.offeringprotocol.odp.core.ServiceDocument;

/** 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;
Expand Down Expand Up @@ -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<String> suggestServices(String prefix, Integer limit) {
return suggestions("/v1/services/suggestions", prefix, limit, null, false);
}

public List<String> suggest(String prefix, Integer limit) {
return suggest(prefix, limit, null);
}

public List<String> suggest(String prefix, Integer limit, DirectoryModels.ServiceFilters filters) {
return suggestions("/v1/directory/suggestions", prefix, limit, filters, true);
}

private List<String> 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<String, Object> 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) {
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading