Skip to content

Commit aad26c0

Browse files
committed
feat(directory): add mixed service and collection discovery
1 parent 95b581b commit aad26c0

15 files changed

Lines changed: 937 additions & 16 deletions

File tree

‎README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,13 @@ every ODP module to one project unless they actually implement multiple roles.
104104

105105
## Agent quick start
106106

107+
For mixed Service/Collection discovery, use `DirectoryClient.search` and `continueSearch`.
108+
`suggest` returns matching target names. The existing `searchServices`, `continueSearchServices`
109+
and `suggestServices` remain available for Service-only discovery. See the
110+
[Directory guide](./odp-directory/README.md) for result types, facets, attribution and the
111+
100-result mixed-search cap. Each Java search call returns one response; it does not traverse
112+
continuations automatically.
113+
107114
`OdpAgent` performs two-stage discovery: it searches the canonical directory and then searches the
108115
live catalogs of matching Services. A Service failure becomes an `IssueEvent` without discarding
109116
Offerings returned by other Services.

‎examples/README.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,27 @@ Full Offering agent-guide:
8181
{...}
8282
```
8383

84+
## Canonical Directory discovery
85+
86+
[`DirectoryDiscovery.java`](./src/main/java/org/offeringprotocol/odp/examples/DirectoryDiscovery.java)
87+
uses the real Directory API rather than `MockDirectory`. It requests up to five mixed results,
88+
prints Service and Collection names, reports unusable items, and retrieves full Collection details
89+
only after the owning Service advertises anonymous retrieval. It does not enroll, pay or execute
90+
Actions. Unknown result types are reported without being treated as Services.
91+
92+
```sh
93+
./mvnw -q -DskipTests install
94+
./mvnw -q -f examples/pom.xml \
95+
-Dexec.mainClass=org.offeringprotocol.odp.examples.DirectoryDiscovery \
96+
-Dexec.args='sandbox weather' \
97+
org.codehaus.mojo:exec-maven-plugin:3.6.3:java
98+
```
99+
100+
Use `production weather` for production, or omit the query to browse. The selected Directory must
101+
provide `/v1/directory/search`; this example cannot run against a deployment without that endpoint.
102+
The server may return more matches than fit in its bounded response; absence of `next` does not
103+
mean the catalog was exhausted. Service-only Agent discovery above remains a separate example.
104+
84105
## From example to application
85106

86107
For an Agent integration:
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package org.offeringprotocol.odp.examples;
2+
3+
import java.net.URI;
4+
import org.offeringprotocol.odp.agent.OdpServiceClient;
5+
import org.offeringprotocol.odp.core.AuthenticationRequirement;
6+
import org.offeringprotocol.odp.core.OdpJson;
7+
import org.offeringprotocol.odp.core.OdpOperation;
8+
import org.offeringprotocol.odp.directory.DirectoryClient;
9+
import org.offeringprotocol.odp.directory.DirectoryEnvironment;
10+
import org.offeringprotocol.odp.directory.DirectoryModels;
11+
12+
/** Mixed discovery through the canonical Directory, followed by anonymous Collection retrieval. */
13+
public final class DirectoryDiscovery {
14+
private DirectoryDiscovery() {}
15+
16+
public static void main(String[] arguments) {
17+
DirectoryEnvironment environment =
18+
switch (arguments.length == 0 ? "production" : arguments[0]) {
19+
case "sandbox" -> DirectoryEnvironment.SANDBOX;
20+
case "production" -> DirectoryEnvironment.PRODUCTION;
21+
default -> throw new IllegalArgumentException("Environment must be production or sandbox");
22+
};
23+
String query = arguments.length > 1 ? arguments[1] : null;
24+
DirectoryClient directory = DirectoryClient.create(environment);
25+
var response = directory.search(new DirectoryModels.ResourceSearchRequest(query, null, 5, null));
26+
for (var issue : response.issues()) {
27+
print("Skipped result " + issue.index(), issue.message());
28+
}
29+
for (var result : response.items()) {
30+
if (result instanceof DirectoryModels.ServiceResult service) {
31+
print(
32+
"Service",
33+
service.service().name() + " — " + service.service().serviceOrigin());
34+
} else if (result instanceof DirectoryModels.CollectionResult collection) {
35+
print(
36+
"Collection",
37+
collection.collection().name() + " — "
38+
+ collection.service().serviceOrigin());
39+
OdpServiceClient client =
40+
OdpServiceClient.create(URI.create(collection.service().serviceOrigin()));
41+
boolean anonymous = client.inspection().document().operations().stream()
42+
.anyMatch(operation -> operation.name() == OdpOperation.GET_COLLECTION
43+
&& operation.authentication() != AuthenticationRequirement.REQUIRED);
44+
if (anonymous) {
45+
print(
46+
"Full Collection",
47+
OdpJson.write(
48+
client.getCollection(collection.collection().id(), "full", null)));
49+
} else {
50+
print("Collection details", "The Service does not advertise anonymous Collection retrieval.");
51+
}
52+
} else {
53+
print("Unsupported result type", result.type());
54+
}
55+
}
56+
}
57+
58+
private static void print(String label, String value) {
59+
System.out.printf("%s: %s%n", label, value); // NOPMD - Console output is the example's user interface.
60+
}
61+
}

‎odp-agent/README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ OdpAgent agent = new OdpAgent(
5151

5252
## Inspect one Service
5353

54+
For mixed Service/Collection discovery, call `DirectoryClient.search`. A `CollectionResult`
55+
contains its owning Service origin and remote Collection ID. Inspect that Service and use
56+
`getCollection` to retrieve current details. `OdpAgent.searchOfferings` remains Service-only;
57+
it does not treat Collection results as separate Services. See the
58+
[Directory guide](../odp-directory/README.md#search-services-and-collections) for the mixed API.
59+
5460
Creating a Service client retrieves `/.well-known/odp`, validates the document, and records the
5561
Service's advertised operations.
5662

‎odp-directory/README.md‎

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
# ODP Directory
22

3-
The official Java client for discovering candidate Services through the one canonical ODP
4-
directory. It searches cached Service metadata; it does not search the complete catalogs owned by
5-
those Services.
3+
The official Java client for discovering indexed Services and submitted Collections through the
4+
canonical Directory. It does not crawl catalogs or index Offerings.
65

76
After directory discovery, an Agent inspects each candidate's live ODP document and queries the
87
Service's Collections and Offerings with [`odp-agent`](../odp-agent/README.md).
@@ -15,7 +14,53 @@ and exactly one JSON provider.
1514
Replace `odp-json-jackson2` with `odp-json-jackson3` in a Jackson 3 application. Add exactly one
1615
provider; it is discovered automatically at runtime.
1716

18-
## Search Services
17+
## Search Services and Collections
18+
19+
```java
20+
DirectoryClient directory = DirectoryClient.create();
21+
DirectoryModels.SearchResponse response = directory.search(
22+
new DirectoryModels.ResourceSearchRequest("weather forecast", null, 25, null));
23+
24+
for (DirectoryModels.Result result : response.items()) {
25+
if (result instanceof DirectoryModels.ServiceResult service) {
26+
System.out.printf("Service: %s (%s)%n", service.service().name(), service.service().serviceOrigin());
27+
} else if (result instanceof DirectoryModels.CollectionResult collection) {
28+
System.out.printf("Collection: %s, ID %s, through %s%n",
29+
collection.collection().name(), collection.collection().id(), collection.service().serviceOrigin());
30+
} else if (result instanceof DirectoryModels.UnknownResult unknown) {
31+
System.out.printf("Unsupported result type: %s%n", unknown.type());
32+
}
33+
}
34+
```
35+
36+
The fourth request argument is an optional list of `"service"` and/or `"collection"`; null selects
37+
both. Explicit lists must be nonempty and distinct. Filters use the owning Service's metadata.
38+
39+
A Collection's identity is its owning Service origin plus its case-sensitive `collection().id()`.
40+
Inspect that Service's live document and use `OdpServiceClient.getCollection` to retrieve current
41+
details. `indexedAt()` on the result records Collection freshness, while `service().indexedAt()`
42+
records the parent's freshness. `service().serviceId()` identifies the local Directory Service.
43+
For Service results, optional `availableThrough()` identifies a platform. Collection attribution
44+
is its owning `service()`.
45+
46+
Known types are validated; a malformed item is omitted and reported in `response.issues()` with
47+
its original index and reason. Other valid items remain available. Unknown future types retain
48+
their wire type and full JSON in `UnknownResult.raw()`; do not treat them as Services or execute
49+
their metadata. Additive fields are retained in `additional()` maps. Execution metadata from the
50+
Directory is not authoritative: obtain current operation paths from the Service itself.
51+
52+
Mixed search returns at most 100 results (the default limit), without continuation. An absent
53+
`next()` does not promise all matches were returned. Refine the query or filters when needed.
54+
`continueSearch(next)` supports an opaque same-origin continuation if the server supplies one;
55+
the SDK never invents a continuation. Each call returns one response, without automatic traversal.
56+
57+
Mixed facets count all matching targets, not just the returned subset: one Service and two
58+
Collections count as three. Collection search is independent of permission to show its card on
59+
the Directory landing page.
60+
61+
See the [runnable canonical discovery example](../examples/README.md#canonical-directory-discovery).
62+
63+
## Search only Services
1964

2065
`DirectoryClient.create()` uses the fixed production directory. Search accepts natural-language
2166
text, deterministic filters, or both.
@@ -71,9 +116,19 @@ The client retrieves continuations with GET, keeps them on the selected canonica
71116
redirects to five, and bounds response bodies. Applications should impose their own total page and
72117
item limit when following multiple pages.
73118

74-
## Keyword suggestions
119+
## Suggestions
120+
121+
```java
122+
List<String> names = directory.suggest("we", 10);
123+
```
124+
125+
`suggest` matches indexed names, descriptions and keywords, and returns **names of matching
126+
Services and Collections**, not the text that matched. Despite the `prefix` argument name,
127+
matching uses substrings and whitespace-separated alternative terms. The server deduplicates
128+
names and returns at most 25 (also the default). These strings are candidate queries, not resource
129+
identifiers. They can be passed to `search`. Collection surfacing permission does not restrict them.
75130

76-
Suggestions let an Agent discover useful keyword vocabulary by prefix:
131+
`suggestServices` retains Service-only keyword-prefix suggestions:
77132

78133
```java
79134
List<String> suggestions = directory.suggestServices("gp", 5);

‎odp-directory/pom.xml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
<properties>
1818
<automatic.module.name>org.offeringprotocol.odp.directory</automatic.module.name>
19+
<directory.test.json-provider>odp-json-jackson3</directory.test.json-provider>
1920
</properties>
2021

2122
<dependencies>
@@ -26,7 +27,7 @@
2627
</dependency>
2728
<dependency>
2829
<groupId>${project.groupId}</groupId>
29-
<artifactId>odp-json-jackson3</artifactId>
30+
<artifactId>${directory.test.json-provider}</artifactId>
3031
<version>${project.version}</version>
3132
<scope>test</scope>
3233
</dependency>

‎odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryClient.java‎

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
/** Client for the canonical ODP directory. */
1919
public final class DirectoryClient {
20+
private static final String METHOD_GET = "GET";
2021
private static final int MAXIMUM_BYTES = 524_288;
2122
private static final int MAXIMUM_REDIRECTS = 5;
2223
private final DirectoryEnvironment selectedEnvironment;
@@ -47,6 +48,16 @@ public DirectoryEnvironment environment() {
4748
return selectedEnvironment;
4849
}
4950

51+
public DirectoryModels.SearchResponse search(DirectoryModels.ResourceSearchRequest request) {
52+
Objects.requireNonNull(request, "request");
53+
return DirectoryResults.decode(
54+
send(selectedEnvironment.origin().resolve("/v1/directory/search"), "POST", encode(request)));
55+
}
56+
57+
public DirectoryModels.SearchResponse continueSearch(String next) {
58+
return DirectoryResults.decode(send(resolveContinuation(next), METHOD_GET, null));
59+
}
60+
5061
public DirectoryModels.SearchPage searchServices(DirectoryModels.SearchRequest request) {
5162
Objects.requireNonNull(request, "request");
5263
return decodeSearchPage(
@@ -55,10 +66,18 @@ public DirectoryModels.SearchPage searchServices(DirectoryModels.SearchRequest r
5566

5667
public DirectoryModels.SearchPage continueSearchServices(String next) {
5768
URI uri = resolveContinuation(next);
58-
return decodeSearchPage(send(uri, "GET", null));
69+
return decodeSearchPage(send(uri, METHOD_GET, null));
5970
}
6071

6172
public List<String> suggestServices(String prefix, Integer limit) {
73+
return suggestions("/v1/services/suggestions", prefix, limit);
74+
}
75+
76+
public List<String> suggest(String prefix, Integer limit) {
77+
return suggestions("/v1/directory/suggestions", prefix, limit);
78+
}
79+
80+
private List<String> suggestions(String path, String prefix, Integer limit) {
6281
if (prefix == null || prefix.isBlank() || prefix.length() > 128) {
6382
throw new IllegalArgumentException("prefix must contain from 1 through 128 characters");
6483
}
@@ -67,7 +86,7 @@ public List<String> suggestServices(String prefix, Integer limit) {
6786
}
6887
String query = "?prefix=" + URLEncoder.encode(prefix, StandardCharsets.UTF_8)
6988
+ (limit == null ? "" : "&limit=" + limit);
70-
String json = send(selectedEnvironment.origin().resolve("/v1/services/suggestions" + query), "GET", null);
89+
String json = send(selectedEnvironment.origin().resolve(path + query), METHOD_GET, null);
7190
try {
7291
return OdpJson.read(json, DirectoryModels.Suggestions.class).items();
7392
} catch (IllegalArgumentException exception) {
@@ -111,7 +130,7 @@ private String send(URI uri, String method, String body) {
111130
.orElseThrow(() -> new IllegalStateException("Directory redirect omitted Location"));
112131
current = requireDirectoryOrigin(current.resolve(location));
113132
if (status == 303 || ((status == 301 || status == 302) && "POST".equals(currentMethod))) {
114-
currentMethod = "GET";
133+
currentMethod = METHOD_GET;
115134
hasBody = false;
116135
}
117136
}
@@ -179,18 +198,22 @@ static DirectoryModels.SearchPage decodeSearchPage(String json) {
179198
throw new IllegalArgumentException("Directory response is empty");
180199
}
181200
DirectoryModels.SearchPage page = OdpJson.treeToValue(value, DirectoryModels.SearchPage.class);
182-
if (page.facets() != null
183-
&& page.facets().trust().stream()
184-
.anyMatch(facet -> facet.value() == null
185-
|| !"tap".equals(facet.value().name()))) {
186-
throw new IllegalArgumentException("Directory trust facets are invalid");
187-
}
201+
validateFacets(page.facets());
188202
return page;
189203
} catch (IllegalArgumentException exception) {
190204
throw new IllegalArgumentException("Directory response is invalid", exception);
191205
}
192206
}
193207

208+
static void validateFacets(DirectoryModels.Facets facets) {
209+
if (facets != null
210+
&& facets.trust().stream()
211+
.anyMatch(facet -> facet.value() == null
212+
|| !"tap".equals(facet.value().name()))) {
213+
throw new IllegalArgumentException("Directory trust facets are invalid");
214+
}
215+
}
216+
194217
private static void normalizeServiceProtocols(OdpJsonNode value) {
195218
if (!value.isObject() || value.get("protocols") == null) {
196219
return;

0 commit comments

Comments
 (0)