diff --git a/README.md b/README.md index 8cc29cf..efcdd82 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/examples/src/main/java/org/offeringprotocol/odp/examples/AgentDiscovery.java b/examples/src/main/java/org/offeringprotocol/odp/examples/AgentDiscovery.java index 98f6313..d90f636 100644 --- a/examples/src/main/java/org/offeringprotocol/odp/examples/AgentDiscovery.java +++ b/examples/src/main/java/org/offeringprotocol/odp/examples/AgentDiscovery.java @@ -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)); } } } diff --git a/odp-agent/README.md b/odp-agent/README.md index ec12994..5494768 100644 --- a/odp-agent/README.md +++ b/odp-agent/README.md @@ -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: @@ -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 diff --git a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/ActionResolver.java b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/ActionResolver.java new file mode 100644 index 0000000..6904a38 --- /dev/null +++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/ActionResolver.java @@ -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 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 actions, String serviceOrigin, String serviceOpenApiUrl) { + if (actions == null || actions.isEmpty()) { + return new NormalizedActions(List.of(), List.of()); + } + Map counts = new HashMap<>(); + actions.forEach(action -> counts.merge(action.id(), 1, Integer::sum)); + Set reportedDuplicates = new HashSet<>(); + List normalized = new ArrayList<>(); + List 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 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 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 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 actions, List issues) { + NormalizedActions { + actions = List.copyOf(actions); + issues = List.copyOf(issues); + } + } +} diff --git a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/AttributeSchemaResolver.java b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/AttributeSchemaResolver.java new file mode 100644 index 0000000..8791c96 --- /dev/null +++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/AttributeSchemaResolver.java @@ -0,0 +1,211 @@ +package org.offeringprotocol.odp.agent; + +import com.networknt.schema.InputFormat; +import com.networknt.schema.Schema; +import com.networknt.schema.SchemaLocation; +import com.networknt.schema.SchemaRegistry; +import com.networknt.schema.SpecificationVersion; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.offeringprotocol.odp.core.OdpJson; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.ObjectNode; + +final class AttributeSchemaResolver { + private static final String DIALECT = "https://json-schema.org/draft/2020-12/schema"; + private static final String DEFINITIONS = "$defs"; + private static final String IDENTIFIER = "$id"; + private static final String STANDARD_VOCABULARY = "https://json-schema.org/draft/2020-12/vocab/"; + private static final int MAXIMUM_DEPTH = 8; + private static final int MAXIMUM_DOCUMENT_BYTES = 262_144; + private static final int MAXIMUM_DOCUMENTS = 16; + private static final int MAXIMUM_GRAPH_BYTES = 1_048_576; + + private final SupportingJsonClient client; + + AttributeSchemaResolver(SupportingJsonClient client) { + this.client = client; + } + + ResolvedSchema resolve(URI reference) { + URI root = withoutFragment(reference); + SchemaGraph graph = new SchemaGraph(); + load(root, 0, graph); + JsonNode bundled = bundle(root, graph.documents); + Map registryDocuments = new HashMap<>(); + graph.documents.forEach((url, document) -> registryDocuments.put(url.toString(), document.toString())); + SchemaRegistry registry = SchemaRegistry.withDefaultDialect( + SpecificationVersion.DRAFT_2020_12, builder -> builder.schemas(registryDocuments)); + Schema validator = registry.getSchema(SchemaLocation.of(root.toString())); + return new ResolvedSchema(bundled, validator); + } + + private void load(URI target, int depth, SchemaGraph graph) { + if (graph.documents.containsKey(target)) { + return; + } + if (graph.documents.size() >= MAXIMUM_DOCUMENTS) { + throw new IllegalStateException("ODP Attribute Schema graph exceeds 16 documents"); + } + if (depth > MAXIMUM_DEPTH) { + throw new IllegalStateException("ODP Attribute Schema graph exceeds eight reference levels"); + } + JsonNode document = client.get( + target, "application/schema+json", Set.of("application/schema+json"), MAXIMUM_DOCUMENT_BYTES, 16); + requireSchema(document); + graph.bytes += document.toString().getBytes(StandardCharsets.UTF_8).length; + if (graph.bytes > MAXIMUM_GRAPH_BYTES) { + throw new IllegalStateException("ODP Attribute Schema graph exceeds its byte limit"); + } + graph.documents.put(target, document); + for (URI external : externalReferences(document, target)) { + load(external, depth + 1, graph); + } + } + + private static void requireSchema(JsonNode document) { + if (!DIALECT.equals(document.path("$schema").asString())) { + throw new IllegalStateException("ODP Attribute Schema must declare JSON Schema Draft 2020-12"); + } + visit(document, node -> { + JsonNode dynamicReference = node.get("$dynamicRef"); + if (dynamicReference != null + && (!dynamicReference.isString() + || !dynamicReference.asString().startsWith("#"))) { + throw new IllegalStateException("ODP Attribute Schema $dynamicRef must be a fragment-only reference"); + } + JsonNode vocabulary = node.get("$vocabulary"); + if (vocabulary != null && vocabulary.isObject()) { + vocabulary.forEachEntry((uri, required) -> { + if (required.asBoolean(false) && !uri.startsWith(STANDARD_VOCABULARY)) { + throw new IllegalStateException("ODP Attribute Schema requires unsupported vocabulary " + uri); + } + }); + } + }); + } + + private static List externalReferences(JsonNode document, URI retrievalUrl) { + Set localResources = new HashSet<>(); + collectResourceIdentifiers(document, retrievalUrl, localResources); + Set references = new HashSet<>(); + collectReferences(document, retrievalUrl, references); + references.removeAll(localResources); + return references.stream().sorted().toList(); + } + + private static void collectResourceIdentifiers(JsonNode value, URI base, Set result) { + URI current = resolveIdentifier(value, base); + result.add(withoutFragment(current)); + for (JsonNode child : value) { + collectResourceIdentifiers(child, current, result); + } + } + + private static void collectReferences(JsonNode value, URI base, Set result) { + URI current = resolveIdentifier(value, base); + JsonNode reference = value.isObject() ? value.get("$ref") : null; + if (reference != null) { + if (!reference.isString()) { + throw new IllegalStateException("ODP Attribute Schema $ref must be a string"); + } + URI resolved = current.resolve(reference.asString()); + requireHttps(resolved, "ODP Attribute Schema references must use HTTPS"); + result.add(withoutFragment(resolved)); + } + for (JsonNode child : value) { + collectReferences(child, current, result); + } + } + + private static URI resolveIdentifier(JsonNode value, URI base) { + JsonNode identifier = value.isObject() ? value.get(IDENTIFIER) : null; + if (identifier == null) { + return base; + } + if (!identifier.isString()) { + throw new IllegalStateException("ODP Attribute Schema $id must be a string"); + } + URI resolved = base.resolve(identifier.asString()); + requireHttps(resolved, "ODP Attribute Schema identifiers must use HTTPS"); + return resolved; + } + + private static JsonNode bundle(URI rootUrl, Map documents) { + ObjectNode root = documents.get(rootUrl).deepCopy().asObject(); + if (!root.has(IDENTIFIER)) { + root.put(IDENTIFIER, rootUrl.toString()); + } + List externalUrls = new ArrayList<>(documents.keySet()); + externalUrls.remove(rootUrl); + externalUrls.sort(URI::compareTo); + if (!externalUrls.isEmpty()) { + ObjectNode definitions = + root.has(DEFINITIONS) && root.get(DEFINITIONS).isObject() + ? root.get(DEFINITIONS).deepCopy().asObject() + : root.putObject(DEFINITIONS); + int index = 0; + for (URI externalUrl : externalUrls) { + String key = "odp_external_" + index; + index++; + while (definitions.has(key)) { + key = key + "_"; + } + ObjectNode external = documents.get(externalUrl).deepCopy().asObject(); + if (!external.has(IDENTIFIER)) { + external.put(IDENTIFIER, externalUrl.toString()); + } + definitions.set(key, external); + } + root.set(DEFINITIONS, definitions); + } + return root; + } + + private static void visit(JsonNode value, NodeVisitor visitor) { + if (value.isObject()) { + visitor.visit(value); + } + for (JsonNode child : value) { + visit(child, visitor); + } + } + + private static URI withoutFragment(URI value) { + requireHttps(value, "ODP Attribute Schema URL must use HTTPS"); + String text = value.toString(); + int fragment = text.indexOf('#'); + return URI.create(fragment == -1 ? text : text.substring(0, fragment)); + } + + private static void requireHttps(URI value, String message) { + if (!"https".equalsIgnoreCase(value.getScheme()) || value.getHost() == null) { + throw new IllegalStateException(message); + } + } + + record ResolvedSchema(JsonNode document, Schema validator) { + boolean validates(Map attributes) { + return validator + .validate(OdpJson.write(attributes), InputFormat.JSON) + .isEmpty(); + } + } + + private static final class SchemaGraph { + private final Map documents = new LinkedHashMap<>(); + private int bytes; + } + + @FunctionalInterface + private interface NodeVisitor { + void visit(JsonNode value); + } +} diff --git a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/DiscoveredAction.java b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/DiscoveredAction.java new file mode 100644 index 0000000..5ba318a --- /dev/null +++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/DiscoveredAction.java @@ -0,0 +1,23 @@ +package org.offeringprotocol.odp.agent; + +import java.util.List; +import org.offeringprotocol.odp.core.AuthenticationRequirement; +import org.offeringprotocol.odp.core.Offering; + +public record DiscoveredAction( + AuthenticationRequirement authentication, + String id, + String rel, + String description, + HttpTarget http, + OpenApiTarget openapi) { + + public record HttpTarget( + String url, String method, Offering.ActionRequest request, List responseContentTypes) { + public HttpTarget { + responseContentTypes = responseContentTypes == null ? List.of() : List.copyOf(responseContentTypes); + } + } + + public record OpenApiTarget(String url, String operationId) {} +} diff --git a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OdpServiceClient.java b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OdpServiceClient.java index 0541fa9..e9b9436 100644 --- a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OdpServiceClient.java +++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OdpServiceClient.java @@ -7,7 +7,9 @@ import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; @@ -22,23 +24,35 @@ import org.offeringprotocol.odp.core.Page; import org.offeringprotocol.odp.core.ProblemDetails; import org.offeringprotocol.odp.core.SearchRequests; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.json.JsonMapper; /** Validated ODP Service inspection and catalog client. */ public final class OdpServiceClient { private static final int MAXIMUM_BYTES = 2_097_152; private static final int MAXIMUM_REDIRECTS = 5; private static final String GET = "GET"; + private static final JsonMapper JSON = JsonMapper.builder().build(); private static final HttpClient DEFAULT_HTTP_CLIENT = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .followRedirects(HttpClient.Redirect.NEVER) .build(); private final OdpTransport transport; + private final ActionResolver actionResolver; + private final AttributeSchemaResolver schemaResolver; private final String serviceOrigin; private final ServiceInspection serviceInspection; - private OdpServiceClient(OdpTransport transport, String serviceOrigin, ServiceInspection inspection) { + private OdpServiceClient( + OdpTransport transport, + ActionResolver actionResolver, + AttributeSchemaResolver schemaResolver, + String serviceOrigin, + ServiceInspection inspection) { this.transport = transport; + this.actionResolver = actionResolver; + this.schemaResolver = schemaResolver; this.serviceOrigin = serviceOrigin; this.serviceInspection = inspection; } @@ -49,8 +63,16 @@ public static OdpServiceClient create(URI serviceUri) { } public static OdpServiceClient create(URI serviceUri, OdpTransport transport) { + return create( + serviceUri, + transport, + request -> DEFAULT_HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofByteArray())); + } + + public static OdpServiceClient create(URI serviceUri, OdpTransport transport, OdpTransport supportingTransport) { Objects.requireNonNull(serviceUri, "serviceUri"); Objects.requireNonNull(transport, "transport"); + Objects.requireNonNull(supportingTransport, "supportingTransport"); String origin = OdpUris.deriveServiceOrigin(serviceUri); URI documentUri = URI.create(origin).resolve(Odp.SERVICE_DOCUMENT_PATH); String json = request(transport, documentUri, GET, null, null, 524_288); @@ -59,8 +81,14 @@ public static OdpServiceClient create(URI serviceUri, OdpTransport transport) { for (OperationDescriptor operation : document.operations()) { operations.put(operation.name(), operation); } + SupportingJsonClient supportingClient = new SupportingJsonClient(supportingTransport); + AttributeSchemaResolver schemaResolver = new AttributeSchemaResolver(supportingClient); return new OdpServiceClient( - transport, origin, new ServiceInspection(origin, documentUri, document, Map.copyOf(operations))); + transport, + new ActionResolver(supportingClient, schemaResolver), + schemaResolver, + origin, + new ServiceInspection(origin, documentUri, document, Map.copyOf(operations))); } public ServiceInspection inspection() { @@ -105,6 +133,47 @@ public Offering getOffering(String id, String representation, String language) { requestOperation(OdpOperation.GET_OFFERING, id, representation, null, language, null)); } + public OfferingDetails getOfferingDetails(String id, String language) { + Offering offering = getOffering(id, "full", language); + String serviceOpenApiUrl = serviceInspection.document().http().openapi() == null + ? null + : serviceInspection.document().http().openapi().url(); + ActionResolver.NormalizedActions normalized = + actionResolver.normalize(offering.actions(), serviceOrigin, serviceOpenApiUrl); + List issues = new ArrayList<>(normalized.issues()); + Offering safeOffering = offering; + tools.jackson.databind.JsonNode attributeSchema = null; + if (offering.schema() != null) { + try { + URI reference = + OdpUris.resolveResourceReference(offering.schema().url(), serviceOrigin); + AttributeSchemaResolver.ResolvedSchema resolved = schemaResolver.resolve(reference); + attributeSchema = resolved.document(); + if (offering.attributes() != null && !resolved.validates(offering.attributes())) { + safeOffering = withoutAttributes(offering); + issues.add(new OfferingIssue( + OfferingIssue.Scope.ATTRIBUTES, + "Offering attributes do not match their Attribute Schema", + null)); + } + } catch (IllegalArgumentException | IllegalStateException exception) { + safeOffering = withoutAttributes(offering); + issues.add(new OfferingIssue(OfferingIssue.Scope.ATTRIBUTE_SCHEMA, exception.getMessage(), null)); + } + } + return new OfferingDetails(safeOffering, attributeSchema, normalized.actions(), issues); + } + + public ResolvedAction resolveAction(String offeringId, String actionId, String language) { + OfferingDetails details = getOfferingDetails(offeringId, language); + DiscoveredAction action = details.actions().stream() + .filter(candidate -> candidate.id().equals(actionId)) + .findFirst() + .orElseThrow( + () -> new IllegalArgumentException("ODP Offering does not expose usable Action " + actionId)); + return actionResolver.resolve(action, serviceOrigin); + } + public Page continueCollections(String next, String language) { URI target = OdpUris.resolveContinuation(next, serviceOrigin); return collectionPage(request(transport, target, GET, null, language, MAXIMUM_BYTES)); @@ -158,6 +227,16 @@ private static void requireSummary(String identifier, String name, String resour } } + private static Offering withoutAttributes(Offering offering) { + try { + var document = JSON.readTree(OdpJson.write(offering)).asObject(); + document.remove("attributes"); + return OdpJson.parseOffering(document.toString()); + } catch (JacksonException exception) { + throw new IllegalStateException("Unable to normalize ODP Offering", exception); + } + } + private static String request( OdpTransport transport, URI target, String method, String body, String language, int maximumBytes) { URI current = target; diff --git a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OfferingDetails.java b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OfferingDetails.java new file mode 100644 index 0000000..7504dd8 --- /dev/null +++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OfferingDetails.java @@ -0,0 +1,21 @@ +package org.offeringprotocol.odp.agent; + +import java.util.List; +import org.offeringprotocol.odp.core.Offering; +import tools.jackson.databind.JsonNode; + +public record OfferingDetails( + Offering offering, JsonNode attributeSchema, List actions, List issues) { + public OfferingDetails { + if (attributeSchema != null) { + attributeSchema = attributeSchema.deepCopy(); + } + actions = actions == null ? List.of() : List.copyOf(actions); + issues = issues == null ? List.of() : List.copyOf(issues); + } + + @Override + public JsonNode attributeSchema() { + return attributeSchema == null ? null : attributeSchema.deepCopy(); + } +} diff --git a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OfferingIssue.java b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OfferingIssue.java new file mode 100644 index 0000000..7091bee --- /dev/null +++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OfferingIssue.java @@ -0,0 +1,9 @@ +package org.offeringprotocol.odp.agent; + +public record OfferingIssue(Scope scope, String message, String actionId) { + public enum Scope { + ACTION, + ATTRIBUTE_SCHEMA, + ATTRIBUTES + } +} diff --git a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/ResolvedAction.java b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/ResolvedAction.java new file mode 100644 index 0000000..ea58f3c --- /dev/null +++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/ResolvedAction.java @@ -0,0 +1,31 @@ +package org.offeringprotocol.odp.agent; + +import tools.jackson.databind.JsonNode; + +public record ResolvedAction( + DiscoveredAction action, JsonNode requestSchema, JsonNode openApiDocument, JsonNode operation) { + public ResolvedAction { + requestSchema = copy(requestSchema); + openApiDocument = copy(openApiDocument); + operation = copy(operation); + } + + @Override + public JsonNode requestSchema() { + return copy(requestSchema); + } + + @Override + public JsonNode openApiDocument() { + return copy(openApiDocument); + } + + @Override + public JsonNode operation() { + return copy(operation); + } + + private static JsonNode copy(JsonNode value) { + return value == null ? null : value.deepCopy(); + } +} diff --git a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/SupportingJsonClient.java b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/SupportingJsonClient.java new file mode 100644 index 0000000..b4cabad --- /dev/null +++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/SupportingJsonClient.java @@ -0,0 +1,122 @@ +package org.offeringprotocol.odp.agent; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Locale; +import java.util.Set; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +final class SupportingJsonClient { + private static final int MAXIMUM_REDIRECTS = 5; + private static final JsonMapper JSON = JsonMapper.builder().build(); + + private final OdpTransport transport; + + SupportingJsonClient(OdpTransport transport) { + this.transport = transport; + } + + JsonNode get(URI target, String accept, Set mediaTypes, int maximumBytes, int maximumDepth) { + requireHttps(target); + URI current = target; + for (int redirects = 0; redirects <= MAXIMUM_REDIRECTS; redirects++) { + HttpRequest request = HttpRequest.newBuilder(current) + .timeout(Duration.ofSeconds(30)) + .header("Accept", accept) + .GET() + .build(); + HttpResponse response = send(request); + int status = response.statusCode(); + if (isRedirect(status)) { + if (redirects == MAXIMUM_REDIRECTS) { + throw new IllegalStateException("ODP supporting resource exceeded its redirect limit"); + } + URI next = current.resolve(response.headers() + .firstValue("Location") + .orElseThrow( + () -> new IllegalStateException("ODP supporting resource redirect omitted Location"))); + requireHttps(next); + if (!sameOrigin(current, next)) { + throw new IllegalStateException("ODP supporting resource redirect changed origin"); + } + current = next; + } else { + if (status < 200 || status > 299) { + throw new IllegalStateException("ODP supporting resource request failed with HTTP " + status); + } + byte[] bytes = response.body(); + if (bytes.length > maximumBytes) { + throw new IllegalStateException("ODP supporting resource exceeds its byte limit"); + } + String contentType = + response.headers().firstValue("Content-Type").orElse(""); + String essence = contentType.split(";", 2)[0].trim().toLowerCase(Locale.ROOT); + if (!mediaTypes.contains(essence)) { + throw new IllegalStateException("ODP supporting resource returned an unsupported Content-Type"); + } + JsonNode document = parse(bytes); + if (!document.isObject()) { + throw new IllegalStateException("ODP supporting resource must be a JSON object"); + } + if (depth(document) > maximumDepth) { + throw new IllegalStateException("ODP supporting resource exceeds its JSON depth limit"); + } + return document; + } + } + throw new IllegalStateException("ODP supporting resource produced no response"); + } + + private HttpResponse send(HttpRequest request) { + try { + return transport.send(request); + } catch (IOException exception) { + throw new IllegalStateException("ODP supporting resource request failed", exception); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("ODP supporting resource request was interrupted", exception); + } + } + + private static JsonNode parse(byte[] bytes) { + try { + return JSON.readTree(new String(bytes, StandardCharsets.UTF_8)); + } catch (JacksonException exception) { + throw new IllegalStateException("ODP supporting resource must contain valid JSON", exception); + } + } + + private static int depth(JsonNode value) { + int maximum = 1; + for (JsonNode child : value) { + maximum = Math.max(maximum, 1 + depth(child)); + } + return maximum; + } + + private static boolean isRedirect(int status) { + return status == 301 || status == 302 || status == 303 || status == 307 || status == 308; + } + + private static void requireHttps(URI target) { + if (!"https".equalsIgnoreCase(target.getScheme()) || target.getHost() == null) { + throw new IllegalArgumentException("ODP supporting document URL must use HTTPS"); + } + } + + private static boolean sameOrigin(URI left, URI right) { + return left.getScheme().equalsIgnoreCase(right.getScheme()) + && left.getHost().equalsIgnoreCase(right.getHost()) + && effectivePort(left) == effectivePort(right); + } + + private static int effectivePort(URI value) { + return value.getPort() == -1 ? 443 : value.getPort(); + } +} diff --git a/odp-agent/src/test/java/org/offeringprotocol/odp/agent/OfferingDetailsTest.java b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/OfferingDetailsTest.java new file mode 100644 index 0000000..3e38dfa --- /dev/null +++ b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/OfferingDetailsTest.java @@ -0,0 +1,222 @@ +package org.offeringprotocol.odp.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +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.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import javax.net.ssl.SSLSession; +import org.junit.jupiter.api.Test; + +class OfferingDetailsTest { + private static final String SERVICE_DOCUMENT = """ + {"odp_version":"1.0","name":"Plant Store","description":"Plants for agents.", + "language":"en","localizations":["en"],"operations":[ + {"authentication":"not-required","name":"get-offering"}, + {"authentication":"not-required","name":"list-offerings"}], + "http":{"endpoint_base":"/odp","openapi":{"url":"/openapi.json"}}} + """; + private static final String OFFERING = """ + {"odp_version":"1.0","id":"gpu","name":"GPU","attributes":{"memory":80}, + "schema":{"url":"https://schemas.example/root.json"},"actions":[ + {"authentication":"not-required","id":"purchase","rel":"purchase","http":{ + "href":"/purchase","method":"POST","request":{ + "content_type":"application/json","schema":{"url":"https://schemas.example/request.json"}}}}, + {"authentication":"required","id":"quote","rel":"quote","openapi":{ + "operation_id":"quoteGpu"}}]} + """; + private static final String ROOT_SCHEMA = """ + {"$schema":"https://json-schema.org/draft/2020-12/schema", + "$id":"https://schemas.example/root.json", + "$ref":"https://schemas.example/common.json"} + """; + private static final String COMMON_SCHEMA = """ + {"$schema":"https://json-schema.org/draft/2020-12/schema", + "$id":"https://schemas.example/common.json","$dynamicAnchor":"node","type":"object", + "properties":{"memory":{"type":"integer","minimum":1},"children":{"type":"array", + "items":{"$dynamicRef":"#node"}}},"required":["memory"]} + """; + private static final String REQUEST_SCHEMA = """ + {"$schema":"https://json-schema.org/draft/2020-12/schema", + "type":"object","properties":{"hours":{"type":"integer"}},"required":["hours"]} + """; + private static final String OPENAPI = """ + {"openapi":"3.1.1","info":{"title":"Plant API","version":"1"},"paths":{ + "/quotes":{"post":{"operationId":"quoteGpu","responses":{"200":{"description":"Quote"}}}}}} + """; + + @Test + void resolvesOfferingSchemaAndNormalizesActions() { + Fixture fixture = fixture(OFFERING, ROOT_SCHEMA); + + OfferingDetails details = fixture.client().getOfferingDetails("gpu", "en"); + + assertEquals(80, details.offering().attributes().get("memory").asInt()); + assertNotNull(details.attributeSchema().get("$defs")); + assertTrue(details.issues().isEmpty()); + assertEquals( + "https://plants.example/purchase", + details.actions().get(0).http().url()); + assertEquals( + "https://plants.example/openapi.json", + details.actions().get(1).openapi().url()); + assertTrue(fixture.supportingRequests().stream() + .allMatch( + request -> request.headers().firstValue("Authorization").isEmpty())); + } + + @Test + void omitsAttributesThatDoNotMatchTheirSchema() { + Fixture fixture = fixture(OFFERING.replace("\"memory\":80", "\"memory\":\"large\""), ROOT_SCHEMA); + + OfferingDetails details = fixture.client().getOfferingDetails("gpu", null); + + assertNull(details.offering().attributes()); + assertNotNull(details.attributeSchema()); + assertEquals(OfferingIssue.Scope.ATTRIBUTES, details.issues().get(0).scope()); + } + + @Test + void retainsOfferingWhenDynamicReferenceIsUnsupported() { + for (String dynamicReference : + List.of("\"https://schemas.example/common.json#node\"", "\"common.json#node\"", "null")) { + String unsupported = """ + {"$schema":"https://json-schema.org/draft/2020-12/schema","$dynamicRef":%s} + """.formatted(dynamicReference); + Fixture fixture = fixture(OFFERING, unsupported); + + OfferingDetails details = fixture.client().getOfferingDetails("gpu", null); + + assertEquals("GPU", details.offering().name()); + assertNull(details.offering().attributes()); + assertNull(details.attributeSchema()); + assertEquals( + OfferingIssue.Scope.ATTRIBUTE_SCHEMA, + details.issues().get(0).scope()); + } + } + + @Test + void resolvesHttpRequestSchemaAndOpenApiOperation() { + Fixture fixture = fixture(OFFERING, ROOT_SCHEMA); + + ResolvedAction purchase = fixture.client().resolveAction("gpu", "purchase", null); + ResolvedAction quote = fixture.client().resolveAction("gpu", "quote", null); + + assertEquals("object", purchase.requestSchema().get("type").asString()); + assertNull(purchase.openApiDocument()); + assertEquals("3.1.1", quote.openApiDocument().get("openapi").asString()); + assertEquals("quoteGpu", quote.operation().get("operationId").asString()); + } + + @Test + void reportsDuplicateAndUnusableActionsWithoutRejectingOffering() { + String invalidActions = OFFERING.replace( + "]}", + ",{" + "\"authentication\":\"not-required\",\"id\":\"purchase\"," + + "\"rel\":\"purchase\",\"http\":{\"href\":\"/other\",\"method\":\"POST\"}}]}"); + Fixture fixture = fixture(invalidActions, ROOT_SCHEMA); + + OfferingDetails details = fixture.client().getOfferingDetails("gpu", null); + + assertEquals("GPU", details.offering().name()); + assertFalse(details.actions().stream().anyMatch(action -> action.id().equals("purchase"))); + assertEquals("purchase", details.issues().get(0).actionId()); + } + + private static Fixture fixture(String offering, String rootSchema) { + OdpTransport service = request -> response( + request, + switch (request.uri().getPath()) { + case "/.well-known/odp" -> SERVICE_DOCUMENT; + case "/odp/offerings/gpu" -> offering; + default -> throw new AssertionError("Unexpected Service request " + request.uri()); + }, + "application/odp+json"); + List requests = new ArrayList<>(); + OdpTransport supporting = request -> { + requests.add(request); + String body; + String contentType; + switch (request.uri().toString()) { + case "https://schemas.example/root.json" -> { + body = rootSchema; + contentType = "application/schema+json"; + } + case "https://schemas.example/common.json" -> { + body = COMMON_SCHEMA; + contentType = "application/schema+json"; + } + case "https://schemas.example/request.json" -> { + body = REQUEST_SCHEMA; + contentType = "application/schema+json"; + } + case "https://plants.example/openapi.json" -> { + body = OPENAPI; + contentType = "application/vnd.oai.openapi+json;version=3.1"; + } + default -> throw new AssertionError("Unexpected supporting request " + request.uri()); + } + return response(request, body, contentType); + }; + return new Fixture( + OdpServiceClient.create(URI.create("https://plants.example"), service, supporting), requests); + } + + private static HttpResponse response(HttpRequest request, String body, String contentType) { + return new HttpResponse<>() { + @Override + public int statusCode() { + return 200; + } + + @Override + public HttpRequest request() { + return request; + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return HttpHeaders.of(Map.of("Content-Type", List.of(contentType)), (left, right) -> true); + } + + @Override + public byte[] body() { + return body.getBytes(StandardCharsets.UTF_8); + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + + @Override + public URI uri() { + return request.uri(); + } + + @Override + public HttpClient.Version version() { + return HttpClient.Version.HTTP_1_1; + } + }; + } + + private record Fixture(OdpServiceClient client, List supportingRequests) {} +} diff --git a/odp-core/README.md b/odp-core/README.md index 92ed97e..8f415f5 100644 --- a/odp-core/README.md +++ b/odp-core/README.md @@ -106,6 +106,9 @@ stop before invoking the next loader. MPP or x402, such as `INFLOW`, `SOLANA`, or `BASE`. These values summarize compatibility for discovery and filtering. Live MPP and x402 responses remain authoritative for exact payment terms. +`ServiceDocument.TrustProtocol` represents advertised trust support. A Service that accepts Visa +Trusted Agent Protocol requests declares a single `tap` descriptor in `protocols.trust`. + ## Related documentation - [Agent integration](../odp-agent/README.md) diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpJson.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpJson.java index 781fbe7..5b4fa56 100644 --- a/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpJson.java +++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpJson.java @@ -72,13 +72,31 @@ public static SearchRequests.Offerings parseOfferingSearchRequest(String json) { } public static OfferingPage parseOfferingSearchResponse(String json) { - return parse(json, "offering-search-response.schema.json", "Offering search response", OfferingPage.class); + OfferingPage page = + parse(json, "offering-search-response.schema.json", "Offering search response", OfferingPage.class); + page.items() + .forEach(item -> + parseOffering(withInheritedVersion(write(item), page.odpVersion(), item.odpVersion() != null))); + return page; } public static Page parsePage(String json, Class itemType) { validate(json, "page-envelope.schema.json", "page envelope"); JavaType type = MAPPER.getTypeFactory().constructParametricType(Page.class, itemType); - return decode(json, type, "page envelope"); + Page page = decode(json, type, "page envelope"); + if (itemType == Collection.class) { + page.items().forEach(item -> { + Collection collection = (Collection) item; + parseCollection( + withInheritedVersion(write(collection), page.odpVersion(), collection.odpVersion() != null)); + }); + } else if (itemType == Offering.class) { + page.items().forEach(item -> { + Offering offering = (Offering) item; + parseOffering(withInheritedVersion(write(offering), page.odpVersion(), offering.odpVersion() != null)); + }); + } + return page; } public static String write(Object value) { @@ -89,6 +107,13 @@ public static String write(Object value) { } } + private static String withInheritedVersion(String json, String version, boolean present) { + if (present) { + return json; + } + return "{\"odp_version\":" + write(version) + (json.length() == 2 ? "}" : "," + json.substring(1)); + } + private static T parse(String json, String schemaName, String documentType, Class type) { validate(json, schemaName, documentType); return decode(json, MAPPER.getTypeFactory().constructType(type), documentType); diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/ServiceDocument.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/ServiceDocument.java index 60e3309..afd1c18 100644 --- a/odp-core/src/main/java/org/offeringprotocol/odp/core/ServiceDocument.java +++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/ServiceDocument.java @@ -372,10 +372,16 @@ public record BrandingImage(String src, String type) {} public record McpEndpoint(String description, String name, String type, String url) {} - public record Protocols(List enrollment, List payments) { + public record Protocols( + List enrollment, List payments, List trust) { public Protocols { enrollment = Copies.list(enrollment); payments = Copies.list(payments); + trust = Copies.list(trust); + } + + public Protocols(List enrollment, List payments) { + this(enrollment, payments, null); } } @@ -386,4 +392,6 @@ public record PaymentProtocol(AuthenticationRequirement authentication, String n options = Copies.list(options); } } + + public record TrustProtocol(String name) {} } diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/index.txt b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/index.txt index 4f36528..5cc9f13 100644 --- a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/index.txt +++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/index.txt @@ -53,3 +53,4 @@ sort-definition-page.schema.json sort-definition.schema.json sort-key.schema.json top-level-document.schema.json +trust-protocol.schema.json diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-protocols.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-protocols.schema.json index d8fa313..96b81b5 100644 --- a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-protocols.schema.json +++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-protocols.schema.json @@ -22,6 +22,15 @@ "items": { "$ref": "payment-protocol.schema.json" } + }, + "trust": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "uniqueItems": true, + "items": { + "$ref": "trust-protocol.schema.json" + } } }, "allOf": [ diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/trust-protocol.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/trust-protocol.schema.json new file mode 100644 index 0000000..4214228 --- /dev/null +++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/trust-protocol.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://offeringprotocol.org/schemas/trust-protocol.schema.json", + "title": "ODP Trust Protocol Descriptor", + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "const": "tap" + } + }, + "additionalProperties": false +} diff --git a/odp-core/src/test/java/org/offeringprotocol/odp/core/OdpJsonTest.java b/odp-core/src/test/java/org/offeringprotocol/odp/core/OdpJsonTest.java index d8a247d..aac5816 100644 --- a/odp-core/src/test/java/org/offeringprotocol/odp/core/OdpJsonTest.java +++ b/odp-core/src/test/java/org/offeringprotocol/odp/core/OdpJsonTest.java @@ -55,6 +55,17 @@ void buildsAndRoundTripsServiceDocuments() { assertEquals(List.of("en"), decoded.localizations()); } + @Test + void parsesTapTrustProtocol() { + ServiceDocument document = OdpJson.parseServiceDocument(DOCUMENT.replace( + "\"example_extension\":{\"enabled\":true}", + "\"protocols\":{\"trust\":[{\"name\":\"tap\"}]},\"example_extension\":{\"enabled\":true}")); + + assertEquals( + List.of(new ServiceDocument.TrustProtocol("tap")), + document.protocols().trust()); + } + @Test void rejectsInvalidServiceDocuments() { OdpValidationException exception = assertThrows( @@ -108,6 +119,22 @@ void preservesAbsentOfferingAttributes() { assertTrue(!OdpJson.write(offering).contains("attributes")); } + @Test + void validatesEmbeddedRepresentationsWithThePageVersion() { + Page collections = OdpJson.parsePage( + "{\"items\":[{\"description\":\"odp_version\",\"id\":\"plants\",\"name\":\"Plants\"}],\"odp_version\":\"1.0\"}", + Collection.class); + OfferingPage offerings = OdpJson.parseOfferingSearchResponse( + "{\"items\":[{\"id\":\"plant\",\"name\":\"Plant\"}],\"odp_version\":\"1.0\"}"); + + assertNull(collections.items().get(0).odpVersion()); + assertNull(offerings.items().get(0).odpVersion()); + assertThrows( + OdpValidationException.class, + () -> OdpJson.parseOfferingSearchResponse( + "{\"items\":[{\"id\":\"bad/id\",\"name\":\"Plant\"}],\"odp_version\":\"1.0\"}")); + } + @Test void resolvesOperationUrisAndCanonicalIdentity() { URI operation = diff --git a/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpService.java b/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpService.java index 06a41bf..d7ee033 100644 --- a/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpService.java +++ b/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpService.java @@ -6,9 +6,12 @@ import java.util.Map; import java.util.Objects; import org.offeringprotocol.odp.core.AuthenticationRequirement; +import org.offeringprotocol.odp.core.Collection; import org.offeringprotocol.odp.core.Odp; import org.offeringprotocol.odp.core.OdpJson; import org.offeringprotocol.odp.core.OdpOperation; +import org.offeringprotocol.odp.core.OdpValidationException; +import org.offeringprotocol.odp.core.Offering; import org.offeringprotocol.odp.core.OperationDescriptor; import org.offeringprotocol.odp.core.ProblemDetails; import org.offeringprotocol.odp.core.SearchCapabilities; @@ -20,6 +23,7 @@ public final class OdpService { private static final int MAXIMUM_REQUEST_BYTES = 65_536; private static final String MEDIA_TYPE = "application/odp+json"; private static final String GET = "GET"; + private static final String INTERNAL_ERROR = "INTERNAL_ERROR"; private static final String NOT_FOUND = "NOT_FOUND"; private final ServiceDocument serviceDocument; @@ -84,6 +88,7 @@ public OdpHttpResponse handle(OdpHttpRequest request) { if (response == null) { return problem(404, NOT_FOUND, "ODP resource not found"); } + validateResponse(route.operation(), response, representation); return json(200, response); } catch (OdpServiceException exception) { return problem(exception.status(), exception.code(), exception.getMessage()); @@ -92,6 +97,46 @@ public OdpHttpResponse handle(OdpHttpRequest request) { } } + private static void validateResponse(OdpOperation operation, Object response, String representation) { + String json = OdpJson.write(response); + try { + switch (operation) { + case GET_COLLECTION -> validateCollection(OdpJson.parseCollection(json), representation); + case GET_OFFERING -> validateOffering(OdpJson.parseOffering(json), representation); + case LIST_COLLECTIONS, SEARCH_COLLECTIONS -> + OdpJson.parsePage(json, Collection.class) + .items() + .forEach(item -> validateCollection(item, representation)); + case LIST_COLLECTION_OFFERINGS, LIST_OFFERINGS -> + OdpJson.parsePage(json, Offering.class) + .items() + .forEach(item -> validateOffering(item, representation)); + case SEARCH_OFFERINGS -> + OdpJson.parseOfferingSearchResponse(json) + .items() + .forEach(item -> validateOffering(item, representation)); + } + } catch (OdpValidationException exception) { + throw new OdpServiceException(500, INTERNAL_ERROR, "ODP catalog returned an invalid response", exception); + } + } + + private static void validateOffering(Offering offering, String representation) { + if ("terse".equals(representation) && offering.actions() != null) { + throw new OdpServiceException(500, INTERNAL_ERROR, "ODP catalog returned Actions in a Terse Offering"); + } + if ("full".equals(representation) && offering.detailFields() != null) { + throw new OdpServiceException(500, INTERNAL_ERROR, "ODP catalog returned detail_fields in a Full Offering"); + } + } + + private static void validateCollection(Collection collection, String representation) { + if ("full".equals(representation) && collection.detailFields() != null) { + throw new OdpServiceException( + 500, INTERNAL_ERROR, "ODP catalog returned detail_fields in a Full Collection"); + } + } + private Route route(OdpHttpRequest request) { if (!request.path().startsWith(endpointBase + "/")) { throw new OdpServiceException(404, NOT_FOUND, "ODP endpoint not found"); diff --git a/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpServiceException.java b/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpServiceException.java index b7e4341..9725940 100644 --- a/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpServiceException.java +++ b/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpServiceException.java @@ -12,6 +12,12 @@ public OdpServiceException(int status, String code, String message) { this.problemCode = code; } + public OdpServiceException(int status, String code, String message, Throwable cause) { + super(message, cause); + this.responseStatus = status; + this.problemCode = code; + } + public int status() { return responseStatus; } diff --git a/odp-service/src/main/java/org/offeringprotocol/odp/service/StaticCatalog.java b/odp-service/src/main/java/org/offeringprotocol/odp/service/StaticCatalog.java index 4d7d0bc..d6be0f2 100644 --- a/odp-service/src/main/java/org/offeringprotocol/odp/service/StaticCatalog.java +++ b/odp-service/src/main/java/org/offeringprotocol/odp/service/StaticCatalog.java @@ -48,7 +48,7 @@ public static Map create( Map handlers = new LinkedHashMap<>(); handlers.put( OdpOperation.LIST_OFFERINGS, - endpoint(request -> page(catalogOfferings, request, StaticCatalog::terseOffering, key))); + endpoint(request -> page(catalogOfferings, request, StaticCatalog::terseOfferingItem, key))); handlers.put( OdpOperation.GET_OFFERING, endpoint(request -> @@ -56,7 +56,7 @@ public static Map create( if (!catalogCollections.isEmpty()) { handlers.put( OdpOperation.LIST_COLLECTIONS, - endpoint(request -> page(catalogCollections, request, StaticCatalog::terseCollection, key))); + endpoint(request -> page(catalogCollections, request, StaticCatalog::terseCollectionItem, key))); handlers.put( OdpOperation.GET_COLLECTION, endpoint(request -> represent( @@ -69,7 +69,7 @@ public static Map create( .filter(offering -> offering.collectionIds() != null && offering.collectionIds().contains(request.identifier())) .toList(); - return page(matches, request, StaticCatalog::terseOffering, key); + return page(matches, request, StaticCatalog::terseOfferingItem, key); })); } return Map.copyOf(handlers); @@ -170,9 +170,17 @@ private static T represent(T value, CatalogRequest request, Function t } private static Offering terseOffering(Offering value) { + return terseOffering(value, false); + } + + private static Offering terseOfferingItem(Offering value) { + return terseOffering(value, true); + } + + private static Offering terseOffering(Offering value, boolean embedded) { return new Offering( value.authExpands(), - null, + embedded ? null : value.odpVersion(), value.id(), value.name(), value.description(), @@ -190,9 +198,17 @@ private static Offering terseOffering(Offering value) { } private static Collection terseCollection(Collection value) { + return terseCollection(value, false); + } + + private static Collection terseCollectionItem(Collection value) { + return terseCollection(value, true); + } + + private static Collection terseCollection(Collection value, boolean embedded) { return new Collection( value.authExpands(), - null, + embedded ? null : value.odpVersion(), value.id(), value.name(), value.description(), diff --git a/odp-service/src/test/java/org/offeringprotocol/odp/service/OdpServiceTest.java b/odp-service/src/test/java/org/offeringprotocol/odp/service/OdpServiceTest.java index 8364d01..eaff21b 100644 --- a/odp-service/src/test/java/org/offeringprotocol/odp/service/OdpServiceTest.java +++ b/odp-service/src/test/java/org/offeringprotocol/odp/service/OdpServiceTest.java @@ -9,6 +9,7 @@ import java.util.Map; import org.junit.jupiter.api.Test; import org.offeringprotocol.odp.core.AuthenticationRequirement; +import org.offeringprotocol.odp.core.Collection; import org.offeringprotocol.odp.core.Odp; import org.offeringprotocol.odp.core.OdpOperation; import org.offeringprotocol.odp.core.Offering; @@ -51,7 +52,12 @@ void servesTheMinimumStaticCatalog() { assertTrue(document.body().contains("list-offerings")); assertTrue(document.body().contains("\"authentication\":\"required\"")); assertTrue(list.body().contains("Rubber Plant")); + assertEquals(2, occurrences(list.body(), "\"odp_version\":\"1.0\"")); assertTrue(detail.body().contains("plant-1")); + assertEquals(1, occurrences(detail.body(), "\"odp_version\":\"1.0\"")); + + OdpHttpResponse terseList = service.handle(request("GET", "/odp/offerings", Map.of())); + assertEquals(1, occurrences(terseList.body(), "\"odp_version\":\"1.0\"")); } @Test @@ -128,7 +134,55 @@ void routesSearchPostAndContinuationGetToTheSameHandler() { assertTrue(continuation.body().contains("Continued result")); } + @Test + void rejectsInvalidCatalogResponses() { + Map endpoints = new java.util.EnumMap<>(StaticCatalog.create( + List.of(offering("plant-1", "Rubber Plant")), List.of(collection("plants", null)))); + endpoints.put( + OdpOperation.GET_OFFERING, + new OdpService.Endpoint(AuthenticationRequirement.NOT_REQUIRED, request -> Map.of("name", "Invalid"))); + OdpService service = new OdpService(template(), endpoints); + + OdpHttpResponse response = service.handle(request("GET", "/odp/offerings/plant-1", Map.of())); + + assertEquals(500, response.status()); + assertTrue(response.body().contains("INTERNAL_ERROR")); + + endpoints.put( + OdpOperation.GET_OFFERING, + new OdpService.Endpoint(AuthenticationRequirement.NOT_REQUIRED, request -> offeringWithAction())); + service = new OdpService(template(), endpoints); + assertEquals( + 500, + service.handle(request("GET", "/odp/offerings/plant-1", Map.of())) + .status()); + + endpoints.put( + OdpOperation.GET_OFFERING, + new OdpService.Endpoint( + AuthenticationRequirement.NOT_REQUIRED, + request -> offering("plant-1", "Rubber Plant", List.of("/description")))); + endpoints.put( + OdpOperation.GET_COLLECTION, + new OdpService.Endpoint( + AuthenticationRequirement.NOT_REQUIRED, + request -> collection("plants", List.of("/description")))); + service = new OdpService(template(), endpoints); + assertEquals( + 500, + service.handle(request("GET", "/odp/offerings/plant-1", Map.of("representation", List.of("full")))) + .status()); + assertEquals( + 500, + service.handle(request("GET", "/odp/collections/plants", Map.of("representation", List.of("full")))) + .status()); + } + private static Offering offering(String id, String name) { + return offering(id, name, null); + } + + private static Offering offering(String id, String name, List detailFields) { return new Offering( null, Odp.VERSION, @@ -144,10 +198,43 @@ private static Offering offering(String id, String name) { null, null, null, - null, + detailFields, Map.of()); } + private static Offering offeringWithAction() { + Offering.Action action = new Offering.Action( + AuthenticationRequirement.NOT_REQUIRED, + "purchase", + "purchase", + null, + new Offering.HttpTarget("/purchase", "POST", null, null), + null); + Offering value = offering("plant-1", "Rubber Plant"); + return new Offering( + value.authExpands(), + value.odpVersion(), + value.id(), + value.name(), + value.description(), + value.images(), + value.language(), + value.localizations(), + value.webUrl(), + value.collectionIds(), + value.price(), + value.schema(), + value.attributes(), + List.of(action), + value.detailFields(), + value.additional()); + } + + private static Collection collection(String id, List detailFields) { + return new Collection( + null, Odp.VERSION, id, "Plants", null, null, null, null, null, null, null, detailFields, Map.of()); + } + private static ServiceDocument template() { return ServiceDocument.builder( "Plant Store", "Plants for agents.", "en", new ServiceDocument.Http("/odp", null)) @@ -159,4 +246,8 @@ private static ServiceDocument template() { private static OdpHttpRequest request(String method, String path, Map> query) { return new OdpHttpRequest(method, path, query, Map.of(), null); } + + private static int occurrences(String value, String token) { + return value.split(java.util.regex.Pattern.quote(token), -1).length - 1; + } } diff --git a/tools/odp-conformance/src/main/java/org/offeringprotocol/odp/conformance/ConformanceAdapter.java b/tools/odp-conformance/src/main/java/org/offeringprotocol/odp/conformance/ConformanceAdapter.java index 40afbfa..4430272 100644 --- a/tools/odp-conformance/src/main/java/org/offeringprotocol/odp/conformance/ConformanceAdapter.java +++ b/tools/odp-conformance/src/main/java/org/offeringprotocol/odp/conformance/ConformanceAdapter.java @@ -4,13 +4,24 @@ import java.io.IOException; import java.io.InputStreamReader; 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.charset.StandardCharsets; import java.util.ArrayList; import java.util.EnumMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import javax.net.ssl.SSLSession; +import org.offeringprotocol.odp.agent.OdpServiceClient; +import org.offeringprotocol.odp.agent.OdpTransport; +import org.offeringprotocol.odp.agent.OfferingDetails; +import org.offeringprotocol.odp.agent.OfferingIssue; import org.offeringprotocol.odp.core.AuthenticationRequirement; import org.offeringprotocol.odp.core.Odp; import org.offeringprotocol.odp.core.OdpJson; @@ -31,6 +42,8 @@ public final class ConformanceAdapter { private static final int MAXIMUM_MESSAGE_LENGTH = 1024; private static final String AGENT_ROLE = "agent"; + private static final String DOCUMENT_FIELD = "document"; + private static final String ROOT_SCHEMA_URL = "https://schemas.example/root.json"; private static final String VALIDATE_PROBLEM = "validate-problem"; private static final JsonMapper JSON = JsonMapper.builder().build(); private static final Set AGENT_BASELINE = Set.of( @@ -67,9 +80,7 @@ private static Map evaluate(JsonNode request) { response.put("sequence", sequence); try { Evaluation evaluation = evaluateCase( - required(required(request, "vector"), "subject").asText(), - required(request, "case"), - required(request, "role").asText()); + text(required(request, "vector"), "subject"), required(request, "case"), text(request, "role")); response.put("status", evaluation.status()); if (evaluation.message() != null) { response.put("message", evaluation.message()); @@ -87,11 +98,11 @@ private static Evaluation evaluateCase(String subject, JsonNode test, String rol case "identity-comparison" -> evaluateIdentity(test); case "service-origin" -> evaluateServiceOrigin(test); case "resource-reference" -> evaluateReference(test); - case "service-document" -> parse(test, "document", OdpJson::parseServiceDocument); - case "collection-envelope" -> parse(test, "document", OdpJson::parseCollection); + case "service-document" -> parse(test, DOCUMENT_FIELD, OdpJson::parseServiceDocument); + case "collection-envelope" -> parse(test, DOCUMENT_FIELD, OdpJson::parseCollection); case "offering-contract" -> "full".equals(optionalText(test, "representation")) - ? parse(test, "document", OdpJson::parseOffering) + ? parse(test, DOCUMENT_FIELD, OdpJson::parseOffering) : skipped(); case "collection-search-contract" -> "validate-request".equals(operation(test)) @@ -101,6 +112,7 @@ private static Evaluation evaluateCase(String subject, JsonNode test, String rol "validate-request".equals(operation(test)) ? parse(test, "request", OdpJson::parseOfferingSearchRequest) : skipped(); + case "attribute-schema-retrieval" -> evaluateAttributeSchema(test); case "pagination-contract" -> evaluatePagination(test); case "errors-limits-contract" -> evaluateErrorsAndLimits(test); case "role-baseline" -> evaluateBaseline(test, role); @@ -108,6 +120,179 @@ private static Evaluation evaluateCase(String subject, JsonNode test, String rol }; } + private static Evaluation evaluateAttributeSchema(JsonNode test) { + return switch (operation(test)) { + case "validate-reference" -> { + String offering = ("{\"id\":\"item\",\"name\":\"Item\",\"odp_version\":\"1.0\"," + "\"schema\":%s}") + .formatted(required(test, "reference")); + yield parseValue(offering, OdpJson::parseOffering, valid(test)); + } + case "validate-response" -> { + var details = attributeSchemaDetails( + Map.of( + ROOT_SCHEMA_URL, + new SchemaResponse( + required(test, DOCUMENT_FIELD).toString(), + text(test, "content_type"), + required(test, "status").asInt())), + ROOT_SCHEMA_URL, + "{\"name\":\"root\"}", + false); + yield result((details.details().attributeSchema() != null) == valid(test)); + } + case "validate-schema-reference-profile" -> { + Map documents = new LinkedHashMap<>(); + String rootUrl = null; + int index = 0; + for (JsonNode document : required(test, "documents")) { + String url = document.has("$id") + ? text(document, "$id") + : "https://schemas.example/document-" + index + ".json"; + if (rootUrl == null) { + rootUrl = url; + } + documents.put(url, new SchemaResponse(document.toString(), "application/schema+json", 200)); + index++; + } + var details = attributeSchemaDetails( + documents, rootUrl, "{\"children\":[{\"name\":\"child\"}],\"name\":\"root\"}", false); + yield result((details.details().attributeSchema() != null) == valid(test)); + } + case "validation-scope" -> { + boolean terse = "terse".equals(text(test, "representation")); + var details = attributeSchemaDetails( + Map.of(ROOT_SCHEMA_URL, new SchemaResponse(""" + {"$schema":"https://json-schema.org/draft/2020-12/schema", + "properties":{"memory":{"type":"number"}},"type":"object"} + """, "application/schema+json", 200)), + ROOT_SCHEMA_URL, + "{\"memory\":\"invalid\"}", + terse); + boolean complete = details.supportingRequests() > 0 + && details.details().offering().attributes() == null + && details.details().issues().stream() + .anyMatch(issue -> issue.scope() == OfferingIssue.Scope.ATTRIBUTES); + yield result(complete + == required(test, "complete_instance_validation").asBoolean()); + } + case "failure-scope" -> { + var details = attributeSchemaDetails( + Map.of( + ROOT_SCHEMA_URL, + new SchemaResponse("{\"title\":\"Unavailable\"}", "application/problem+json", 503)), + ROOT_SCHEMA_URL, + "{\"name\":\"root\"}", + false); + Map actual = Map.of( + "offering_usable", + "item".equals(details.details().offering().id()), + "attributes_usable", details.details().offering().attributes() != null, + "report_issue", + details.details().issues().stream() + .anyMatch(issue -> issue.scope() == OfferingIssue.Scope.ATTRIBUTE_SCHEMA)); + Map expected = new LinkedHashMap<>(); + required(test, "expected") + .properties() + .forEach(entry -> + expected.put(entry.getKey(), entry.getValue().asBoolean())); + yield result(actual.equals(expected)); + } + default -> skipped(); + }; + } + + private static AttributeSchemaEvaluation attributeSchemaDetails( + Map documents, String rootUrl, String attributes, boolean terse) { + String serviceDocument = """ + {"description":"ODP Java conformance adapter","http":{"endpoint_base":"/odp"}, + "language":"en","localizations":["en"],"name":"Conformance Service", + "odp_version":"1.0","operations":[{"authentication":"not-required","name":"get-offering"}, + {"authentication":"not-required","name":"list-offerings"}]} + """; + String offering = ("{\"attributes\":%s,\"id\":\"item\",\"name\":\"Item\",\"odp_version\":\"1.0\"," + + "\"schema\":{\"url\":\"%s\"}}") + .formatted(attributes, rootUrl); + OdpTransport service = request -> response( + request, + "/.well-known/odp".equals(request.uri().getPath()) + ? serviceDocument + : terse ? "{\"id\":\"item\",\"name\":\"Item\",\"odp_version\":\"1.0\"}" : offering, + "application/odp+json", + 200); + AtomicInteger supportingRequests = new AtomicInteger(); + OdpTransport supporting = request -> { + supportingRequests.incrementAndGet(); + SchemaResponse document = documents.getOrDefault( + request.uri().toString(), + new SchemaResponse("{\"title\":\"Not Found\"}", "application/problem+json", 404)); + return response(request, document.body(), document.contentType(), document.status()); + }; + OdpServiceClient client = OdpServiceClient.create(URI.create("https://service.example"), service, supporting); + OfferingDetails details = terse + ? new OfferingDetails(client.getOffering("item", "terse", null), null, List.of(), List.of()) + : client.getOfferingDetails("item", null); + return new AttributeSchemaEvaluation(details, supportingRequests.get()); + } + + private static HttpResponse response(HttpRequest request, String body, String contentType, int status) { + return new HttpResponse<>() { + @Override + public int statusCode() { + return status; + } + + @Override + public HttpRequest request() { + return request; + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return HttpHeaders.of(Map.of("Content-Type", List.of(contentType)), (name, value) -> true); + } + + @Override + public byte[] body() { + return body.getBytes(StandardCharsets.UTF_8); + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + + @Override + public URI uri() { + return request.uri(); + } + + @Override + public HttpClient.Version version() { + return HttpClient.Version.HTTP_1_1; + } + }; + } + + private static Evaluation parseValue(String value, Parser parser, boolean expected) { + boolean actual; + try { + parser.parse(value); + actual = true; + } catch (IllegalArgumentException exception) { + actual = false; + } + return result(actual == expected); + } + + private record AttributeSchemaEvaluation(OfferingDetails details, int supportingRequests) {} + + private record SchemaResponse(String body, String contentType, int status) {} + private static Evaluation evaluateIdentity(JsonNode test) { ResourceIdentity left = decode(required(test, "left"), ResourceIdentity.class); ResourceIdentity right = decode(required(test, "right"), ResourceIdentity.class); @@ -208,13 +393,13 @@ private static Evaluation evaluateBaseline(JsonNode test, String role) { } if (AGENT_ROLE.equals(role)) { Set behaviors = new java.util.HashSet<>(); - required(test, "behaviors").forEach(value -> behaviors.add(value.asText())); + required(test, "behaviors").forEach(value -> behaviors.add(textValue(value))); return result(behaviors.containsAll(AGENT_BASELINE) == valid(test)); } List operations = new ArrayList<>(); required(test, "operations") .forEach(value -> operations.add(new OperationDescriptor( - AuthenticationRequirement.NOT_REQUIRED, OdpOperation.fromValue(value.asText())))); + AuthenticationRequirement.NOT_REQUIRED, OdpOperation.fromValue(textValue(value))))); boolean actual; try { OdpJson.parseServiceDocument(OdpJson.write(document(operations))); @@ -265,12 +450,20 @@ private static JsonNode required(JsonNode object, String name) { } private static String text(JsonNode object, String name) { - return required(object, name).asText(); + return textValue(required(object, name)); } private static String optionalText(JsonNode object, String name) { JsonNode value = object.get(name); - return value == null ? null : value.asText(); + return value == null ? null : textValue(value); + } + + private static String textValue(JsonNode value) { + String result = value.stringValue(); + if (result == null) { + throw new IllegalArgumentException("Conformance case field is not a string"); + } + return result; } private static String operation(JsonNode test) {