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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
6 changes: 4 additions & 2 deletions odp-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
54 changes: 45 additions & 9 deletions odp-directory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()`.

Expand All @@ -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<String> 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;
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -117,7 +118,17 @@ public record ServiceFilters(
List<String> keywords,
List<OperationFilter> operations,
List<PaymentFilter> payments,
List<ServiceDocument.TrustProtocol> trust) {
List<ServiceDocument.TrustProtocol> trust,
List<String> sources) {
public ServiceFilters(
List<ServiceDocument.EnrollmentProtocol> enrollment,
List<String> keywords,
List<OperationFilter> operations,
List<PaymentFilter> payments,
List<ServiceDocument.TrustProtocol> trust) {
this(enrollment, keywords, operations, payments, trust, null);
}

public ServiceFilters(
List<ServiceDocument.EnrollmentProtocol> enrollment,
List<String> keywords,
Expand All @@ -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
Expand Down Expand Up @@ -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<String, OdpJsonNode> additional) {
public Source {
additional = additional == null ? Map.of() : Map.copyOf(additional);
}
}

public record Facet<T>(T value, long count) {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -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)) {
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading