diff --git a/README.md b/README.md index efcdd82..a1d4837 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,10 @@ Choose the module that matches the role your application implements: | A directory-only integration | [`odp-directory`](./odp-directory/README.md) | Canonical production or sandbox Service search | | An ODP validator or protocol implementation | [`odp-core`](./odp-core/README.md) | Models, bundled schemas, identity, references, and pagination | +Every application also selects one JSON provider: `odp-json-jackson2` for applications using +Jackson 2, or `odp-json-jackson3` for applications using Jackson 3. The role modules do not force a +second Jackson major version into the application. + All artifacts use Maven group `org.offeringprotocol`, require Java 17 or newer, and are available from Maven Central without adding a repository. @@ -41,7 +45,12 @@ For an Agent application: org.offeringprotocol odp-agent - 0.1.1 + 0.2.0 + + + org.offeringprotocol + odp-json-jackson2 + 0.2.0 ``` @@ -51,16 +60,24 @@ For a Service integration: org.offeringprotocol odp-service - 0.1.1 + 0.2.0 + + + org.offeringprotocol + odp-json-jackson2 + 0.2.0 ``` Gradle uses the same coordinates: ```kotlin -implementation("org.offeringprotocol:odp-agent:0.1.1") +implementation("org.offeringprotocol:odp-agent:0.2.0") +implementation("org.offeringprotocol:odp-json-jackson2:0.2.0") ``` +Replace `odp-json-jackson2` with `odp-json-jackson3` when the application uses Jackson 3. Exactly +one provider must be present at runtime; `OdpJson` discovers it through Java `ServiceLoader`. Maven resolves the required Core and Directory modules transitively. Applications should not add every ODP module to one project unless they actually implement multiple roles. diff --git a/examples/README.md b/examples/README.md index 59e7530..cbc4137 100644 --- a/examples/README.md +++ b/examples/README.md @@ -3,6 +3,9 @@ The examples demonstrate the two ODP integration roles with the same public Java modules published to Maven Central. Run all commands from the repository root with Java 17 or newer. +The example module selects `odp-json-jackson3`. Applications using Jackson 2 select +`odp-json-jackson2` instead; the role modules and public ODP models remain the same. + ## Source map | Source | Purpose | diff --git a/examples/pom.xml b/examples/pom.xml index 532ec0d..d627ee8 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -7,7 +7,7 @@ org.offeringprotocol odp-java - 0.1.1 + 0.2.0 odp-examples @@ -20,6 +20,11 @@ + + ${project.groupId} + odp-json-jackson3 + ${project.version} + ${project.groupId} odp-agent diff --git a/odp-agent/README.md b/odp-agent/README.md index 5494768..4a9296a 100644 --- a/odp-agent/README.md +++ b/odp-agent/README.md @@ -13,15 +13,23 @@ capability checks, Collections, Offerings, localization, and continuations. org.offeringprotocol odp-agent - 0.1.1 + 0.2.0 + + + org.offeringprotocol + odp-json-jackson2 + 0.2.0 ``` ```kotlin -implementation("org.offeringprotocol:odp-agent:0.1.1") +implementation("org.offeringprotocol:odp-agent:0.2.0") +implementation("org.offeringprotocol:odp-json-jackson2:0.2.0") ``` -The Agent module brings in `odp-directory` and `odp-core` transitively. +The Agent module brings in `odp-directory` and `odp-core` transitively. Replace +`odp-json-jackson2` with `odp-json-jackson3` in a Jackson 3 application. Exactly one provider must +be present at runtime; no programmatic configuration is required. ## Discover Offerings across Services @@ -221,6 +229,10 @@ OdpServiceClient service = OdpServiceClient.create( Supporting-document resolution does not invoke an Action. The application remains responsible for Action selection, user approval, authentication, payment, request construction, and invocation. +Service inspection filters unrecognized enrollment, payment, and trust protocol descriptors for +compatible ODP versions. Recognized descriptors remain subject to the complete Service Document +contract. + ## Errors Non-success Service responses throw `OdpRequestException`, which preserves the HTTP status, diff --git a/odp-agent/pom.xml b/odp-agent/pom.xml index 7dfd12d..4e8e446 100644 --- a/odp-agent/pom.xml +++ b/odp-agent/pom.xml @@ -7,7 +7,7 @@ org.offeringprotocol odp-java - 0.1.1 + 0.2.0 odp-agent @@ -29,5 +29,11 @@ odp-directory ${project.version} + + ${project.groupId} + odp-json-jackson3 + ${project.version} + test + 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 index 6904a38..e0ca1ba 100644 --- a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/ActionResolver.java +++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/ActionResolver.java @@ -9,9 +9,9 @@ import java.util.Map; import java.util.Set; import java.util.regex.Pattern; +import org.offeringprotocol.odp.core.OdpJsonNode; 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; @@ -59,13 +59,13 @@ ResolvedAction resolve(DiscoveredAction action, String serviceOrigin) { return new ResolvedAction(action, null, null, null); } URI reference = OdpUris.resolveResourceReference(request.schema().url(), serviceOrigin); - JsonNode schema = schemaResolver.resolve(reference).document(); + OdpJsonNode 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( + OdpJsonNode 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"), @@ -75,7 +75,7 @@ ResolvedAction resolve(DiscoveredAction action, String serviceOrigin) { if (!OPENAPI_VERSION.matcher(version).matches()) { throw new IllegalStateException("ODP Action requires an OpenAPI 3.1 document"); } - List operations = findOperations(document, action.openapi().operationId()); + 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"); @@ -121,13 +121,13 @@ private static DiscoveredAction normalize(Offering.Action action, String service target.toString(), action.openapi().operationId())); } - private static List findOperations(JsonNode document, String operationId) { - JsonNode paths = document.get("paths"); + private static List findOperations(OdpJsonNode document, String operationId) { + OdpJsonNode 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) { + List matches = new ArrayList<>(); + for (OdpJsonNode path : paths) { if (!path.isObject()) { continue; } 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 index 8791c96..54b4d27 100644 --- a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/AttributeSchemaResolver.java +++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/AttributeSchemaResolver.java @@ -1,10 +1,5 @@ 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; @@ -15,8 +10,8 @@ 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; +import org.offeringprotocol.odp.core.OdpJsonNode; +import org.offeringprotocol.odp.core.OdpJsonSchema; final class AttributeSchemaResolver { private static final String DIALECT = "https://json-schema.org/draft/2020-12/schema"; @@ -38,12 +33,10 @@ ResolvedSchema resolve(URI reference) { URI root = withoutFragment(reference); SchemaGraph graph = new SchemaGraph(); load(root, 0, graph); - JsonNode bundled = bundle(root, graph.documents); + OdpJsonNode 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())); + OdpJsonSchema validator = OdpJson.compileSchema(registryDocuments, root.toString()); return new ResolvedSchema(bundled, validator); } @@ -57,7 +50,7 @@ private void load(URI target, int depth, SchemaGraph graph) { if (depth > MAXIMUM_DEPTH) { throw new IllegalStateException("ODP Attribute Schema graph exceeds eight reference levels"); } - JsonNode document = client.get( + OdpJsonNode 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; @@ -70,18 +63,18 @@ private void load(URI target, int depth, SchemaGraph graph) { } } - private static void requireSchema(JsonNode document) { + private static void requireSchema(OdpJsonNode 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"); + OdpJsonNode 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"); + OdpJsonNode vocabulary = node.get("$vocabulary"); if (vocabulary != null && vocabulary.isObject()) { vocabulary.forEachEntry((uri, required) -> { if (required.asBoolean(false) && !uri.startsWith(STANDARD_VOCABULARY)) { @@ -92,7 +85,7 @@ private static void requireSchema(JsonNode document) { }); } - private static List externalReferences(JsonNode document, URI retrievalUrl) { + private static List externalReferences(OdpJsonNode document, URI retrievalUrl) { Set localResources = new HashSet<>(); collectResourceIdentifiers(document, retrievalUrl, localResources); Set references = new HashSet<>(); @@ -101,17 +94,17 @@ private static List externalReferences(JsonNode document, URI retrievalUrl) return references.stream().sorted().toList(); } - private static void collectResourceIdentifiers(JsonNode value, URI base, Set result) { + private static void collectResourceIdentifiers(OdpJsonNode value, URI base, Set result) { URI current = resolveIdentifier(value, base); result.add(withoutFragment(current)); - for (JsonNode child : value) { + for (OdpJsonNode child : value) { collectResourceIdentifiers(child, current, result); } } - private static void collectReferences(JsonNode value, URI base, Set result) { + private static void collectReferences(OdpJsonNode value, URI base, Set result) { URI current = resolveIdentifier(value, base); - JsonNode reference = value.isObject() ? value.get("$ref") : null; + OdpJsonNode reference = value.isObject() ? value.get("$ref") : null; if (reference != null) { if (!reference.isString()) { throw new IllegalStateException("ODP Attribute Schema $ref must be a string"); @@ -120,13 +113,13 @@ private static void collectReferences(JsonNode value, URI base, Set result) requireHttps(resolved, "ODP Attribute Schema references must use HTTPS"); result.add(withoutFragment(resolved)); } - for (JsonNode child : value) { + for (OdpJsonNode child : value) { collectReferences(child, current, result); } } - private static URI resolveIdentifier(JsonNode value, URI base) { - JsonNode identifier = value.isObject() ? value.get(IDENTIFIER) : null; + private static URI resolveIdentifier(OdpJsonNode value, URI base) { + OdpJsonNode identifier = value.isObject() ? value.get(IDENTIFIER) : null; if (identifier == null) { return base; } @@ -138,8 +131,8 @@ private static URI resolveIdentifier(JsonNode value, URI base) { return resolved; } - private static JsonNode bundle(URI rootUrl, Map documents) { - ObjectNode root = documents.get(rootUrl).deepCopy().asObject(); + private static OdpJsonNode bundle(URI rootUrl, Map documents) { + OdpJsonNode root = documents.get(rootUrl).deepCopy(); if (!root.has(IDENTIFIER)) { root.put(IDENTIFIER, rootUrl.toString()); } @@ -147,9 +140,9 @@ private static JsonNode bundle(URI rootUrl, Map documents) { externalUrls.remove(rootUrl); externalUrls.sort(URI::compareTo); if (!externalUrls.isEmpty()) { - ObjectNode definitions = + OdpJsonNode definitions = root.has(DEFINITIONS) && root.get(DEFINITIONS).isObject() - ? root.get(DEFINITIONS).deepCopy().asObject() + ? root.get(DEFINITIONS).deepCopy() : root.putObject(DEFINITIONS); int index = 0; for (URI externalUrl : externalUrls) { @@ -158,7 +151,7 @@ private static JsonNode bundle(URI rootUrl, Map documents) { while (definitions.has(key)) { key = key + "_"; } - ObjectNode external = documents.get(externalUrl).deepCopy().asObject(); + OdpJsonNode external = documents.get(externalUrl).deepCopy(); if (!external.has(IDENTIFIER)) { external.put(IDENTIFIER, externalUrl.toString()); } @@ -169,11 +162,11 @@ private static JsonNode bundle(URI rootUrl, Map documents) { return root; } - private static void visit(JsonNode value, NodeVisitor visitor) { + private static void visit(OdpJsonNode value, NodeVisitor visitor) { if (value.isObject()) { visitor.visit(value); } - for (JsonNode child : value) { + for (OdpJsonNode child : value) { visit(child, visitor); } } @@ -191,21 +184,19 @@ private static void requireHttps(URI value, String message) { } } - record ResolvedSchema(JsonNode document, Schema validator) { - boolean validates(Map attributes) { - return validator - .validate(OdpJson.write(attributes), InputFormat.JSON) - .isEmpty(); + record ResolvedSchema(OdpJsonNode document, OdpJsonSchema validator) { + boolean validates(Map attributes) { + return validator.validate(OdpJson.write(attributes)).isEmpty(); } } private static final class SchemaGraph { - private final Map documents = new LinkedHashMap<>(); + private final Map documents = new LinkedHashMap<>(); private int bytes; } @FunctionalInterface private interface NodeVisitor { - void visit(JsonNode value); + void visit(OdpJsonNode value); } } 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 e9b9436..4699952 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 @@ -16,6 +16,7 @@ import org.offeringprotocol.odp.core.Collection; import org.offeringprotocol.odp.core.Odp; import org.offeringprotocol.odp.core.OdpJson; +import org.offeringprotocol.odp.core.OdpJsonNode; import org.offeringprotocol.odp.core.OdpOperation; import org.offeringprotocol.odp.core.OdpUris; import org.offeringprotocol.odp.core.Offering; @@ -24,15 +25,12 @@ 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) @@ -76,7 +74,7 @@ public static OdpServiceClient create(URI serviceUri, OdpTransport transport, Od 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); - var document = OdpJson.parseServiceDocument(json); + var document = OdpJson.parseAgentServiceDocument(json); Map operations = new LinkedHashMap<>(); for (OperationDescriptor operation : document.operations()) { operations.put(operation.name(), operation); @@ -107,7 +105,7 @@ public Page searchCollections( } public Collection getCollection(String id, String representation, String language) { - return OdpJson.parseCollection( + return parseAgentCollection( requestOperation(OdpOperation.GET_COLLECTION, id, representation, null, language, null)); } @@ -122,14 +120,14 @@ public Page listOfferings(String representation, Integer limit, String } public OfferingPage searchOfferings(SearchRequests.Offerings request, String representation, String language) { - OfferingPage page = OdpJson.parseOfferingSearchResponse(requestOperation( + OfferingPage page = parseAgentOfferingSearchResponse(requestOperation( OdpOperation.SEARCH_OFFERINGS, null, representation, null, language, OdpJson.write(request))); page.items().forEach(item -> requireSummary(item.id(), item.name(), "Offering")); return page; } public Offering getOffering(String id, String representation, String language) { - return OdpJson.parseOffering( + return parseAgentOffering( requestOperation(OdpOperation.GET_OFFERING, id, representation, null, language, null)); } @@ -142,7 +140,7 @@ public OfferingDetails getOfferingDetails(String id, String language) { actionResolver.normalize(offering.actions(), serviceOrigin, serviceOpenApiUrl); List issues = new ArrayList<>(normalized.issues()); Offering safeOffering = offering; - tools.jackson.databind.JsonNode attributeSchema = null; + OdpJsonNode attributeSchema = null; if (offering.schema() != null) { try { URI reference = @@ -210,13 +208,13 @@ private String requestOperation( } private static Page collectionPage(String json) { - Page page = OdpJson.parsePage(json, Collection.class); + Page page = parseAgentCollectionPage(json); page.items().forEach(item -> requireSummary(item.id(), item.name(), "Collection")); return page; } private static Page offeringPage(String json) { - Page page = OdpJson.parsePage(json, Offering.class); + Page page = parseAgentOfferingPage(json); page.items().forEach(item -> requireSummary(item.id(), item.name(), "Offering")); return page; } @@ -228,13 +226,29 @@ 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); - } + OdpJsonNode document = OdpJson.valueToTree(offering); + document.remove("attributes"); + return parseAgentOffering(document.toString()); + } + + private static Collection parseAgentCollection(String json) { + return OdpJson.parseCollection(OdpJson.normalizeAgentResponse(json, "collection")); + } + + private static Offering parseAgentOffering(String json) { + return OdpJson.parseOffering(OdpJson.normalizeAgentResponse(json, "offering")); + } + + private static Page parseAgentCollectionPage(String json) { + return OdpJson.parsePage(OdpJson.normalizeAgentResponse(json, "collection-page"), Collection.class); + } + + private static Page parseAgentOfferingPage(String json) { + return OdpJson.parsePage(OdpJson.normalizeAgentResponse(json, "offering-page"), Offering.class); + } + + private static OfferingPage parseAgentOfferingSearchResponse(String json) { + return OdpJson.parseOfferingSearchResponse(OdpJson.normalizeAgentResponse(json, "offering-page")); } private static String request( @@ -291,7 +305,7 @@ private static String request( if (status < 200 || status > 299) { ProblemDetails problem = null; try { - problem = OdpJson.parseProblemDetails(text); + problem = OdpJson.parseProblemDetails(OdpJson.normalizeAgentResponse(text, "problem")); } catch (IllegalArgumentException ignored) { // The HTTP status remains available when a peer does not return ODP Problem Details. } 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 index 7504dd8..cc0d680 100644 --- a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OfferingDetails.java +++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OfferingDetails.java @@ -1,11 +1,11 @@ package org.offeringprotocol.odp.agent; import java.util.List; +import org.offeringprotocol.odp.core.OdpJsonNode; import org.offeringprotocol.odp.core.Offering; -import tools.jackson.databind.JsonNode; public record OfferingDetails( - Offering offering, JsonNode attributeSchema, List actions, List issues) { + Offering offering, OdpJsonNode attributeSchema, List actions, List issues) { public OfferingDetails { if (attributeSchema != null) { attributeSchema = attributeSchema.deepCopy(); @@ -15,7 +15,7 @@ public record OfferingDetails( } @Override - public JsonNode attributeSchema() { + public OdpJsonNode attributeSchema() { return attributeSchema == null ? null : attributeSchema.deepCopy(); } } 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 index ea58f3c..bfb3587 100644 --- a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/ResolvedAction.java +++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/ResolvedAction.java @@ -1,9 +1,9 @@ package org.offeringprotocol.odp.agent; -import tools.jackson.databind.JsonNode; +import org.offeringprotocol.odp.core.OdpJsonNode; public record ResolvedAction( - DiscoveredAction action, JsonNode requestSchema, JsonNode openApiDocument, JsonNode operation) { + DiscoveredAction action, OdpJsonNode requestSchema, OdpJsonNode openApiDocument, OdpJsonNode operation) { public ResolvedAction { requestSchema = copy(requestSchema); openApiDocument = copy(openApiDocument); @@ -11,21 +11,21 @@ public record ResolvedAction( } @Override - public JsonNode requestSchema() { + public OdpJsonNode requestSchema() { return copy(requestSchema); } @Override - public JsonNode openApiDocument() { + public OdpJsonNode openApiDocument() { return copy(openApiDocument); } @Override - public JsonNode operation() { + public OdpJsonNode operation() { return copy(operation); } - private static JsonNode copy(JsonNode value) { + private static OdpJsonNode copy(OdpJsonNode 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 index b4cabad..dbf4a40 100644 --- a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/SupportingJsonClient.java +++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/SupportingJsonClient.java @@ -8,21 +8,18 @@ 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; +import org.offeringprotocol.odp.core.OdpJson; +import org.offeringprotocol.odp.core.OdpJsonNode; 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) { + OdpJsonNode get(URI target, String accept, Set mediaTypes, int maximumBytes, int maximumDepth) { requireHttps(target); URI current = target; for (int redirects = 0; redirects <= MAXIMUM_REDIRECTS; redirects++) { @@ -60,7 +57,7 @@ JsonNode get(URI target, String accept, Set mediaTypes, int maximumBytes if (!mediaTypes.contains(essence)) { throw new IllegalStateException("ODP supporting resource returned an unsupported Content-Type"); } - JsonNode document = parse(bytes); + OdpJsonNode document = parse(bytes); if (!document.isObject()) { throw new IllegalStateException("ODP supporting resource must be a JSON object"); } @@ -84,17 +81,17 @@ private HttpResponse send(HttpRequest request) { } } - private static JsonNode parse(byte[] bytes) { + private static OdpJsonNode parse(byte[] bytes) { try { - return JSON.readTree(new String(bytes, StandardCharsets.UTF_8)); - } catch (JacksonException exception) { + return OdpJson.parseTree(new String(bytes, StandardCharsets.UTF_8)); + } catch (IllegalArgumentException exception) { throw new IllegalStateException("ODP supporting resource must contain valid JSON", exception); } } - private static int depth(JsonNode value) { + private static int depth(OdpJsonNode value) { int maximum = 1; - for (JsonNode child : value) { + for (OdpJsonNode child : value) { maximum = Math.max(maximum, 1 + depth(child)); } return maximum; diff --git a/odp-agent/src/test/java/org/offeringprotocol/odp/agent/OdpServiceClientTest.java b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/OdpServiceClientTest.java index d992023..20c3e63 100644 --- a/odp-agent/src/test/java/org/offeringprotocol/odp/agent/OdpServiceClientTest.java +++ b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/OdpServiceClientTest.java @@ -15,6 +15,7 @@ import javax.net.ssl.SSLSession; import org.junit.jupiter.api.Test; import org.offeringprotocol.odp.core.OdpOperation; +import org.offeringprotocol.odp.core.ServiceDocument; class OdpServiceClientTest { @Test @@ -44,6 +45,30 @@ void inspectsAndNavigatesAService() { client.listOfferings("terse", 10, "en").items().get(0).name()); } + @Test + void filtersUnknownProtocolsBeforeExposingServiceInspection() { + OdpTransport transport = request -> response(request, """ + {"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"},"protocols":{ + "enrollment":[{"name":"future-enrollment"},{"name":"aep"}], + "payments":[{"authentication":"not-required","name":"future-payment"}, + {"authentication":"not-required","name":"mpp"}], + "trust":[{"name":"future-trust"},{"name":"tap"}]}} + """); + + ServiceDocument.Protocols protocols = OdpServiceClient.create(URI.create("https://plants.example"), transport) + .inspection() + .document() + .protocols(); + + assertEquals(List.of(new ServiceDocument.EnrollmentProtocol("aep")), protocols.enrollment()); + assertEquals(List.of(new ServiceDocument.TrustProtocol("tap")), protocols.trust()); + assertEquals(1, protocols.payments().size()); + } + private static HttpResponse response(HttpRequest request, String body) { return new HttpResponse<>() { @Override diff --git a/odp-core/README.md b/odp-core/README.md index 8f415f5..874cbaa 100644 --- a/odp-core/README.md +++ b/odp-core/README.md @@ -13,16 +13,24 @@ ODP models without Agent or Service HTTP behavior. org.offeringprotocol odp-core - 0.1.1 + 0.2.0 + + + org.offeringprotocol + odp-json-jackson2 + 0.2.0 ``` ```kotlin -implementation("org.offeringprotocol:odp-core:0.1.1") +implementation("org.offeringprotocol:odp-core:0.2.0") +implementation("org.offeringprotocol:odp-json-jackson2:0.2.0") ``` -`odp-core` requires Java 17 or newer and does not depend on another ODP module or an application -framework. +Use `odp-json-jackson2` with Jackson 2 applications or replace it with `odp-json-jackson3` for +Jackson 3. Add exactly one provider. `OdpJson` discovers the provider through Java `ServiceLoader` +and rejects a runtime with no provider or multiple providers. `odp-core` requires Java 17 or newer +and does not select a JSON library or application framework. ## Validate and decode documents @@ -45,6 +53,10 @@ responses, search requests, page envelopes, and ODP Problem Details. `OdpJson.wr the corresponding Java records while omitting absent optional members. Unknown additive members permitted by the protocol are retained in each model's `additional` map. +JSON-valued protocol members use `OdpJsonNode`, so public ODP models do not expose either Jackson +major version. Use `OdpJson.parseTree`, `OdpJson.valueToTree`, and `OdpJson.treeToValue` at the +application boundary when those members require tree access. + Validation failures are reported as `OdpValidationException` with a document type and structured issues. Invalid local method arguments use `IllegalArgumentException`. @@ -109,6 +121,10 @@ discovery and filtering. Live MPP and x402 responses remain authoritative for ex `ServiceDocument.TrustProtocol` represents advertised trust support. A Service that accepts Visa Trusted Agent Protocol requests declares a single `tap` descriptor in `protocols.trust`. +Service authoring uses `OdpJson.parseServiceDocument` and rejects protocol names outside the +declared ODP version. Agent readers use `OdpJson.parseAgentServiceDocument`; it filters unrecognized +enrollment, payment, and trust descriptors before validating every recognized descriptor. + ## Related documentation - [Agent integration](../odp-agent/README.md) diff --git a/odp-core/pom.xml b/odp-core/pom.xml index 3eaaa83..5350655 100644 --- a/odp-core/pom.xml +++ b/odp-core/pom.xml @@ -7,7 +7,7 @@ org.offeringprotocol odp-java - 0.1.1 + 0.2.0 odp-core @@ -20,15 +20,8 @@ - com.networknt - json-schema-validator - ${json-schema-validator.version} - - - tools.jackson.dataformat - jackson-dataformat-yaml - - + com.fasterxml.jackson.core + jackson-annotations diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/Collection.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/Collection.java index 430e866..5765c79 100644 --- a/odp-core/src/main/java/org/offeringprotocol/odp/core/Collection.java +++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/Collection.java @@ -5,7 +5,6 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; import java.util.Map; -import tools.jackson.databind.JsonNode; public record Collection( @JsonProperty("auth_expands") Boolean authExpands, @@ -20,7 +19,7 @@ public record Collection( @JsonProperty("web_url") String webUrl, @JsonProperty("search_capabilities") SearchCapabilities searchCapabilities, @JsonProperty("detail_fields") List detailFields, - @JsonAnySetter @JsonAnyGetter Map additional) { + @JsonAnySetter @JsonAnyGetter Map additional) { public Collection { images = Copies.list(images); localizations = Copies.list(localizations); diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/Copies.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/Copies.java index 59382fa..eae4492 100644 --- a/odp-core/src/main/java/org/offeringprotocol/odp/core/Copies.java +++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/Copies.java @@ -3,7 +3,6 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; -import tools.jackson.databind.JsonNode; final class Copies { private Copies() {} @@ -16,7 +15,7 @@ static Map map(Map value) { return value == null ? Map.of() : Map.copyOf(value); } - static Map nodes(Map value) { + static Map nodes(Map value) { if (value == null) { return Map.of(); } @@ -25,7 +24,7 @@ static Map nodes(Map value) { Map.Entry::getKey, entry -> entry.getValue().deepCopy())); } - static Map nullableNodes(Map value) { + static Map nullableNodes(Map value) { return value == null ? null : nodes(value); } } 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 5b4fa56..7a467fd 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 @@ -1,11 +1,5 @@ package org.offeringprotocol.odp.core; -import com.fasterxml.jackson.annotation.JsonInclude; -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.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -14,32 +8,386 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.ServiceLoader; import java.util.Set; -import tools.jackson.core.JacksonException; -import tools.jackson.databind.JavaType; -import tools.jackson.databind.json.JsonMapper; /** Validated JSON encoding and decoding for ODP documents. */ public final class OdpJson { + private static final String FIELD_NAME = "name"; + private static final String FIELD_TYPE = "type"; + private static final String FIELD_URL = "url"; + private static final String OFFERING = "offering"; + private static final int REQUIRED_PROVIDER_COUNT = 1; private static final String SCHEMA_ORIGIN = "https://offeringprotocol.org/schemas/"; private static final String SCHEMA_PATH = "/org/offeringprotocol/odp/core/schemas/"; - private static final JsonMapper MAPPER = JsonMapper.builder() - .changeDefaultPropertyInclusion(inclusion -> inclusion.withValueInclusion(JsonInclude.Include.NON_NULL)) - .build(); - private static final SchemaRegistry SCHEMAS = createRegistry(); + private static final String SERVICE_DOCUMENT = "Service Document"; + private static final OdpJsonProvider PROVIDER = loadProvider(); + private static final Map SCHEMA_DOCUMENTS = readSchemas(); + private static final Map SCHEMAS = compileSchemas(); private OdpJson() {} public static ServiceDocument parseServiceDocument(String json) { - ServiceDocument document = - parse(json, "service-document.schema.json", "Service Document", ServiceDocument.class); - validateLocalizations(document.language(), document.localizations(), "Service Document"); + ServiceDocument document = parse(json, "service-document.schema.json", SERVICE_DOCUMENT, ServiceDocument.class); + validateLocalizations(document.language(), document.localizations(), SERVICE_DOCUMENT); if (document.additional().containsKey("web_url")) { - throw semanticError("Service Document", "web_url is not permitted", "/web_url"); + throw semanticError(SERVICE_DOCUMENT, "web_url is not permitted", "/web_url"); } return document; } + public static ServiceDocument parseAgentServiceDocument(String json) { + return parseServiceDocument(normalizeAgentResponse(json, "service-document")); + } + + public static String normalizeAgentResponse(String json, String kind) { + try { + OdpJsonNode value = PROVIDER.parseTree(json); + if (value == null || !value.isObject()) { + return json; + } + normalizeAgentDocument(value, kind); + return value.toString(); + } catch (IllegalArgumentException exception) { + throw new OdpValidationException( + "Agent response", + List.of(new ValidationIssue("json", exception.getMessage(), Map.of(), "")), + exception); + } + } + + private static void normalizeAgentDocument(OdpJsonNode document, String kind) { + switch (kind) { + case "service-document" -> { + filterAgentProtocols(document); + OdpJsonNode protocols = document.get("protocols"); + if (protocols != null && protocols.isObject()) { + filterUnknownAuthentication(protocols, "payments"); + } + filterNamedList( + document, + "operations", + Set.of( + "get-collection", + "get-offering", + "list-collection-offerings", + "list-collections", + "list-offerings", + "search-collections", + "search-offerings")); + filterUnknownAuthentication(document, "operations"); + filterTypedList(document, "mcp", Set.of("streamable-http")); + filterClosedObjectList(document, "operations", Set.of("authentication", FIELD_NAME)); + filterClosedObjectList(document, "mcp", Set.of("description", FIELD_NAME, FIELD_TYPE, FIELD_URL)); + filterPaymentOptions(document); + normalizeBranding(document); + normalizeSearchCapabilities(document); + } + case "collection", OFFERING -> { + filterTypedList( + document, + "images", + Set.of("image/avif", "image/jpeg", "image/png", "image/svg+xml", "image/webp")); + stripObjectList(document, "images", Set.of("alt", "height", "src", FIELD_TYPE, "width")); + normalizeSearchCapabilities(document); + if (OFFERING.equals(kind)) { + normalizeOffering(document); + } + } + case "collection-page", "offering-page" -> { + OdpJsonNode items = document.get("items"); + if (items != null && items.isArray()) { + String itemKind = "offering-page".equals(kind) ? OFFERING : "collection"; + items.forEach(item -> { + if (item.isObject()) { + normalizeAgentDocument(item, itemKind); + } + }); + } + } + case "filter-page" -> filterDefinitions(document, true); + case "sort-page" -> filterDefinitions(document, false); + case "problem" -> filterProblemParameters(document); + default -> {} + } + } + + private static void filterNamedList(OdpJsonNode document, String member, Set recognized) { + filterList(document, member, FIELD_NAME, recognized); + } + + private static void filterTypedList(OdpJsonNode document, String member, Set recognized) { + filterList(document, member, FIELD_TYPE, recognized); + } + + private static void filterList(OdpJsonNode document, String member, String discriminator, Set recognized) { + OdpJsonNode value = document.get(member); + if (value == null || !value.isArray()) { + return; + } + value.removeIf(item -> { + OdpJsonNode field = item.isObject() ? item.get(discriminator) : null; + return field != null && field.isString() && !recognized.contains(field.asString()); + }); + if (value.isEmpty()) { + document.remove(member); + } + } + + private static void filterClosedObjectList(OdpJsonNode document, String member, Set allowed) { + OdpJsonNode value = document.get(member); + if (value == null || !value.isArray()) { + return; + } + value.removeIf(item -> item.isObject() && item.fieldNames().stream().anyMatch(name -> !allowed.contains(name))); + if (value.isEmpty()) { + document.remove(member); + } + } + + private static void filterUnknownAuthentication(OdpJsonNode document, String member) { + OdpJsonNode value = document.get(member); + if (value == null || !value.isArray()) { + return; + } + value.removeIf(item -> item.isObject() && hasUnknownAuthentication(item)); + if (value.isEmpty()) { + document.remove(member); + } + } + + private static boolean hasUnknownAuthentication(OdpJsonNode value) { + OdpJsonNode authentication = value.get("authentication"); + return authentication != null + && authentication.isString() + && !Set.of("not-required", "optional", "required").contains(authentication.asString()); + } + + private static void stripObjectList(OdpJsonNode document, String member, Set allowed) { + OdpJsonNode value = document.get(member); + if (value == null || !value.isArray()) { + return; + } + value.forEach(item -> { + if (item.isObject()) { + List unknown = item.fieldNames().stream() + .filter(name -> !allowed.contains(name)) + .toList(); + item.remove(unknown); + } + }); + } + + private static void filterPaymentOptions(OdpJsonNode document) { + OdpJsonNode payments = document.at("/protocols/payments"); + if (!payments.isArray()) { + return; + } + Set recognized = Set.of( + "algorand", + "aptos", + "arbitrum", + "avalanche", + "base", + "card", + "ethereum", + "hedera", + "inflow", + "lightning", + "polygon", + "solana", + "stellar", + "stripe", + "tempo", + "ton"); + payments.forEach(payment -> { + OdpJsonNode options = payment.get("options"); + if (payment.isObject() && options != null && options.isArray()) { + options.removeIf(option -> option.isString() && !recognized.contains(option.asString())); + if (options.isEmpty()) { + payment.remove("options"); + } + } + }); + } + + private static void normalizeBranding(OdpJsonNode document) { + OdpJsonNode value = document.get("branding"); + if (value == null || !value.isObject()) { + return; + } + List unknownMembers = value.fieldNames().stream() + .filter(name -> !Set.of("icon", "logo").contains(name)) + .toList(); + value.remove(unknownMembers); + Set recognized = Set.of("image/png", "image/svg+xml", "image/webp"); + for (String member : List.of("icon", "logo")) { + OdpJsonNode type = value.at("/" + member + "/" + FIELD_TYPE); + if (type.isString() && !recognized.contains(type.asString())) { + value.remove(member); + } else if (value.has(member) && value.get(member).isObject()) { + OdpJsonNode image = value.get(member); + List unknown = image.fieldNames().stream() + .filter(name -> !Set.of("src", FIELD_TYPE).contains(name)) + .toList(); + image.remove(unknown); + } + } + if (value.isEmpty()) { + document.remove("branding"); + } + } + + private static void normalizeSearchCapabilities(OdpJsonNode document) { + OdpJsonNode value = document.get("search_capabilities"); + if (value == null || !value.isObject()) { + return; + } + filterInlineDefinitions(value, "filters", true); + filterInlineDefinitions(value, "sorts", false); + if (value.isEmpty()) { + document.remove("search_capabilities"); + } + } + + private static void filterInlineDefinitions(OdpJsonNode capabilities, String member, boolean filters) { + OdpJsonNode source = capabilities.get(member); + OdpJsonNode inline = source == null ? null : source.get("inline"); + if (source == null || !source.isObject() || inline == null || !inline.isArray()) { + return; + } + inline.removeIf(item -> item.isObject() && !(filters ? knownFilter(item) : knownSort(item))); + if (inline.isEmpty()) { + capabilities.remove(member); + } + } + + private static void normalizeOffering(OdpJsonNode document) { + OdpJsonNode schema = document.get("schema"); + if (schema != null + && schema.isObject() + && schema.fieldNames().stream().anyMatch(name -> !FIELD_URL.equals(name))) { + document.remove("schema"); + } + OdpJsonNode price = document.get("price"); + Set prices = Set.of("fixed", "free", "metered", "quote", "range", "starting_at"); + if (price != null && price.isObject()) { + OdpJsonNode type = price.get(FIELD_TYPE); + if (type != null && type.isString() && !prices.contains(type.asString())) { + document.remove("price"); + } + } + OdpJsonNode actions = document.get("actions"); + if (actions == null || !actions.isArray()) { + return; + } + actions.removeIf(action -> { + if (action.isObject() && hasUnknownAuthentication(action)) { + return true; + } + if (action.isObject() + && action.fieldNames().stream() + .anyMatch(name -> !Set.of("authentication", "description", "http", "id", "openapi", "rel") + .contains(name))) { + return true; + } + OdpJsonNode http = action.get("http"); + if (http != null + && http.isObject() + && http.fieldNames().stream() + .anyMatch(name -> !Set.of("href", "method", "request", "response_content_types") + .contains(name))) { + return true; + } + OdpJsonNode request = action.at("/http/request"); + if (request.isObject() + && request.fieldNames().stream() + .anyMatch(name -> !Set.of("content_type", "schema").contains(name))) { + return true; + } + OdpJsonNode actionSchema = action.at("/http/request/schema"); + if (actionSchema.isObject() + && actionSchema.fieldNames().stream().anyMatch(name -> !FIELD_URL.equals(name))) { + return true; + } + OdpJsonNode openapi = action.get("openapi"); + if (openapi != null + && openapi.isObject() + && openapi.fieldNames().stream() + .anyMatch(name -> !Set.of("operation_id", FIELD_URL).contains(name))) { + return true; + } + OdpJsonNode method = action.at("/http/method"); + return method.isString() && !Set.of("GET", "POST").contains(method.asString()); + }); + if (actions.isEmpty()) { + document.remove("actions"); + } + } + + private static void filterDefinitions(OdpJsonNode document, boolean filters) { + OdpJsonNode items = document.get("items"); + if (items == null || !items.isArray()) { + return; + } + items.removeIf(item -> item.isObject() && !(filters ? knownFilter(item) : knownSort(item))); + } + + private static boolean knownFilter(OdpJsonNode definition) { + OdpJsonNode type = definition.get(FIELD_TYPE); + if (type != null + && type.isString() + && !Set.of("boolean", "date", "date-time", "decimal", "integer", "number", "string") + .contains(type.asString())) { + return false; + } + OdpJsonNode operators = definition.get("operators"); + if (operators != null && operators.isArray()) { + for (OdpJsonNode operator : operators) { + if (operator.isString() + && !Set.of("eq", "exists", "gt", "gte", "in", "lt", "lte") + .contains(operator.asString())) { + return false; + } + } + } + OdpJsonNode system = definition.at("/unit/system"); + return !system.isString() || Set.of("service", "ucum").contains(system.asString()); + } + + private static boolean knownSort(OdpJsonNode definition) { + OdpJsonNode keys = definition.get("keys"); + if (keys == null || !keys.isArray()) { + return true; + } + for (OdpJsonNode key : keys) { + OdpJsonNode direction = key.get("direction"); + OdpJsonNode missing = key.get("missing"); + if (direction != null + && direction.isString() + && !Set.of("ascending", "descending").contains(direction.asString()) + || missing != null + && missing.isString() + && !Set.of("first", "last").contains(missing.asString())) { + return false; + } + } + return true; + } + + private static void filterProblemParameters(OdpJsonNode document) { + OdpJsonNode parameters = document.get("invalid_params"); + if (parameters == null || !parameters.isArray()) { + return; + } + parameters.removeIf(parameter -> { + OdpJsonNode location = parameter.get("in"); + return location != null + && location.isString() + && !Set.of("body", "header", "path", "query").contains(location.asString()); + }); + } + public static Collection parseCollection(String json) { Collection collection = parse(json, "collection.schema.json", "Collection", Collection.class); validateLocalizations(collection.language(), collection.localizations(), "Collection"); @@ -82,8 +430,7 @@ public static OfferingPage parseOfferingSearchResponse(String json) { public static Page parsePage(String json, Class itemType) { validate(json, "page-envelope.schema.json", "page envelope"); - JavaType type = MAPPER.getTypeFactory().constructParametricType(Page.class, itemType); - Page page = decode(json, type, "page envelope"); + Page page = decodePage(json, itemType, "page envelope"); if (itemType == Collection.class) { page.items().forEach(item -> { Collection collection = (Collection) item; @@ -101,12 +448,36 @@ public static Page parsePage(String json, Class itemType) { public static String write(Object value) { try { - return MAPPER.writeValueAsString(value); - } catch (JacksonException exception) { + return PROVIDER.write(value); + } catch (IllegalArgumentException exception) { throw new IllegalArgumentException("Unable to encode ODP JSON", exception); } } + public static OdpJsonSchema compileSchema(Map schemas, String rootSchema) { + OdpJsonSchema schema = PROVIDER.compileSchemas(schemas).get(rootSchema); + if (schema == null) { + throw new IllegalArgumentException("ODP JSON Schema registry does not contain " + rootSchema); + } + return schema; + } + + public static OdpJsonNode parseTree(String json) { + return PROVIDER.parseTree(json); + } + + public static T read(String json, Class type) { + return PROVIDER.decode(json, type); + } + + public static T treeToValue(OdpJsonNode node, Class type) { + return PROVIDER.treeToValue(node, type); + } + + public static OdpJsonNode valueToTree(Object value) { + return PROVIDER.valueToTree(value); + } + private static String withInheritedVersion(String json, String version, boolean present) { if (present) { return json; @@ -116,13 +487,52 @@ private static String withInheritedVersion(String json, String version, boolean private static T parse(String json, String schemaName, String documentType, Class type) { validate(json, schemaName, documentType); - return decode(json, MAPPER.getTypeFactory().constructType(type), documentType); + return decode(json, type, documentType); + } + + private static void filterAgentProtocols(OdpJsonNode document) { + OdpJsonNode value = document.get("protocols"); + if (value == null || !value.isObject()) { + return; + } + filterAgentProtocolCategory(value, "enrollment", Set.of("aep")); + filterAgentProtocolCategory(value, "payments", Set.of("mpp", "x402")); + filterAgentProtocolCategory(value, "trust", Set.of("tap")); + if (value.isEmpty()) { + document.remove("protocols"); + } + } + + private static void filterAgentProtocolCategory(OdpJsonNode protocols, String category, Set recognized) { + OdpJsonNode value = protocols.get(category); + if (value == null || !value.isArray()) { + return; + } + int originalSize = value.size(); + value.removeIf(descriptor -> { + OdpJsonNode name = descriptor.isObject() ? descriptor.get(FIELD_NAME) : null; + return name != null && name.isString() && !recognized.contains(name.asString()); + }); + if (originalSize != value.size() && value.isEmpty()) { + protocols.remove(category); + } } - private static T decode(String json, JavaType type, String documentType) { + private static T decode(String json, Class type, String documentType) { try { - return MAPPER.readValue(json, type); - } catch (JacksonException exception) { + return PROVIDER.decode(json, type); + } catch (IllegalArgumentException exception) { + throw new OdpValidationException( + documentType, + List.of(new ValidationIssue("json", exception.getMessage(), Map.of(), "")), + exception); + } + } + + private static Page decodePage(String json, Class itemType, String documentType) { + try { + return PROVIDER.decodePage(json, itemType); + } catch (IllegalArgumentException exception) { throw new OdpValidationException( documentType, List.of(new ValidationIssue("json", exception.getMessage(), Map.of(), "")), @@ -227,31 +637,39 @@ private static OdpValidationException semanticError( } private static void validate(String json, String schemaName, String documentType) { - Schema schema = SCHEMAS.getSchema(SchemaLocation.of(SCHEMA_ORIGIN + schemaName)); - List errors = schema.validate( - json, - InputFormat.JSON, - context -> context.executionConfig(config -> config.formatAssertionsEnabled(true))); - if (!errors.isEmpty()) { - List issues = errors.stream() - .map(error -> new ValidationIssue( - error.getKeyword(), - error.getMessage(), - Map.of(), - error.getInstanceLocation().toString())) - .toList(); + List issues = SCHEMAS.get(schemaName).validate(json); + if (!issues.isEmpty()) { throw new OdpValidationException(documentType, issues); } } - private static SchemaRegistry createRegistry() { + private static Map compileSchemas() { + Map compiled = PROVIDER.compileSchemas(SCHEMA_DOCUMENTS); + Map schemas = new HashMap<>(); + for (String name : SCHEMA_DOCUMENTS.keySet()) { + schemas.put(name.substring(SCHEMA_ORIGIN.length()), compiled.get(name)); + } + return Map.copyOf(schemas); + } + + private static OdpJsonProvider loadProvider() { + List providers = ServiceLoader.load(OdpJsonProvider.class).stream() + .map(ServiceLoader.Provider::get) + .toList(); + if (providers.size() != REQUIRED_PROVIDER_COUNT) { + throw new IllegalStateException( + "ODP JSON requires exactly one provider; add either odp-json-jackson2 or odp-json-jackson3"); + } + return providers.get(0); + } + + private static Map readSchemas() { Map schemas = new HashMap<>(); for (String name : readSchema("index.txt").lines().filter(line -> !line.isBlank()).toList()) { schemas.put(SCHEMA_ORIGIN + name, readSchema(name)); } - return SchemaRegistry.withDefaultDialect( - SpecificationVersion.DRAFT_2020_12, builder -> builder.schemas(schemas)); + return Map.copyOf(schemas); } private static String readSchema(String name) { diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpJsonNode.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpJsonNode.java new file mode 100644 index 0000000..d559341 --- /dev/null +++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpJsonNode.java @@ -0,0 +1,57 @@ +package org.offeringprotocol.odp.core; + +import java.util.Collection; +import java.util.Iterator; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Predicate; + +/** A JSON tree value independent of the selected JSON provider. */ +public abstract class OdpJsonNode implements Iterable { + public abstract OdpJsonNode at(String pointer); + + public abstract boolean asBoolean(boolean defaultValue); + + public abstract int asInt(); + + public abstract String asString(); + + public abstract OdpJsonNode deepCopy(); + + public abstract Set fieldNames(); + + public abstract void forEachEntry(BiConsumer consumer); + + public abstract OdpJsonNode get(String name); + + public abstract boolean has(String name); + + public abstract boolean isArray(); + + public abstract boolean isEmpty(); + + public abstract boolean isNull(); + + public abstract boolean isObject(); + + public abstract boolean isString(); + + @Override + public abstract Iterator iterator(); + + public abstract OdpJsonNode path(String name); + + public abstract OdpJsonNode put(String name, String value); + + public abstract OdpJsonNode putObject(String name); + + public abstract OdpJsonNode remove(String name); + + public abstract void remove(Collection names); + + public abstract boolean removeIf(Predicate predicate); + + public abstract OdpJsonNode set(String name, OdpJsonNode value); + + public abstract int size(); +} diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpJsonProvider.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpJsonProvider.java new file mode 100644 index 0000000..47c0e5f --- /dev/null +++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpJsonProvider.java @@ -0,0 +1,20 @@ +package org.offeringprotocol.odp.core; + +import java.util.Map; + +/** JSON encoding and schema validation supplied by an application-selected adapter. */ +public interface OdpJsonProvider { + Map compileSchemas(Map schemas); + + T decode(String json, Class type); + + Page decodePage(String json, Class itemType); + + OdpJsonNode parseTree(String json); + + T treeToValue(OdpJsonNode node, Class type); + + OdpJsonNode valueToTree(Object value); + + String write(Object value); +} diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpJsonSchema.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpJsonSchema.java new file mode 100644 index 0000000..d600445 --- /dev/null +++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpJsonSchema.java @@ -0,0 +1,9 @@ +package org.offeringprotocol.odp.core; + +import java.util.List; + +/** A compiled JSON Schema independent of the selected validation provider. */ +@FunctionalInterface +public interface OdpJsonSchema { + List validate(String json); +} diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/Offering.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/Offering.java index cd4ff5c..170d276 100644 --- a/odp-core/src/main/java/org/offeringprotocol/odp/core/Offering.java +++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/Offering.java @@ -5,7 +5,6 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; import java.util.Map; -import tools.jackson.databind.JsonNode; public record Offering( @JsonProperty("auth_expands") Boolean authExpands, @@ -20,10 +19,10 @@ public record Offering( @JsonProperty("collection_ids") List collectionIds, PricePreview price, SchemaReference schema, - Map attributes, + Map attributes, List actions, @JsonProperty("detail_fields") List detailFields, - @JsonAnySetter @JsonAnyGetter Map additional) { + @JsonAnySetter @JsonAnyGetter Map additional) { public Offering { images = Copies.list(images); @@ -42,7 +41,7 @@ public record PricePreview( String minimum, String maximum, String unit, - @JsonAnySetter @JsonAnyGetter Map additional) { + @JsonAnySetter @JsonAnyGetter Map additional) { public PricePreview { additional = Copies.nodes(additional); } diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/OfferingPage.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/OfferingPage.java index 58a7f8b..bfabca2 100644 --- a/odp-core/src/main/java/org/offeringprotocol/odp/core/OfferingPage.java +++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/OfferingPage.java @@ -5,7 +5,6 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; import java.util.Map; -import tools.jackson.databind.JsonNode; public record OfferingPage( @JsonProperty("auth_expands") Boolean authExpands, @@ -13,7 +12,7 @@ public record OfferingPage( List items, String next, List refinements, - @JsonAnySetter @JsonAnyGetter Map additional) { + @JsonAnySetter @JsonAnyGetter Map additional) { public OfferingPage { items = List.copyOf(items); refinements = Copies.list(refinements); @@ -27,7 +26,7 @@ public record RefinementGroup(@JsonProperty("filter_id") String filterId, List( @JsonProperty("auth_expands") Boolean authExpands, @JsonProperty("odp_version") String odpVersion, List items, String next, - @JsonAnySetter @JsonAnyGetter Map additional) { + @JsonAnySetter @JsonAnyGetter Map additional) { public Page { items = List.copyOf(items); additional = Copies.map(additional); diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/ProblemDetails.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/ProblemDetails.java index 1acc247..d71a33d 100644 --- a/odp-core/src/main/java/org/offeringprotocol/odp/core/ProblemDetails.java +++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/ProblemDetails.java @@ -5,7 +5,6 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; import java.util.Map; -import tools.jackson.databind.JsonNode; public record ProblemDetails( String type, @@ -15,7 +14,7 @@ public record ProblemDetails( String detail, String instance, @JsonProperty("invalid_params") List invalidParams, - @JsonAnySetter @JsonAnyGetter Map additional) { + @JsonAnySetter @JsonAnyGetter Map additional) { public ProblemDetails { invalidParams = Copies.list(invalidParams); additional = Copies.nodes(additional); diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/SearchCapabilities.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/SearchCapabilities.java index 2f1978b..80ccaa2 100644 --- a/odp-core/src/main/java/org/offeringprotocol/odp/core/SearchCapabilities.java +++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/SearchCapabilities.java @@ -5,12 +5,11 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; import java.util.Map; -import tools.jackson.databind.JsonNode; public record SearchCapabilities( FilterSource filters, SortSource sorts, - @JsonAnySetter @JsonAnyGetter Map additional) { + @JsonAnySetter @JsonAnyGetter Map additional) { public SearchCapabilities { additional = Copies.nodes(additional); } @@ -37,7 +36,7 @@ public record FilterDefinition( List operators, FilterUnit unit, Boolean refinable, - @JsonAnySetter @JsonAnyGetter Map additional) { + @JsonAnySetter @JsonAnyGetter Map additional) { public FilterDefinition { operators = List.copyOf(operators); additional = Copies.nodes(additional); @@ -48,7 +47,7 @@ public record FilterUnit( String system, String code, String title, - @JsonAnySetter @JsonAnyGetter Map additional) { + @JsonAnySetter @JsonAnyGetter Map additional) { public FilterUnit { additional = Copies.nodes(additional); } @@ -59,7 +58,7 @@ public record SortDefinition( String title, String description, List keys, - @JsonAnySetter @JsonAnyGetter Map additional) { + @JsonAnySetter @JsonAnyGetter Map additional) { public SortDefinition { keys = List.copyOf(keys); additional = Copies.nodes(additional); @@ -70,7 +69,7 @@ public record SortKey( @JsonProperty("filter_id") String filterId, String direction, String missing, - @JsonAnySetter @JsonAnyGetter Map additional) { + @JsonAnySetter @JsonAnyGetter Map additional) { public SortKey { additional = Copies.nodes(additional); } @@ -79,8 +78,8 @@ public record SortKey( public record FilterExpression( String id, String operator, - JsonNode value, - @JsonAnySetter @JsonAnyGetter Map additional) { + OdpJsonNode value, + @JsonAnySetter @JsonAnyGetter Map additional) { public FilterExpression { value = value.deepCopy(); additional = Copies.nodes(additional); 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 afd1c18..e25e84c 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 @@ -6,11 +6,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; -import tools.jackson.databind.JsonNode; -import tools.jackson.databind.annotation.JsonDeserialize; -import tools.jackson.databind.annotation.JsonPOJOBuilder; -@JsonDeserialize(builder = ServiceDocument.Builder.class) public final class ServiceDocument { private final String documentOdpVersion; private final String documentName; @@ -29,7 +25,7 @@ public final class ServiceDocument { private final String documentStatusUrl; private final String documentSupportUrl; private final String documentWebsiteUrl; - private final Map documentAdditional; + private final Map documentAdditional; private ServiceDocument(Builder builder) { this.documentOdpVersion = builder.configuredOdpVersion; @@ -170,7 +166,7 @@ public String websiteUrl() { } @JsonAnyGetter - public Map additional() { + public Map additional() { return documentAdditional; } @@ -225,7 +221,6 @@ public int hashCode() { documentAdditional); } - @JsonPOJOBuilder(withPrefix = "") public static final class Builder { private String configuredOdpVersion; private String configuredName; @@ -244,7 +239,7 @@ public static final class Builder { private String configuredStatusUrl; private String configuredSupportUrl; private String configuredWebsiteUrl; - private Map configuredAdditional = Map.of(); + private Map configuredAdditional = Map.of(); private Builder() {} @@ -341,14 +336,14 @@ public Builder websiteUrl(String value) { } @JsonAnySetter - public Builder additional(String name, JsonNode value) { - Map values = new java.util.LinkedHashMap<>(configuredAdditional); + public Builder additional(String name, OdpJsonNode value) { + Map values = new java.util.LinkedHashMap<>(configuredAdditional); values.put(name, value); this.configuredAdditional = Map.copyOf(values); return this; } - public Builder additional(Map values) { + public Builder additional(Map values) { this.configuredAdditional = values == null ? Map.of() : Map.copyOf(values); return this; } diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/page-envelope.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/page-envelope.schema.json index 81ac24b..9ae3880 100644 --- a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/page-envelope.schema.json +++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/page-envelope.schema.json @@ -15,7 +15,8 @@ "const": "1.0" }, "items": { - "type": "array" + "type": "array", + "maxItems": 100 }, "next": { "$ref": "resource-reference.schema.json" diff --git a/odp-directory/README.md b/odp-directory/README.md index 4697db9..59c371d 100644 --- a/odp-directory/README.md +++ b/odp-directory/README.md @@ -13,14 +13,23 @@ Service's Collections and Offerings with [`odp-agent`](../odp-agent/README.md). org.offeringprotocol odp-directory - 0.1.1 + 0.2.0 + + + org.offeringprotocol + odp-json-jackson2 + 0.2.0 ``` ```kotlin -implementation("org.offeringprotocol:odp-directory:0.1.1") +implementation("org.offeringprotocol:odp-directory:0.2.0") +implementation("org.offeringprotocol:odp-json-jackson2:0.2.0") ``` +Replace `odp-json-jackson2` with `odp-json-jackson3` in a Jackson 3 application. Add exactly one +provider; it is discovered automatically at runtime. + ## Search Services `DirectoryClient.create()` uses the fixed production directory. Search accepts natural-language @@ -59,6 +68,9 @@ The response includes structured facets for enrollment protocols, keywords, oper protocols, and payment options. Use them to refine a user or Agent query without 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. + ## Continue a search One call returns one page. When `page.next()` is non-null, submit that opaque value unchanged: diff --git a/odp-directory/pom.xml b/odp-directory/pom.xml index d38c725..96dffad 100644 --- a/odp-directory/pom.xml +++ b/odp-directory/pom.xml @@ -7,7 +7,7 @@ org.offeringprotocol odp-java - 0.1.1 + 0.2.0 odp-directory @@ -24,5 +24,11 @@ odp-core ${project.version} + + ${project.groupId} + odp-json-jackson3 + ${project.version} + test + diff --git a/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryClient.java b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryClient.java index ec1fe19..9bab762 100644 --- a/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryClient.java +++ b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryClient.java @@ -1,6 +1,5 @@ package org.offeringprotocol.odp.directory; -import com.fasterxml.jackson.annotation.JsonInclude; import java.io.IOException; import java.net.URI; import java.net.URLEncoder; @@ -12,17 +11,14 @@ import java.util.List; import java.util.Locale; import java.util.Objects; -import tools.jackson.core.JacksonException; -import tools.jackson.databind.json.JsonMapper; +import org.offeringprotocol.odp.core.OdpJson; +import org.offeringprotocol.odp.core.OdpJsonNode; +import org.offeringprotocol.odp.core.ServiceDocument; /** Client for the canonical ODP directory. */ public final class DirectoryClient { private static final int MAXIMUM_BYTES = 524_288; private static final int MAXIMUM_REDIRECTS = 5; - private static final JsonMapper JSON = JsonMapper.builder() - .changeDefaultPropertyInclusion(inclusion -> inclusion.withValueInclusion(JsonInclude.Include.NON_NULL)) - .build(); - private final DirectoryEnvironment selectedEnvironment; private final HttpClient httpClient; @@ -53,14 +49,13 @@ public DirectoryEnvironment environment() { public DirectoryModels.SearchPage searchServices(DirectoryModels.SearchRequest request) { Objects.requireNonNull(request, "request"); - return decode( - send(selectedEnvironment.origin().resolve("/v1/services/search"), "POST", encode(request)), - DirectoryModels.SearchPage.class); + return decodeSearchPage( + send(selectedEnvironment.origin().resolve("/v1/services/search"), "POST", encode(request))); } public DirectoryModels.SearchPage continueSearchServices(String next) { URI uri = resolveContinuation(next); - return decode(send(uri, "GET", null), DirectoryModels.SearchPage.class); + return decodeSearchPage(send(uri, "GET", null)); } public List suggestServices(String prefix, Integer limit) { @@ -74,9 +69,8 @@ public List suggestServices(String prefix, Integer limit) { + (limit == null ? "" : "&limit=" + limit); String json = send(selectedEnvironment.origin().resolve("/v1/services/suggestions" + query), "GET", null); try { - DirectoryModels.Suggestions suggestions = JSON.readValue(json, DirectoryModels.Suggestions.class); - return suggestions.items(); - } catch (JacksonException exception) { + return OdpJson.read(json, DirectoryModels.Suggestions.class).items(); + } catch (IllegalArgumentException exception) { throw new IllegalArgumentException("Directory suggestions response is invalid", exception); } } @@ -166,17 +160,44 @@ private static boolean isRedirect(int status) { private static String encode(Object value) { try { - return JSON.writeValueAsString(value); - } catch (JacksonException exception) { + return OdpJson.write(value); + } catch (IllegalArgumentException exception) { throw new IllegalArgumentException("Directory request is not encodable", exception); } } - private static T decode(String json, Class type) { + static DirectoryModels.SearchPage decodeSearchPage(String json) { try { - return JSON.readValue(json, type); - } catch (JacksonException exception) { + OdpJsonNode value = OdpJson.parseTree(json); + if (value != null && value.isObject()) { + OdpJsonNode items = value.get("items"); + if (items != null && items.isArray()) { + items.forEach(DirectoryClient::normalizeServiceProtocols); + } + } + if (value == null) { + throw new IllegalArgumentException("Directory response is empty"); + } + return OdpJson.treeToValue(value, DirectoryModels.SearchPage.class); + } catch (IllegalArgumentException exception) { throw new IllegalArgumentException("Directory response is invalid", exception); } } + + private static void normalizeServiceProtocols(OdpJsonNode value) { + if (!value.isObject() || value.get("protocols") == null) { + return; + } + OdpJsonNode service = value; + OdpJsonNode document = service.deepCopy(); + document.remove(List.of("service_origin", "indexed_at")); + document.put("odp_version", "1.0"); + document.putObject("http").put("endpoint_base", "/"); + ServiceDocument parsed = OdpJson.parseAgentServiceDocument(document.toString()); + if (parsed.protocols() == null) { + service.remove("protocols"); + return; + } + service.set("protocols", OdpJson.valueToTree(parsed.protocols())); + } } diff --git a/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryModels.java b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryModels.java index 8ac2a21..16bb074 100644 --- a/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryModels.java +++ b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryModels.java @@ -9,11 +9,11 @@ import java.util.List; import java.util.Map; import org.offeringprotocol.odp.core.AuthenticationRequirement; +import org.offeringprotocol.odp.core.OdpJsonNode; import org.offeringprotocol.odp.core.OdpOperation; import org.offeringprotocol.odp.core.OperationDescriptor; import org.offeringprotocol.odp.core.PaymentOption; import org.offeringprotocol.odp.core.ServiceDocument; -import tools.jackson.databind.JsonNode; public interface DirectoryModels { public record SearchRequest(String query, ServiceFilters filters, Integer limit) { @@ -59,7 +59,7 @@ public record Service( @JsonProperty("status_url") String statusUrl, @JsonProperty("support_url") String supportUrl, @JsonProperty("website_url") String websiteUrl, - @JsonAnySetter @JsonAnyGetter Map additional) { + @JsonAnySetter @JsonAnyGetter Map additional) { public Service { localizations = localizations == null ? List.of() : List.copyOf(localizations); keywords = keywords == null ? List.of() : List.copyOf(keywords); @@ -91,7 +91,7 @@ public record SearchPage( List items, String next, Facets facets, - @JsonAnySetter @JsonAnyGetter Map additional) { + @JsonAnySetter @JsonAnyGetter Map additional) { public SearchPage { items = items == null ? List.of() : List.copyOf(items); additional = additional == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(additional)); diff --git a/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryClientTest.java b/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryClientTest.java index 283e22d..75d75fb 100644 --- a/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryClientTest.java +++ b/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryClientTest.java @@ -1,9 +1,12 @@ package org.offeringprotocol.odp.directory; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import java.util.List; import org.junit.jupiter.api.Test; +import org.offeringprotocol.odp.core.ServiceDocument; class DirectoryClientTest { @Test @@ -21,4 +24,33 @@ void selectsOnlyCanonicalEnvironments() { void rejectsInvalidSearchLimitsBeforeTransport() { assertThrows(IllegalArgumentException.class, () -> new DirectoryModels.SearchRequest("plants", null, 101)); } + + @Test + void filtersUnknownProtocolsFromDirectoryResults() { + DirectoryModels.SearchPage page = DirectoryClient.decodeSearchPage(""" + {"items":[ + {"service_origin":"https://plants.example","name":"Plants","description":"Plant store", + "language":"en","localizations":["en"],"operations":[ + {"authentication":"not-required","name":"get-offering"}, + {"authentication":"not-required","name":"list-offerings"}], + "protocols":{"enrollment":[{"name":"future-enrollment"}], + "payments":[{"authentication":"not-required","name":"future-payment"}, + {"authentication":"not-required","name":"mpp"}], + "trust":[{"name":"future-trust"},{"name":"tap"}]}, + "indexed_at":"2026-08-28T00:00:00Z"}, + {"service_origin":"https://future.example","name":"Future","description":"Future service", + "language":"en","localizations":["en"],"operations":[ + {"authentication":"not-required","name":"get-offering"}, + {"authentication":"not-required","name":"list-offerings"}], + "protocols":{"trust":[{"name":"future-trust"}]}, + "indexed_at":"2026-08-28T00:00:00Z"} + ]} + """); + + ServiceDocument.Protocols protocols = page.items().get(0).protocols(); + assertEquals(List.of(new ServiceDocument.TrustProtocol("tap")), protocols.trust()); + assertEquals(1, protocols.payments().size()); + assertNull(protocols.enrollment()); + assertNull(page.items().get(1).protocols()); + } } diff --git a/odp-json-jackson2/README.md b/odp-json-jackson2/README.md new file mode 100644 index 0000000..839bff6 --- /dev/null +++ b/odp-json-jackson2/README.md @@ -0,0 +1,16 @@ +# ODP JSON for Jackson 2 + +Jackson 2 encoding, decoding, and JSON Schema validation for `odp-core`. + +Add this artifact alongside an ODP role module when the application uses Jackson 2: + +```xml + + org.offeringprotocol + odp-json-jackson2 + 0.2.0 + +``` + +`OdpJson` discovers the provider through Java `ServiceLoader`. Do not also add +`odp-json-jackson3`; exactly one provider must be present at runtime. diff --git a/odp-json-jackson2/pom.xml b/odp-json-jackson2/pom.xml new file mode 100644 index 0000000..713a56b --- /dev/null +++ b/odp-json-jackson2/pom.xml @@ -0,0 +1,44 @@ + + + 4.0.0 + + + org.offeringprotocol + odp-java + 0.2.0 + + + odp-json-jackson2 + ODP JSON for Jackson 2 + Jackson 2 JSON and schema validation provider for ODP Java. + + + org.offeringprotocol.odp.json.jackson2 + + + + + ${project.groupId} + odp-core + ${project.version} + + + com.fasterxml.jackson.core + jackson-databind + ${jackson2.version} + + + com.networknt + json-schema-validator + ${json-schema-validator2.version} + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + + + + + diff --git a/odp-json-jackson2/src/main/java/org/offeringprotocol/odp/json/jackson2/Jackson2JsonProvider.java b/odp-json-jackson2/src/main/java/org/offeringprotocol/odp/json/jackson2/Jackson2JsonProvider.java new file mode 100644 index 0000000..7660fdc --- /dev/null +++ b/odp-json-jackson2/src/main/java/org/offeringprotocol/odp/json/jackson2/Jackson2JsonProvider.java @@ -0,0 +1,327 @@ +package org.offeringprotocol.odp.json.jackson2; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +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.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Predicate; +import org.offeringprotocol.odp.core.OdpJsonNode; +import org.offeringprotocol.odp.core.OdpJsonProvider; +import org.offeringprotocol.odp.core.OdpJsonSchema; +import org.offeringprotocol.odp.core.Page; +import org.offeringprotocol.odp.core.ServiceDocument; +import org.offeringprotocol.odp.core.ValidationIssue; + +public final class Jackson2JsonProvider implements OdpJsonProvider { + private static final String DECODE_ERROR = "Unable to decode JSON"; + + @JsonPOJOBuilder(withPrefix = "") + private interface BuilderMixin {} + + private static final class Node extends OdpJsonNode { + private final JsonNode value; + + private Node(JsonNode value) { + this.value = Objects.requireNonNull(value, "value"); + } + + @Override + public OdpJsonNode at(String pointer) { + return new Node(value.at(pointer)); + } + + @Override + public boolean asBoolean(boolean defaultValue) { + return value.asBoolean(defaultValue); + } + + @Override + public int asInt() { + return value.asInt(); + } + + @Override + public String asString() { + return value.asText(); + } + + @Override + public OdpJsonNode deepCopy() { + return new Node(value.deepCopy()); + } + + @Override + public boolean equals(Object other) { + return other instanceof Node node && value.equals(node.value); + } + + @Override + public Set fieldNames() { + Set names = new LinkedHashSet<>(); + value.fieldNames().forEachRemaining(names::add); + return Set.copyOf(names); + } + + @Override + public void forEachEntry(BiConsumer consumer) { + value.fields().forEachRemaining(entry -> consumer.accept(entry.getKey(), new Node(entry.getValue()))); + } + + @Override + public OdpJsonNode get(String name) { + JsonNode child = value.get(name); + return child == null ? null : new Node(child); + } + + @Override + public boolean has(String name) { + return value.has(name); + } + + @Override + public int hashCode() { + return value.hashCode(); + } + + @Override + public boolean isArray() { + return value.isArray(); + } + + @Override + public boolean isEmpty() { + return value.isEmpty(); + } + + @Override + public boolean isNull() { + return value.isNull(); + } + + @Override + public boolean isObject() { + return value.isObject(); + } + + @Override + public boolean isString() { + return value.isTextual(); + } + + @Override + public Iterator iterator() { + List children = new ArrayList<>(); + value.forEach(child -> children.add(new Node(child))); + return children.iterator(); + } + + @Override + public OdpJsonNode path(String name) { + return new Node(value.path(name)); + } + + @Override + public OdpJsonNode put(String name, String text) { + requireObject().put(name, text); + return this; + } + + @Override + public OdpJsonNode putObject(String name) { + return new Node(requireObject().putObject(name)); + } + + @Override + public OdpJsonNode remove(String name) { + JsonNode removed = requireObject().remove(name); + return removed == null ? null : new Node(removed); + } + + @Override + public void remove(Collection names) { + requireObject().remove(names); + } + + @Override + public boolean removeIf(Predicate predicate) { + ArrayNode array = requireArray(); + boolean removed = false; + for (int index = array.size() - 1; index >= 0; index--) { + if (predicate.test(new Node(array.get(index)))) { + array.remove(index); + removed = true; + } + } + return removed; + } + + @Override + public OdpJsonNode set(String name, OdpJsonNode node) { + requireObject().set(name, unwrap(node)); + return this; + } + + @Override + public int size() { + return value.size(); + } + + @Override + public String toString() { + return value.toString(); + } + + private ArrayNode requireArray() { + if (!(value instanceof ArrayNode array)) { + throw new IllegalStateException("JSON value is not an array"); + } + return array; + } + + private ObjectNode requireObject() { + if (!(value instanceof ObjectNode object)) { + throw new IllegalStateException("JSON value is not an object"); + } + return object; + } + } + + private static final class NodeDeserializer extends JsonDeserializer { + @Override + public OdpJsonNode deserialize(JsonParser parser, DeserializationContext context) throws IOException { + return new Node(parser.getCodec().readTree(parser)); + } + } + + private static final class NodeSerializer extends JsonSerializer { + @Override + public void serialize(OdpJsonNode value, JsonGenerator generator, SerializerProvider serializers) + throws IOException { + generator.writeTree(unwrap(value)); + } + } + + @JsonDeserialize(builder = ServiceDocument.Builder.class) + private interface ServiceDocumentMixin {} + + private static JsonNode unwrap(OdpJsonNode node) { + if (!(node instanceof Node jacksonNode)) { + throw new IllegalArgumentException("ODP JSON node belongs to another provider"); + } + return jacksonNode.value; + } + + private final ObjectMapper mapper; + + public Jackson2JsonProvider() { + SimpleModule module = new SimpleModule() + .addDeserializer(OdpJsonNode.class, new NodeDeserializer()) + .addSerializer(OdpJsonNode.class, new NodeSerializer()); + mapper = new ObjectMapper() + .addMixIn(ServiceDocument.class, ServiceDocumentMixin.class) + .addMixIn(ServiceDocument.Builder.class, BuilderMixin.class) + .registerModule(module) + .setSerializationInclusion(JsonInclude.Include.NON_NULL); + } + + @Override + public Map compileSchemas(Map schemas) { + SchemaRegistry registry = SchemaRegistry.withDefaultDialect( + SpecificationVersion.DRAFT_2020_12, builder -> builder.schemas(schemas)); + return schemas.keySet().stream() + .collect(java.util.stream.Collectors.toUnmodifiableMap( + name -> name, name -> validator(registry.getSchema(SchemaLocation.of(name))))); + } + + private static OdpJsonSchema validator(Schema schema) { + return json -> schema + .validate( + json, + InputFormat.JSON, + context -> context.executionConfig(config -> config.formatAssertionsEnabled(true))) + .stream() + .map(error -> new ValidationIssue( + error.getKeyword(), + error.getMessage(), + Map.of(), + error.getInstanceLocation().toString())) + .toList(); + } + + @Override + public T decode(String json, Class type) { + try { + return mapper.readValue(json, type); + } catch (JsonProcessingException exception) { + throw new IllegalArgumentException(DECODE_ERROR, exception); + } + } + + @Override + public Page decodePage(String json, Class itemType) { + try { + return mapper.readValue(json, mapper.getTypeFactory().constructParametricType(Page.class, itemType)); + } catch (JsonProcessingException exception) { + throw new IllegalArgumentException(DECODE_ERROR, exception); + } + } + + @Override + public OdpJsonNode parseTree(String json) { + try { + JsonNode value = mapper.readTree(json); + return value == null ? null : new Node(value); + } catch (JsonProcessingException exception) { + throw new IllegalArgumentException(DECODE_ERROR, exception); + } + } + + @Override + public T treeToValue(OdpJsonNode node, Class type) { + try { + return mapper.treeToValue(unwrap(node), type); + } catch (JsonProcessingException exception) { + throw new IllegalArgumentException(DECODE_ERROR, exception); + } + } + + @Override + public OdpJsonNode valueToTree(Object value) { + return new Node(mapper.valueToTree(value)); + } + + @Override + public String write(Object value) { + try { + return mapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalArgumentException("Unable to encode JSON", exception); + } + } +} diff --git a/odp-json-jackson2/src/main/resources/META-INF/services/org.offeringprotocol.odp.core.OdpJsonProvider b/odp-json-jackson2/src/main/resources/META-INF/services/org.offeringprotocol.odp.core.OdpJsonProvider new file mode 100644 index 0000000..eda2dcf --- /dev/null +++ b/odp-json-jackson2/src/main/resources/META-INF/services/org.offeringprotocol.odp.core.OdpJsonProvider @@ -0,0 +1 @@ +org.offeringprotocol.odp.json.jackson2.Jackson2JsonProvider diff --git a/odp-json-jackson2/src/test/java/org/offeringprotocol/odp/json/jackson2/Jackson2JsonProviderTest.java b/odp-json-jackson2/src/test/java/org/offeringprotocol/odp/json/jackson2/Jackson2JsonProviderTest.java new file mode 100644 index 0000000..b8bac1d --- /dev/null +++ b/odp-json-jackson2/src/test/java/org/offeringprotocol/odp/json/jackson2/Jackson2JsonProviderTest.java @@ -0,0 +1,31 @@ +package org.offeringprotocol.odp.json.jackson2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; +import org.offeringprotocol.odp.core.OdpJson; +import org.offeringprotocol.odp.core.OdpValidationException; + +class Jackson2JsonProviderTest { + @Test + void supportsTheOdpJsonContract() { + String document = """ + { + "odp_version":"1.0", + "name":"Example Service", + "description":"An ODP Service.", + "language":"en", + "localizations":["en"], + "operations":[ + {"authentication":"not-required","name":"get-offering"}, + {"authentication":"not-required","name":"list-offerings"} + ], + "http":{"endpoint_base":"/odp"} + } + """; + + assertEquals("Example Service", OdpJson.parseServiceDocument(document).name()); + assertThrows(OdpValidationException.class, () -> OdpJson.parseServiceDocument("{}")); + } +} diff --git a/odp-json-jackson3/README.md b/odp-json-jackson3/README.md new file mode 100644 index 0000000..b21e6ed --- /dev/null +++ b/odp-json-jackson3/README.md @@ -0,0 +1,16 @@ +# ODP JSON for Jackson 3 + +Jackson 3 encoding, decoding, and JSON Schema validation for `odp-core`. + +Add this artifact alongside an ODP role module when the application uses Jackson 3: + +```xml + + org.offeringprotocol + odp-json-jackson3 + 0.2.0 + +``` + +`OdpJson` discovers the provider through Java `ServiceLoader`. Do not also add +`odp-json-jackson2`; exactly one provider must be present at runtime. diff --git a/odp-json-jackson3/pom.xml b/odp-json-jackson3/pom.xml new file mode 100644 index 0000000..ac40cda --- /dev/null +++ b/odp-json-jackson3/pom.xml @@ -0,0 +1,39 @@ + + + 4.0.0 + + + org.offeringprotocol + odp-java + 0.2.0 + + + odp-json-jackson3 + ODP JSON for Jackson 3 + Jackson 3 JSON and schema validation provider for ODP Java. + + + org.offeringprotocol.odp.json.jackson3 + + + + + ${project.groupId} + odp-core + ${project.version} + + + com.networknt + json-schema-validator + ${json-schema-validator3.version} + + + tools.jackson.dataformat + jackson-dataformat-yaml + + + + + diff --git a/odp-json-jackson3/src/main/java/org/offeringprotocol/odp/json/jackson3/Jackson3JsonProvider.java b/odp-json-jackson3/src/main/java/org/offeringprotocol/odp/json/jackson3/Jackson3JsonProvider.java new file mode 100644 index 0000000..d72696f --- /dev/null +++ b/odp-json-jackson3/src/main/java/org/offeringprotocol/odp/json/jackson3/Jackson3JsonProvider.java @@ -0,0 +1,331 @@ +package org.offeringprotocol.odp.json.jackson3; + +import com.fasterxml.jackson.annotation.JsonInclude; +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.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Predicate; +import org.offeringprotocol.odp.core.OdpJsonNode; +import org.offeringprotocol.odp.core.OdpJsonProvider; +import org.offeringprotocol.odp.core.OdpJsonSchema; +import org.offeringprotocol.odp.core.Page; +import org.offeringprotocol.odp.core.ServiceDocument; +import org.offeringprotocol.odp.core.ValidationIssue; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.ValueSerializer; +import tools.jackson.databind.annotation.JsonDeserialize; +import tools.jackson.databind.annotation.JsonPOJOBuilder; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.module.SimpleModule; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; + +public final class Jackson3JsonProvider implements OdpJsonProvider { + private static final String DECODE_ERROR = "Unable to decode JSON"; + + @JsonPOJOBuilder(withPrefix = "") + private interface BuilderMixin {} + + private static final class Node extends OdpJsonNode { + private final JsonNode value; + + private Node(JsonNode value) { + this.value = Objects.requireNonNull(value, "value"); + } + + @Override + public OdpJsonNode at(String pointer) { + return new Node(value.at(pointer)); + } + + @Override + public boolean asBoolean(boolean defaultValue) { + return value.asBoolean(defaultValue); + } + + @Override + public int asInt() { + return value.asInt(); + } + + @Override + public String asString() { + return value.asString(); + } + + @Override + public OdpJsonNode deepCopy() { + return new Node(value.deepCopy()); + } + + @Override + public boolean equals(Object other) { + return other instanceof Node node && value.equals(node.value); + } + + @Override + public Set fieldNames() { + Set names = new LinkedHashSet<>(); + value.properties().forEach(entry -> names.add(entry.getKey())); + return Set.copyOf(names); + } + + @Override + public void forEachEntry(BiConsumer consumer) { + value.properties().forEach(entry -> consumer.accept(entry.getKey(), new Node(entry.getValue()))); + } + + @Override + public OdpJsonNode get(String name) { + JsonNode child = value.get(name); + return child == null ? null : new Node(child); + } + + @Override + public boolean has(String name) { + return value.has(name); + } + + @Override + public int hashCode() { + return value.hashCode(); + } + + @Override + public boolean isArray() { + return value.isArray(); + } + + @Override + public boolean isEmpty() { + return value.isEmpty(); + } + + @Override + public boolean isNull() { + return value.isNull(); + } + + @Override + public boolean isObject() { + return value.isObject(); + } + + @Override + public boolean isString() { + return value.isString(); + } + + @Override + public Iterator iterator() { + List children = new ArrayList<>(); + value.forEach(child -> children.add(new Node(child))); + return children.iterator(); + } + + @Override + public OdpJsonNode path(String name) { + return new Node(value.path(name)); + } + + @Override + public OdpJsonNode put(String name, String text) { + requireObject().put(name, text); + return this; + } + + @Override + public OdpJsonNode putObject(String name) { + return new Node(requireObject().putObject(name)); + } + + @Override + public OdpJsonNode remove(String name) { + JsonNode removed = requireObject().remove(name); + return removed == null ? null : new Node(removed); + } + + @Override + public void remove(Collection names) { + requireObject().remove(names); + } + + @Override + public boolean removeIf(Predicate predicate) { + ArrayNode array = requireArray(); + boolean removed = false; + for (int index = array.size() - 1; index >= 0; index--) { + if (predicate.test(new Node(array.get(index)))) { + array.remove(index); + removed = true; + } + } + return removed; + } + + @Override + public OdpJsonNode set(String name, OdpJsonNode node) { + requireObject().set(name, unwrap(node)); + return this; + } + + @Override + public int size() { + return value.size(); + } + + @Override + public String toString() { + return value.toString(); + } + + private ArrayNode requireArray() { + if (!(value instanceof ArrayNode array)) { + throw new IllegalStateException("JSON value is not an array"); + } + return array; + } + + private ObjectNode requireObject() { + if (!(value instanceof ObjectNode object)) { + throw new IllegalStateException("JSON value is not an object"); + } + return object; + } + } + + private static final class NodeDeserializer extends ValueDeserializer { + @Override + public OdpJsonNode deserialize(JsonParser parser, DeserializationContext context) throws JacksonException { + return new Node(context.readTree(parser)); + } + } + + private static final class NodeSerializer extends ValueSerializer { + @Override + public void serialize(OdpJsonNode value, JsonGenerator generator, SerializationContext context) + throws JacksonException { + context.writeTree(generator, unwrap(value)); + } + } + + @JsonDeserialize(builder = ServiceDocument.Builder.class) + private interface ServiceDocumentMixin {} + + private static JsonNode unwrap(OdpJsonNode node) { + if (!(node instanceof Node jacksonNode)) { + throw new IllegalArgumentException("ODP JSON node belongs to another provider"); + } + return jacksonNode.value; + } + + private final JsonMapper mapper; + + public Jackson3JsonProvider() { + SimpleModule module = new SimpleModule() + .addDeserializer(OdpJsonNode.class, new NodeDeserializer()) + .addSerializer(OdpJsonNode.class, new NodeSerializer()); + mapper = JsonMapper.builder() + .addMixIn(ServiceDocument.class, ServiceDocumentMixin.class) + .addMixIn(ServiceDocument.Builder.class, BuilderMixin.class) + .addModule(module) + .changeDefaultPropertyInclusion(inclusion -> inclusion.withValueInclusion(JsonInclude.Include.NON_NULL)) + .build(); + } + + @Override + public Map compileSchemas(Map schemas) { + SchemaRegistry registry = SchemaRegistry.withDefaultDialect( + SpecificationVersion.DRAFT_2020_12, builder -> builder.schemas(schemas)); + return schemas.keySet().stream() + .collect(java.util.stream.Collectors.toUnmodifiableMap( + name -> name, name -> validator(registry.getSchema(SchemaLocation.of(name))))); + } + + private static OdpJsonSchema validator(Schema schema) { + return json -> schema + .validate( + json, + InputFormat.JSON, + context -> context.executionConfig(config -> config.formatAssertionsEnabled(true))) + .stream() + .map(error -> new ValidationIssue( + error.getKeyword(), + error.getMessage(), + Map.of(), + error.getInstanceLocation().toString())) + .toList(); + } + + @Override + public T decode(String json, Class type) { + try { + return mapper.readValue(json, type); + } catch (JacksonException exception) { + throw new IllegalArgumentException(DECODE_ERROR, exception); + } + } + + @Override + public Page decodePage(String json, Class itemType) { + try { + return mapper.readValue(json, mapper.getTypeFactory().constructParametricType(Page.class, itemType)); + } catch (JacksonException exception) { + throw new IllegalArgumentException(DECODE_ERROR, exception); + } + } + + @Override + public OdpJsonNode parseTree(String json) { + try { + JsonNode value = mapper.readTree(json); + return value == null ? null : new Node(value); + } catch (JacksonException exception) { + throw new IllegalArgumentException(DECODE_ERROR, exception); + } + } + + @Override + public T treeToValue(OdpJsonNode node, Class type) { + try { + return mapper.treeToValue(unwrap(node), type); + } catch (JacksonException exception) { + throw new IllegalArgumentException(DECODE_ERROR, exception); + } + } + + @Override + public OdpJsonNode valueToTree(Object value) { + try { + return new Node(mapper.valueToTree(value)); + } catch (JacksonException exception) { + throw new IllegalArgumentException("Unable to encode JSON", exception); + } + } + + @Override + public String write(Object value) { + try { + return mapper.writeValueAsString(value); + } catch (JacksonException exception) { + throw new IllegalArgumentException("Unable to encode JSON", exception); + } + } +} diff --git a/odp-json-jackson3/src/main/resources/META-INF/services/org.offeringprotocol.odp.core.OdpJsonProvider b/odp-json-jackson3/src/main/resources/META-INF/services/org.offeringprotocol.odp.core.OdpJsonProvider new file mode 100644 index 0000000..a87c61c --- /dev/null +++ b/odp-json-jackson3/src/main/resources/META-INF/services/org.offeringprotocol.odp.core.OdpJsonProvider @@ -0,0 +1 @@ +org.offeringprotocol.odp.json.jackson3.Jackson3JsonProvider diff --git a/odp-core/src/test/java/org/offeringprotocol/odp/core/OdpJsonTest.java b/odp-json-jackson3/src/test/java/org/offeringprotocol/odp/core/OdpJsonTest.java similarity index 75% rename from odp-core/src/test/java/org/offeringprotocol/odp/core/OdpJsonTest.java rename to odp-json-jackson3/src/test/java/org/offeringprotocol/odp/core/OdpJsonTest.java index aac5816..513428a 100644 --- a/odp-core/src/test/java/org/offeringprotocol/odp/core/OdpJsonTest.java +++ b/odp-json-jackson3/src/test/java/org/offeringprotocol/odp/core/OdpJsonTest.java @@ -66,6 +66,52 @@ void parsesTapTrustProtocol() { document.protocols().trust()); } + @Test + void filtersUnknownProtocolsForAgentsWithoutWeakeningServiceValidation() { + String protocols = """ + "protocols":{ + "enrollment":[{"name":"future-enrollment"},{"name":"aep"}], + "payments":[ + {"authentication":"not-required","name":"future-payment"}, + {"authentication":"not-required","name":"mpp"}, + {"authentication":"not-required","name":"x402"}], + "trust":[{"name":"future-trust"},{"name":"tap"}]}, + """; + String value = DOCUMENT.replace("\"example_extension\":", protocols + "\"example_extension\":"); + + assertThrows(OdpValidationException.class, () -> OdpJson.parseServiceDocument(value)); + ServiceDocument document = OdpJson.parseAgentServiceDocument(value); + + assertEquals( + List.of(new ServiceDocument.EnrollmentProtocol("aep")), + document.protocols().enrollment()); + assertEquals(2, document.protocols().payments().size()); + assertEquals( + List.of(new ServiceDocument.TrustProtocol("tap")), + document.protocols().trust()); + } + + @Test + void omitsAgentProtocolCategoriesContainingOnlyUnknownNames() { + String protocols = """ + "protocols":{ + "enrollment":[{"name":"future-enrollment"}], + "payments":[{"authentication":"not-required","name":"future-payment"}], + "trust":[{"name":"future-trust"}]}, + """; + String value = DOCUMENT.replace("\"example_extension\":", protocols + "\"example_extension\":"); + + assertNull(OdpJson.parseAgentServiceDocument(value).protocols()); + } + + @Test + void rejectsMalformedRecognizedProtocolsForAgents() { + String value = DOCUMENT.replace( + "\"example_extension\":", "\"protocols\":{\"payments\":[{\"name\":\"mpp\"}]},\"example_extension\":"); + + assertThrows(OdpValidationException.class, () -> OdpJson.parseAgentServiceDocument(value)); + } + @Test void rejectsInvalidServiceDocuments() { OdpValidationException exception = assertThrows( diff --git a/odp-service/README.md b/odp-service/README.md index 58c2549..d53ac3a 100644 --- a/odp-service/README.md +++ b/odp-service/README.md @@ -13,16 +13,23 @@ it does not load, copy, sort, or index a storage-backed catalog. org.offeringprotocol odp-service - 0.1.1 + 0.2.0 + + + org.offeringprotocol + odp-json-jackson2 + 0.2.0 ``` ```kotlin -implementation("org.offeringprotocol:odp-service:0.1.1") +implementation("org.offeringprotocol:odp-service:0.2.0") +implementation("org.offeringprotocol:odp-json-jackson2:0.2.0") ``` The Service module brings in `odp-core` transitively and does not depend on Agent or directory -behavior. +behavior. Replace `odp-json-jackson2` with `odp-json-jackson3` in a Jackson 3 application. Exactly +one provider must be present at runtime; no programmatic configuration is required. ## Minimum integration @@ -160,6 +167,9 @@ request-body ceiling, Service Document generation, media types, and ODP Problem application owns connection policy, HTTP caching headers, compression, observability, rate limits, and deployment lifecycle. +Service Document protocol advertisements are validated against the declared ODP version and accept +only the enrollment, payment, and trust protocol names defined by that version. + ## Authentication and payment Operation authentication defaults to `not-required`. Override advertised requirements without diff --git a/odp-service/pom.xml b/odp-service/pom.xml index d5c89eb..2b34d8d 100644 --- a/odp-service/pom.xml +++ b/odp-service/pom.xml @@ -7,7 +7,7 @@ org.offeringprotocol odp-java - 0.1.1 + 0.2.0 odp-service @@ -24,5 +24,11 @@ odp-core ${project.version} + + ${project.groupId} + odp-json-jackson3 + ${project.version} + test + 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 d7ee033..1bd6b05 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 @@ -9,6 +9,7 @@ import org.offeringprotocol.odp.core.Collection; import org.offeringprotocol.odp.core.Odp; import org.offeringprotocol.odp.core.OdpJson; +import org.offeringprotocol.odp.core.OdpJsonNode; import org.offeringprotocol.odp.core.OdpOperation; import org.offeringprotocol.odp.core.OdpValidationException; import org.offeringprotocol.odp.core.Offering; @@ -16,7 +17,6 @@ import org.offeringprotocol.odp.core.ProblemDetails; import org.offeringprotocol.odp.core.SearchCapabilities; import org.offeringprotocol.odp.core.ServiceDocument; -import tools.jackson.databind.JsonNode; /** Framework-neutral ODP Service request handler. */ public final class OdpService { @@ -228,7 +228,7 @@ public static final class Builder { private String configuredStatusUrl; private String configuredSupportUrl; private String configuredWebsiteUrl; - private Map configuredAdditional = Map.of(); + private Map configuredAdditional = Map.of(); private Map configuredEndpoints; private Map configuredOperationAuthentication = Map.of(); @@ -300,7 +300,7 @@ public Builder websiteUrl(String value) { return this; } - public Builder additional(Map values) { + public Builder additional(Map values) { this.configuredAdditional = Map.copyOf(values); return this; } diff --git a/pom.xml b/pom.xml index 3772480..4e81fff 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ org.offeringprotocol odp-java - 0.1.1 + 0.2.0 pom Offering Discovery Protocol for Java @@ -39,6 +39,8 @@ odp-core + odp-json-jackson2 + odp-json-jackson3 odp-directory odp-agent odp-service @@ -52,8 +54,10 @@ UTF-8 6.1.3 - 3.2.2 - 3.0.7 + 2.18.3 + 3.2.2 + 2.0.0 + 3.0.7 0.8.15 3.6.3 0.11.0 @@ -84,7 +88,7 @@ tools.jackson jackson-bom - ${jackson.version} + ${jackson3.version} pom import diff --git a/scripts/verify-consumer.sh b/scripts/verify-consumer.sh index 122bc6d..e41d0d2 100755 --- a/scripts/verify-consumer.sh +++ b/scripts/verify-consumer.sh @@ -31,7 +31,7 @@ if [ "${ODP_CONSUMER_SOURCE:-local}" = "local" ]; then } install_artifact "$root/pom.xml" "$root/pom.xml" - for artifact in odp-core odp-directory odp-agent odp-service; do + for artifact in odp-core odp-json-jackson2 odp-directory odp-agent odp-service; do install_artifact \ "$root/$artifact/target/$artifact-$version.jar" \ "$root/$artifact/pom.xml" diff --git a/testdata/consumer/pom.xml b/testdata/consumer/pom.xml index f0efc7f..6a7661f 100644 --- a/testdata/consumer/pom.xml +++ b/testdata/consumer/pom.xml @@ -14,6 +14,11 @@ + + org.offeringprotocol + odp-json-jackson2 + ${odp.version} + org.offeringprotocol odp-core diff --git a/tools/odp-conformance/pom.xml b/tools/odp-conformance/pom.xml index bc041b3..c8e7218 100644 --- a/tools/odp-conformance/pom.xml +++ b/tools/odp-conformance/pom.xml @@ -7,7 +7,7 @@ org.offeringprotocol odp-java - 0.1.1 + 0.2.0 ../../pom.xml @@ -21,6 +21,11 @@ + + ${project.groupId} + odp-json-jackson3 + ${project.version} + ${project.groupId} odp-agent 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 4430272..d5d9133 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 @@ -31,12 +31,15 @@ import org.offeringprotocol.odp.core.OperationDescriptor; import org.offeringprotocol.odp.core.Page; import org.offeringprotocol.odp.core.ResourceIdentity; +import org.offeringprotocol.odp.core.SearchCapabilities.FilterDefinition; +import org.offeringprotocol.odp.core.SearchCapabilities.SortDefinition; import org.offeringprotocol.odp.core.ServiceDocument; import org.offeringprotocol.odp.service.OdpHttpRequest; import org.offeringprotocol.odp.service.OdpService; import tools.jackson.core.JacksonException; import tools.jackson.databind.JsonNode; import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ObjectNode; /** Process adapter for the language-neutral ODP conformance harness. */ public final class ConformanceAdapter { @@ -44,6 +47,9 @@ public final class ConformanceAdapter { 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 FILTER_ADVERTISEMENT = "filter-advertisement"; + private static final String NORMALIZE_AGENT_RESPONSE = "normalize-agent-response"; + private static final String VALIDATE_ADVERTISEMENT = "validate-advertisement"; private static final String VALIDATE_PROBLEM = "validate-problem"; private static final JsonMapper JSON = JsonMapper.builder().build(); private static final Set AGENT_BASELINE = Set.of( @@ -108,6 +114,7 @@ private static Evaluation evaluateCase(String subject, JsonNode test, String rol "validate-request".equals(operation(test)) ? parse(test, "request", OdpJson::parseCollectionSearchRequest) : skipped(); + case "composition-contract" -> evaluateComposition(test, role); case "offering-search-contract" -> "validate-request".equals(operation(test)) ? parse(test, "request", OdpJson::parseOfferingSearchRequest) @@ -120,6 +127,73 @@ private static Evaluation evaluateCase(String subject, JsonNode test, String rol }; } + private static Evaluation evaluateComposition(JsonNode test, String role) { + if (NORMALIZE_AGENT_RESPONSE.equals(operation(test)) && AGENT_ROLE.equals(role)) { + try { + JsonNode actual = JSON.readTree(OdpJson.normalizeAgentResponse( + required(test, DOCUMENT_FIELD).toString(), + required(test, "kind").asString())); + validateAgentResponse(actual.toString(), required(test, "kind").asString()); + return result(actual.equals(required(test, "expected"))); + } catch (JacksonException exception) { + throw new IllegalArgumentException("Unable to decode normalized Agent response", exception); + } + } + if (!VALIDATE_ADVERTISEMENT.equals(operation(test)) + && (!FILTER_ADVERTISEMENT.equals(operation(test)) || !AGENT_ROLE.equals(role))) { + return skipped(); + } + String document = """ + {"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"}],"protocols": + """ + required(test, "protocols") + "}"; + if (VALIDATE_ADVERTISEMENT.equals(operation(test))) { + return parseValue(document, OdpJson::parseServiceDocument, valid(test)); + } + try { + ServiceDocument parsed = OdpJson.parseAgentServiceDocument(document); + JsonNode actual = + parsed.protocols() == null ? JSON.createObjectNode() : JSON.valueToTree(parsed.protocols()); + removeNullProperties(actual); + return result(actual.equals(required(test, "expected"))); + } catch (JacksonException exception) { + throw new IllegalArgumentException("Unable to encode Agent protocol projection", exception); + } + } + + private static void validateAgentResponse(String document, String kind) { + switch (kind) { + case "service-document" -> OdpJson.parseAgentServiceDocument(document); + case "collection" -> OdpJson.parseCollection(document); + case "offering" -> OdpJson.parseOffering(document); + case "collection-page" -> OdpJson.parsePage(document, org.offeringprotocol.odp.core.Collection.class); + case "offering-page" -> OdpJson.parsePage(document, Offering.class); + case "filter-page" -> OdpJson.parsePage(document, FilterDefinition.class); + case "sort-page" -> OdpJson.parsePage(document, SortDefinition.class); + case "problem" -> OdpJson.parseProblemDetails(document); + default -> throw new IllegalArgumentException("Unknown Agent response kind"); + } + } + + private static void removeNullProperties(JsonNode node) { + if (node instanceof ObjectNode object) { + List nullProperties = new ArrayList<>(); + object.properties().forEach(entry -> { + if (entry.getValue().isNull()) { + nullProperties.add(entry.getKey()); + } else { + removeNullProperties(entry.getValue()); + } + }); + nullProperties.forEach(object::remove); + return; + } + node.forEach(ConformanceAdapter::removeNullProperties); + } + private static Evaluation evaluateAttributeSchema(JsonNode test) { return switch (operation(test)) { case "validate-reference" -> {