Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -41,7 +45,12 @@ For an Agent application:
<dependency>
<groupId>org.offeringprotocol</groupId>
<artifactId>odp-agent</artifactId>
<version>0.1.1</version>
<version>0.2.0</version>
</dependency>
<dependency>
<groupId>org.offeringprotocol</groupId>
<artifactId>odp-json-jackson2</artifactId>
<version>0.2.0</version>
</dependency>
```

Expand All @@ -51,16 +60,24 @@ For a Service integration:
<dependency>
<groupId>org.offeringprotocol</groupId>
<artifactId>odp-service</artifactId>
<version>0.1.1</version>
<version>0.2.0</version>
</dependency>
<dependency>
<groupId>org.offeringprotocol</groupId>
<artifactId>odp-json-jackson2</artifactId>
<version>0.2.0</version>
</dependency>
```

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.

Expand Down
3 changes: 3 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
7 changes: 6 additions & 1 deletion examples/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<parent>
<groupId>org.offeringprotocol</groupId>
<artifactId>odp-java</artifactId>
<version>0.1.1</version>
<version>0.2.0</version>
</parent>

<artifactId>odp-examples</artifactId>
Expand All @@ -20,6 +20,11 @@
</properties>

<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>odp-json-jackson3</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>odp-agent</artifactId>
Expand Down
18 changes: 15 additions & 3 deletions odp-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,23 @@ capability checks, Collections, Offerings, localization, and continuations.
<dependency>
<groupId>org.offeringprotocol</groupId>
<artifactId>odp-agent</artifactId>
<version>0.1.1</version>
<version>0.2.0</version>
</dependency>
<dependency>
<groupId>org.offeringprotocol</groupId>
<artifactId>odp-json-jackson2</artifactId>
<version>0.2.0</version>
</dependency>
```

```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

Expand Down Expand Up @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion odp-agent/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<parent>
<groupId>org.offeringprotocol</groupId>
<artifactId>odp-java</artifactId>
<version>0.1.1</version>
<version>0.2.0</version>
</parent>

<artifactId>odp-agent</artifactId>
Expand All @@ -29,5 +29,11 @@
<artifactId>odp-directory</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>odp-json-jackson3</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"),
Expand All @@ -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<JsonNode> operations = findOperations(document, action.openapi().operationId());
List<OdpJsonNode> 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");
Expand Down Expand Up @@ -121,13 +121,13 @@ private static DiscoveredAction normalize(Offering.Action action, String service
target.toString(), action.openapi().operationId()));
}

private static List<JsonNode> findOperations(JsonNode document, String operationId) {
JsonNode paths = document.get("paths");
private static List<OdpJsonNode> 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<JsonNode> matches = new ArrayList<>();
for (JsonNode path : paths) {
List<OdpJsonNode> matches = new ArrayList<>();
for (OdpJsonNode path : paths) {
if (!path.isObject()) {
continue;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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";
Expand All @@ -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<String, String> 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);
}

Expand All @@ -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;
Expand All @@ -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)) {
Expand All @@ -92,7 +85,7 @@ private static void requireSchema(JsonNode document) {
});
}

private static List<URI> externalReferences(JsonNode document, URI retrievalUrl) {
private static List<URI> externalReferences(OdpJsonNode document, URI retrievalUrl) {
Set<URI> localResources = new HashSet<>();
collectResourceIdentifiers(document, retrievalUrl, localResources);
Set<URI> references = new HashSet<>();
Expand All @@ -101,17 +94,17 @@ private static List<URI> externalReferences(JsonNode document, URI retrievalUrl)
return references.stream().sorted().toList();
}

private static void collectResourceIdentifiers(JsonNode value, URI base, Set<URI> result) {
private static void collectResourceIdentifiers(OdpJsonNode value, URI base, Set<URI> 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<URI> result) {
private static void collectReferences(OdpJsonNode value, URI base, Set<URI> 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");
Expand All @@ -120,13 +113,13 @@ private static void collectReferences(JsonNode value, URI base, Set<URI> 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;
}
Expand All @@ -138,18 +131,18 @@ private static URI resolveIdentifier(JsonNode value, URI base) {
return resolved;
}

private static JsonNode bundle(URI rootUrl, Map<URI, JsonNode> documents) {
ObjectNode root = documents.get(rootUrl).deepCopy().asObject();
private static OdpJsonNode bundle(URI rootUrl, Map<URI, OdpJsonNode> documents) {
OdpJsonNode root = documents.get(rootUrl).deepCopy();
if (!root.has(IDENTIFIER)) {
root.put(IDENTIFIER, rootUrl.toString());
}
List<URI> externalUrls = new ArrayList<>(documents.keySet());
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) {
Expand All @@ -158,7 +151,7 @@ private static JsonNode bundle(URI rootUrl, Map<URI, JsonNode> 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());
}
Expand All @@ -169,11 +162,11 @@ private static JsonNode bundle(URI rootUrl, Map<URI, JsonNode> 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);
}
}
Expand All @@ -191,21 +184,19 @@ private static void requireHttps(URI value, String message) {
}
}

record ResolvedSchema(JsonNode document, Schema validator) {
boolean validates(Map<String, JsonNode> attributes) {
return validator
.validate(OdpJson.write(attributes), InputFormat.JSON)
.isEmpty();
record ResolvedSchema(OdpJsonNode document, OdpJsonSchema validator) {
boolean validates(Map<String, OdpJsonNode> attributes) {
return validator.validate(OdpJson.write(attributes)).isEmpty();
}
}

private static final class SchemaGraph {
private final Map<URI, JsonNode> documents = new LinkedHashMap<>();
private final Map<URI, OdpJsonNode> documents = new LinkedHashMap<>();
private int bytes;
}

@FunctionalInterface
private interface NodeVisitor {
void visit(JsonNode value);
void visit(OdpJsonNode value);
}
}
Loading