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: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,8 @@ and indexes instead of materializing the catalog in memory. See the

## Protocol composition

ODP advertises AEP enrollment, operation authentication requirements, MPP and x402 payment
support, and Offering Actions. It does not duplicate those protocols' credential or payment
semantics.
ODP advertises enrollment, payment, and trust protocols, operation authentication requirements, and
Offering Actions. It does not duplicate those protocols' credential, payment, or trust semantics.

The default Java Agent transport performs anonymous HTTP requests. Applications inject an
`OdpTransport` when catalog requests need AEP credentials, MPP, x402, or application-specific
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public static void main(String[] arguments) {
var page = service.listOfferings("terse", null, null);
print("Terse Offering list", page);
for (var offering : page.items()) {
print("Full Offering " + offering.id(), service.getOffering(offering.id(), "full", null));
print("Full Offering " + offering.id(), service.getOfferingDetails(offering.id(), null));
}
}
}
Expand Down
57 changes: 54 additions & 3 deletions odp-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,46 @@ Call `searchCollections(...)` with `SearchRequests.Collections` for Collection s
`inspection.supports(...)` before invoking optional Collection or search operations; the client
also rejects an unsupported call locally.

### Interpret a full Offering

Use `getOfferingDetails(...)` when the application needs the Offering's Service-defined attributes
or Actions:

```java
OfferingDetails details = service.getOfferingDetails("rubber-plant", "en");

if (details.attributeSchema() != null) {
consume(details.offering().attributes(), details.attributeSchema());
}
for (DiscoveredAction action : details.actions()) {
System.out.println(action.id() + " " + action.rel());
}
for (OfferingIssue issue : details.issues()) {
report(issue.scope(), issue.message());
}
```

The Agent retrieves the complete bounded JSON Schema Draft 2020-12 graph, bundles external `$ref`
documents into the returned schema, and validates full Offering attributes. `$dynamicRef` is limited
to fragment references such as `#node`. An unavailable, unsupported, or non-matching schema removes
only the uninterpretable attributes and produces a scoped issue; the Offering remains usable.

Actions are normalized to absolute compact HTTP or OpenAPI targets. Resolve the supporting document
for one explicitly selected Action without invoking it:

```java
ResolvedAction action = service.resolveAction("rubber-plant", "purchase", "en");

if (action.requestSchema() != null) {
prepareBody(action.action().http(), action.requestSchema());
} else if (action.openApiDocument() != null) {
prepareOperation(action.action().openapi(), action.operation());
}
```

Compact HTTP request schemas follow the same bounded resolution rules as Attribute Schemas. OpenAPI
targets require a JSON OpenAPI 3.1 document containing exactly one matching `operationId`.

## Continue a response

Continuation values are opaque. Pass `next` unchanged to the matching continuation method:
Expand Down Expand Up @@ -166,9 +206,20 @@ protocol-aware transport and performs challenge handling before returning the fi
credentials scoped to the intended Service and authenticated principal. `OdpAgent` accepts a
`ServiceClientFactory` when federated discovery needs the same custom transport for each Service.

Full Offerings expose their advertised Actions. The Java SDK does not invoke Actions or retrieve
their supporting JSON Schema or OpenAPI documents. The application remains responsible for Action
selection, user approval, authentication, payment, and invocation.
Attribute Schema, Action request-schema, and OpenAPI requests use the default anonymous transport so
credentials added by the catalog transport are not forwarded to supporting-resource origins. Supply
an explicit anonymous supporting transport as the third argument when the application needs to
control that network boundary:

```java
OdpServiceClient service = OdpServiceClient.create(
URI.create("https://service.example"),
protocolAwareTransport,
anonymousSupportingTransport);
```

Supporting-document resolution does not invoke an Action. The application remains responsible for
Action selection, user approval, authentication, payment, request construction, and invocation.

## Errors

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package org.offeringprotocol.odp.agent;

import java.net.URI;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import org.offeringprotocol.odp.core.OdpUris;
import org.offeringprotocol.odp.core.Offering;
import tools.jackson.databind.JsonNode;

final class ActionResolver {
private static final int EXPECTED_OPERATION_COUNT = 1;
private static final Set<String> HTTP_METHODS =
Set.of("delete", "get", "head", "options", "patch", "post", "put", "trace");
private static final Pattern OPENAPI_VERSION = Pattern.compile("3\\.1\\.\\d+(?:[-+].*)?");

private final SupportingJsonClient supportingClient;
private final AttributeSchemaResolver schemaResolver;

ActionResolver(SupportingJsonClient supportingClient, AttributeSchemaResolver schemaResolver) {
this.supportingClient = supportingClient;
this.schemaResolver = schemaResolver;
}

NormalizedActions normalize(List<Offering.Action> actions, String serviceOrigin, String serviceOpenApiUrl) {
if (actions == null || actions.isEmpty()) {
return new NormalizedActions(List.of(), List.of());
}
Map<String, Integer> counts = new HashMap<>();
actions.forEach(action -> counts.merge(action.id(), 1, Integer::sum));
Set<String> reportedDuplicates = new HashSet<>();
List<DiscoveredAction> normalized = new ArrayList<>();
List<OfferingIssue> issues = new ArrayList<>();
for (Offering.Action action : actions) {
if (counts.get(action.id()) > EXPECTED_OPERATION_COUNT) {
if (reportedDuplicates.add(action.id())) {
issues.add(issue(action.id(), "Duplicate Action identifier " + action.id()));
}
continue;
}
try {
normalized.add(normalize(action, serviceOrigin, serviceOpenApiUrl));
} catch (IllegalArgumentException | IllegalStateException exception) {
issues.add(issue(action.id(), exception.getMessage()));
}
}
return new NormalizedActions(normalized, issues);
}

ResolvedAction resolve(DiscoveredAction action, String serviceOrigin) {
if (action.http() != null) {
Offering.ActionRequest request = action.http().request();
if (request == null || request.schema() == null) {
return new ResolvedAction(action, null, null, null);
}
URI reference = OdpUris.resolveResourceReference(request.schema().url(), serviceOrigin);
JsonNode schema = schemaResolver.resolve(reference).document();
return new ResolvedAction(action, schema, null, null);
}
if (action.openapi() == null) {
throw new IllegalStateException("ODP Action has no usable target");
}
JsonNode document = supportingClient.get(
URI.create(action.openapi().url()),
"application/vnd.oai.openapi+json;version=3.1, application/json;q=0.9",
Set.of("application/vnd.oai.openapi+json", "application/json"),
1_048_576,
32);
String version = document.path("openapi").asString();
if (!OPENAPI_VERSION.matcher(version).matches()) {
throw new IllegalStateException("ODP Action requires an OpenAPI 3.1 document");
}
List<JsonNode> operations = findOperations(document, action.openapi().operationId());
if (operations.size() != EXPECTED_OPERATION_COUNT) {
throw new IllegalStateException(
"ODP Action operation_id " + action.openapi().operationId() + " must resolve exactly once");
}
return new ResolvedAction(action, null, document, operations.get(0));
}

private static DiscoveredAction normalize(Offering.Action action, String serviceOrigin, String serviceOpenApiUrl) {
if (action.http() != null) {
URI target = OdpUris.resolveResourceReference(action.http().href(), serviceOrigin);
return new DiscoveredAction(
action.authentication(),
action.id(),
action.rel(),
action.description(),
new DiscoveredAction.HttpTarget(
target.toString(),
action.http().method(),
action.http().request(),
action.http().responseContentTypes()),
null);
}
if (action.openapi() == null) {
throw new IllegalStateException("ODP Action has no usable target");
}
String reference = action.openapi().url() == null
? serviceOpenApiUrl
: action.openapi().url();
if (reference == null) {
throw new IllegalStateException("OpenAPI Action has no OpenAPI document URL");
}
URI target = OdpUris.resolveResourceReference(reference, serviceOrigin);
if (!"https".equalsIgnoreCase(target.getScheme())) {
throw new IllegalArgumentException("ODP supporting document URL must use HTTPS");
}
return new DiscoveredAction(
action.authentication(),
action.id(),
action.rel(),
action.description(),
null,
new DiscoveredAction.OpenApiTarget(
target.toString(), action.openapi().operationId()));
}

private static List<JsonNode> findOperations(JsonNode document, String operationId) {
JsonNode paths = document.get("paths");
if (paths == null || !paths.isObject()) {
throw new IllegalStateException("ODP OpenAPI document must contain paths");
}
List<JsonNode> matches = new ArrayList<>();
for (JsonNode path : paths) {
if (!path.isObject()) {
continue;
}
path.forEachEntry((method, operation) -> {
if (HTTP_METHODS.contains(method.toLowerCase(Locale.ROOT))
&& operation.isObject()
&& operationId.equals(operation.path("operationId").asString())) {
matches.add(operation);
}
});
}
return matches;
}

private static OfferingIssue issue(String actionId, String message) {
return new OfferingIssue(OfferingIssue.Scope.ACTION, message, actionId);
}

record NormalizedActions(List<DiscoveredAction> actions, List<OfferingIssue> issues) {
NormalizedActions {
actions = List.copyOf(actions);
issues = List.copyOf(issues);
}
}
}
Loading