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" -> {