diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 181a641..3ed41b4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,6 +22,12 @@ jobs:
steps:
- uses: actions/checkout@v7
+ - name: Check out ODP specifications
+ uses: actions/checkout@v7
+ with:
+ repository: offering-protocol/odp-specs
+ path: .conformance/odp-specs
+
- uses: actions/setup-java@v6
with:
distribution: temurin
@@ -29,3 +35,46 @@ jobs:
cache: maven
- run: ./mvnw --batch-mode --no-transfer-progress verify
+
+ - name: Run shared conformance harness
+ run: ./scripts/run-conformance.sh
+ env:
+ ODP_SPECS_DIR: .conformance/odp-specs
+
+ - name: Upload conformance reports
+ if: matrix.java == '17'
+ uses: actions/upload-artifact@v7
+ with:
+ name: odp-java-conformance
+ path: .conformance/reports/*.json
+ if-no-files-found: error
+ retention-days: 7
+
+ - name: Check out Node.js reference implementation
+ if: matrix.java == '17'
+ uses: actions/checkout@v7
+ with:
+ repository: offering-protocol/odp-node
+ path: .conformance/odp-node
+
+ - name: Set up pnpm
+ if: matrix.java == '17'
+ uses: pnpm/action-setup@v6
+ with:
+ version: 11.1.3
+
+ - name: Set up Node.js
+ if: matrix.java == '17'
+ uses: actions/setup-node@v6
+ with:
+ node-version: 22
+
+ - name: Install Node.js reference implementation
+ if: matrix.java == '17'
+ run: pnpm --dir .conformance/odp-node install --frozen-lockfile
+
+ - name: Run Node.js interoperability
+ if: matrix.java == '17'
+ run: ./scripts/run-node-interoperability.sh
+ env:
+ ODP_NODE_DIR: .conformance/odp-node
diff --git a/.gitignore b/.gitignore
index 675b579..18c8197 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
.DS_Store
+.conformance/
.idea/
.project
.classpath
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index f290c27..372d5cf 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -22,6 +22,18 @@ Format Java sources with:
./mvnw spotless:apply
```
+Run the shared protocol conformance harness with:
+
+```sh
+ODP_SPECS_DIR=/path/to/odp-specs ./scripts/run-conformance.sh
+```
+
+Run cross-language interoperability against the Node.js reference Service with:
+
+```sh
+ODP_NODE_DIR=/path/to/odp-node ./scripts/run-node-interoperability.sh
+```
+
## Module boundaries
`odp-core` owns transport-independent protocol behavior. `odp-directory`, `odp-agent`, and
diff --git a/README.md b/README.md
index d9a04a1..e232926 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,19 @@ directory behavior.
All artifacts use the Maven group `org.offeringprotocol` and require Java 17 or newer.
+## Examples
+
+Run the small Service and Agent examples in separate terminals:
+
+```sh
+./scripts/run-small-service.sh
+./scripts/run-agent-example.sh
+```
+
+The Agent example explicitly uses a mock directory assembled from reachable Service origins. It
+then performs live Service inspection, Offering listing, and full Offering retrieval. See
+[examples/README.md](./examples/README.md) for the complete walkthrough.
+
## Development
The Maven Wrapper provides the complete merge gate:
@@ -41,6 +54,20 @@ Format Java sources with:
./mvnw spotless:apply
```
+Generate Agent and Service conformance reports with:
+
+```sh
+ODP_SPECS_DIR=/path/to/odp-specs ./scripts/run-conformance.sh
+```
+
+Run the Java Agent against the Node.js reference Service with:
+
+```sh
+ODP_NODE_DIR=/path/to/odp-node ./scripts/run-node-interoperability.sh
+```
+
+The shared harness writes release evidence to `.conformance/reports/`.
+
See [DEVELOPMENT.md](./DEVELOPMENT.md) for repository conventions and
[`odp-specs`](https://github.com/offering-protocol/odp-specs) for the normative draft, schemas,
examples, and test vectors.
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 0000000..dd41926
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,34 @@
+# Runnable examples
+
+The examples demonstrate the two ODP integration roles with the public Java modules.
+
+## Small Service
+
+The small Service keeps a Collection and two Offerings in memory. It publishes a Service Document,
+supports listing and searching Offerings, returns full Offering details, and exposes a free download
+Action.
+
+```sh
+./scripts/run-small-service.sh
+```
+
+The Service listens on `http://127.0.0.1:4103` by default. Set `PORT` to use another port.
+
+## Agent discovery
+
+The Agent example composes a mock directory from reachable local Services, prints every inspected
+Service Document, lists terse Offerings, and retrieves each full Offering. The mock directory is
+example infrastructure; it does not call the canonical ODP directory.
+
+Start the small Service in another terminal, then run:
+
+```sh
+./scripts/run-agent-example.sh
+```
+
+Without arguments, the mock directory checks ports 4101, 4102, and 4103. Pass one or more Service
+origins to use a different set:
+
+```sh
+./scripts/run-agent-example.sh https://service.example
+```
diff --git a/examples/pom.xml b/examples/pom.xml
new file mode 100644
index 0000000..ca4aa80
--- /dev/null
+++ b/examples/pom.xml
@@ -0,0 +1,43 @@
+
+
+ 4.0.0
+
+
+ org.offeringprotocol
+ odp-java
+ 0.1.0-SNAPSHOT
+
+
+ odp-examples
+ ODP Java Examples
+ Runnable Agent and Service examples for ODP Java.
+
+
+ org.offeringprotocol.odp.examples
+ true
+
+
+
+
+ ${project.groupId}
+ odp-agent
+ ${project.version}
+
+
+ ${project.groupId}
+ odp-service
+ ${project.version}
+
+
+
+
+
+
+ org.codehaus.mojo
+ exec-maven-plugin
+
+
+
+
diff --git a/examples/src/main/java/org/offeringprotocol/odp/examples/AgentDiscovery.java b/examples/src/main/java/org/offeringprotocol/odp/examples/AgentDiscovery.java
new file mode 100644
index 0000000..98f6313
--- /dev/null
+++ b/examples/src/main/java/org/offeringprotocol/odp/examples/AgentDiscovery.java
@@ -0,0 +1,37 @@
+package org.offeringprotocol.odp.examples;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.offeringprotocol.odp.core.OdpJson;
+
+/** Runnable two-stage Agent discovery example using a mock directory. */
+public final class AgentDiscovery {
+ private AgentDiscovery() {}
+
+ public static void main(String[] arguments) {
+ List candidates = arguments.length == 0
+ ? List.of("http://127.0.0.1:4101", "http://127.0.0.1:4102", "http://127.0.0.1:4103")
+ : Arrays.asList(arguments);
+ MockDirectory directory = MockDirectory.discover(candidates);
+ System.out.printf( // NOPMD - Console output is the example's user interface.
+ "Mock directory contains %d reachable ODP Service(s).%n", directory.size());
+ List services = new ArrayList<>(directory.search("odp"));
+ if (services.isEmpty()) {
+ services.addAll(directory.search(""));
+ }
+ for (var service : services) {
+ print("ODP Service Document", service.inspection().document());
+ var page = service.listOfferings("terse", null, null);
+ print("Terse Offering list", page);
+ for (var offering : page.items()) {
+ print("Full Offering " + offering.id(), service.getOffering(offering.id(), "full", null));
+ }
+ }
+ }
+
+ private static void print(String label, Object value) {
+ System.out.printf( // NOPMD - Console output is the example's user interface.
+ "%n%s:%n%s%n", label, OdpJson.write(value));
+ }
+}
diff --git a/examples/src/main/java/org/offeringprotocol/odp/examples/MockDirectory.java b/examples/src/main/java/org/offeringprotocol/odp/examples/MockDirectory.java
new file mode 100644
index 0000000..f512986
--- /dev/null
+++ b/examples/src/main/java/org/offeringprotocol/odp/examples/MockDirectory.java
@@ -0,0 +1,51 @@
+package org.offeringprotocol.odp.examples;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.offeringprotocol.odp.agent.OdpServiceClient;
+
+/** Reachable Service index used only by the Agent example. */
+final class MockDirectory {
+ private final Map services;
+
+ private MockDirectory(Map services) {
+ this.services = Map.copyOf(services);
+ }
+
+ static MockDirectory discover(List candidates) {
+ Map services = new LinkedHashMap<>();
+ for (String candidate : candidates) {
+ try {
+ OdpServiceClient client = OdpServiceClient.create(URI.create(candidate));
+ services.put(client.inspection().serviceOrigin(), client);
+ } catch (RuntimeException ignored) {
+ // Unreachable candidates do not appear in the mock directory.
+ }
+ }
+ if (services.isEmpty()) {
+ throw new IllegalStateException("No configured ODP Services are reachable");
+ }
+ return new MockDirectory(services);
+ }
+
+ List search(String query) {
+ String normalized = query.toLowerCase(java.util.Locale.ROOT);
+ List matches = new ArrayList<>();
+ for (OdpServiceClient client : services.values()) {
+ var document = client.inspection().document();
+ String metadata = document.name() + " " + document.description() + " "
+ + String.join(" ", document.keywords() == null ? List.of() : document.keywords());
+ if (metadata.toLowerCase(java.util.Locale.ROOT).contains(normalized)) {
+ matches.add(client);
+ }
+ }
+ return List.copyOf(matches);
+ }
+
+ int size() {
+ return services.size();
+ }
+}
diff --git a/examples/src/main/java/org/offeringprotocol/odp/examples/SmallService.java b/examples/src/main/java/org/offeringprotocol/odp/examples/SmallService.java
new file mode 100644
index 0000000..487286d
--- /dev/null
+++ b/examples/src/main/java/org/offeringprotocol/odp/examples/SmallService.java
@@ -0,0 +1,187 @@
+package org.offeringprotocol.odp.examples;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.Executors;
+import org.offeringprotocol.odp.core.AuthenticationRequirement;
+import org.offeringprotocol.odp.core.Collection;
+import org.offeringprotocol.odp.core.Odp;
+import org.offeringprotocol.odp.core.OdpJson;
+import org.offeringprotocol.odp.core.OdpOperation;
+import org.offeringprotocol.odp.core.Offering;
+import org.offeringprotocol.odp.core.Page;
+import org.offeringprotocol.odp.core.ServiceDocument;
+import org.offeringprotocol.odp.service.OdpHttpRequest;
+import org.offeringprotocol.odp.service.OdpHttpResponse;
+import org.offeringprotocol.odp.service.OdpService;
+import org.offeringprotocol.odp.service.StaticCatalog;
+
+/** Runnable small-catalog ODP Service. */
+public final class SmallService {
+ private SmallService() {}
+
+ public static void main(String[] arguments) throws IOException {
+ int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "4103"));
+ List offerings = offerings();
+ Map endpoints =
+ new EnumMap<>(StaticCatalog.create(offerings, List.of(collection())));
+ endpoints.put(
+ OdpOperation.SEARCH_OFFERINGS,
+ new OdpService.Endpoint(AuthenticationRequirement.NOT_REQUIRED, request -> {
+ String query =
+ OdpJson.parseOfferingSearchRequest(request.body()).query();
+ String normalized = query == null ? "" : query.toLowerCase(Locale.ROOT);
+ List matches = offerings.stream()
+ .filter(offering -> (offering.name() + " " + offering.description())
+ .toLowerCase(Locale.ROOT)
+ .contains(normalized))
+ .toList();
+ return new Page<>(null, Odp.VERSION, matches, null, Map.of());
+ }));
+ OdpService service = new OdpService(document(), endpoints);
+ HttpServer server = HttpServer.create(
+ new InetSocketAddress("127.0.0.1", port), // NOPMD - The example must remain local-only.
+ 0);
+ server.createContext("/downloads/agent-guide.txt", exchange -> {
+ byte[] body = "Build Agents against advertised ODP operations.\n".getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
+ exchange.sendResponseHeaders(200, body.length);
+ exchange.getResponseBody().write(body);
+ exchange.close();
+ });
+ server.createContext("/", exchange -> handle(service, exchange));
+ server.setExecutor(Executors.newCachedThreadPool());
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> server.stop(0)));
+ server.start();
+ System.out.printf( // NOPMD - Console output is the example's user interface.
+ "Small ODP Service listening at http://127.0.0.1:%d%n", port);
+ System.out.printf( // NOPMD - Console output is the example's user interface.
+ "Service Document: http://127.0.0.1:%d/.well-known/odp%n", port);
+ System.out.printf( // NOPMD - Console output is the example's user interface.
+ "Offerings: http://127.0.0.1:%d/odp/offerings%n", port);
+ }
+
+ private static void handle(OdpService service, HttpExchange exchange) throws IOException {
+ OdpHttpRequest request = new OdpHttpRequest(
+ exchange.getRequestMethod(),
+ exchange.getRequestURI().getPath(),
+ query(exchange.getRequestURI().getRawQuery()),
+ exchange.getRequestHeaders(),
+ new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
+ OdpHttpResponse response = service.handle(request);
+ response.headers().forEach(exchange.getResponseHeaders()::set);
+ byte[] body = response.body().getBytes(StandardCharsets.UTF_8);
+ exchange.sendResponseHeaders(response.status(), body.length);
+ exchange.getResponseBody().write(body);
+ exchange.close();
+ System.out.printf( // NOPMD - Request logging makes the example observable.
+ "%s %s -> %d%n", request.method(), exchange.getRequestURI(), response.status());
+ }
+
+ private static Map> query(String rawQuery) {
+ if (rawQuery == null || rawQuery.isBlank()) {
+ return Map.of();
+ }
+ Map> result = new java.util.LinkedHashMap<>();
+ for (String pair : rawQuery.split("&")) {
+ String[] parts = pair.split("=", 2);
+ String name = URLDecoder.decode(parts[0], StandardCharsets.UTF_8);
+ String value = parts.length == 1 ? "" : URLDecoder.decode(parts[1], StandardCharsets.UTF_8);
+ result.computeIfAbsent(name, ignored -> new ArrayList<>()).add(value);
+ }
+ return result;
+ }
+
+ private static ServiceDocument document() {
+ return new ServiceDocument(
+ Odp.VERSION,
+ "ODP Developer Resources",
+ "Free resources for ODP integrators",
+ null,
+ "en",
+ List.of("en"),
+ null,
+ List.of("agent", "developer", "documentation"),
+ null,
+ null,
+ new ServiceDocument.Http("/odp", null),
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ Map.of());
+ }
+
+ private static Collection collection() {
+ return new Collection(
+ null,
+ Odp.VERSION,
+ "resources",
+ "Resources",
+ "Guides and reference materials",
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ Map.of());
+ }
+
+ private static List offerings() {
+ Offering.Action action = new Offering.Action(
+ AuthenticationRequirement.NOT_REQUIRED,
+ "download",
+ "download",
+ "Download the guide",
+ new Offering.HttpTarget("/downloads/agent-guide.txt", "GET", null, List.of("text/plain")),
+ null);
+ Offering guide = new Offering(
+ null,
+ Odp.VERSION,
+ "agent-guide",
+ "ODP Agent Guide",
+ "A short guide for building an ODP Agent",
+ null,
+ null,
+ null,
+ null,
+ List.of("resources"),
+ new Offering.PricePreview("free", null, null, null, null, null, Map.of()),
+ null,
+ null,
+ List.of(action),
+ null,
+ Map.of());
+ Offering review = new Offering(
+ null,
+ Odp.VERSION,
+ "architecture-review",
+ "Architecture Review",
+ "A one-time architecture review",
+ null,
+ null,
+ null,
+ null,
+ null,
+ new Offering.PricePreview("starting_at", "500", "USD", null, null, null, Map.of()),
+ null,
+ null,
+ null,
+ null,
+ Map.of());
+ return List.of(guide, review);
+ }
+}
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 af43bc9..59382fa 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
@@ -24,4 +24,8 @@ static Map nodes(Map value) {
.collect(Collectors.toUnmodifiableMap(
Map.Entry::getKey, entry -> entry.getValue().deepCopy()));
}
+
+ 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 da0bef6..781fbe7 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
@@ -10,8 +10,11 @@
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
+import java.util.Set;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.JavaType;
import tools.jackson.databind.json.JsonMapper;
@@ -28,15 +31,27 @@ public final class OdpJson {
private OdpJson() {}
public static ServiceDocument parseServiceDocument(String json) {
- return parse(json, "service-document.schema.json", "Service Document", ServiceDocument.class);
+ 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");
+ }
+ return document;
}
public static Collection parseCollection(String json) {
- return parse(json, "collection.schema.json", "Collection", Collection.class);
+ Collection collection = parse(json, "collection.schema.json", "Collection", Collection.class);
+ validateLocalizations(collection.language(), collection.localizations(), "Collection");
+ validateImages(collection.images(), "Collection");
+ return collection;
}
public static Offering parseOffering(String json) {
- return parse(json, "offering.schema.json", "Offering", Offering.class);
+ Offering offering = parse(json, "offering.schema.json", "Offering", Offering.class);
+ validateLocalizations(offering.language(), offering.localizations(), "Offering");
+ validateImages(offering.images(), "Offering");
+ return offering;
}
public static ProblemDetails parseProblemDetails(String json) {
@@ -90,6 +105,102 @@ private static T decode(String json, JavaType type, String documentType) {
}
}
+ private static void validateLocalizations(String language, List localizations, String documentType) {
+ Set normalized = new HashSet<>();
+ if (localizations != null) {
+ for (int index = 0; index < localizations.size(); index++) {
+ String localization =
+ normalizeLanguageTag(localizations.get(index), documentType, "/localizations/" + index);
+ if (!normalized.add(localization)) {
+ throw semanticError(
+ documentType,
+ "localizations must be unique without regard to case",
+ "/localizations/" + index);
+ }
+ }
+ }
+ if (language != null) {
+ String normalizedLanguage = normalizeLanguageTag(language, documentType, "/language");
+ if (localizations != null && !normalized.contains(normalizedLanguage)) {
+ throw semanticError(documentType, "language must appear in localizations", "/language");
+ }
+ }
+ }
+
+ private static String normalizeLanguageTag(String value, String documentType, String path) {
+ try {
+ String normalized = new Locale.Builder()
+ .setLanguageTag(value)
+ .build()
+ .toLanguageTag()
+ .toLowerCase(Locale.ROOT);
+ validateUniqueVariants(value, documentType, path);
+ return normalized;
+ } catch (java.util.IllformedLocaleException exception) {
+ throw semanticError(documentType, "language tag is invalid", path, exception);
+ }
+ }
+
+ private static void validateUniqueVariants(String value, String documentType, String path) {
+ String[] subtags = value.split("-");
+ int index = 1;
+ int extlangs = 0;
+ while (index < subtags.length && extlangs < 3 && isLetters(subtags[index], 3)) {
+ index++;
+ extlangs++;
+ }
+ if (index < subtags.length && isLetters(subtags[index], 4)) {
+ index++;
+ }
+ if (index < subtags.length && (isLetters(subtags[index], 2) || isDigits(subtags[index], 3))) {
+ index++;
+ }
+ Set variants = new HashSet<>();
+ while (index < subtags.length && isVariant(subtags[index])) {
+ if (!variants.add(subtags[index].toLowerCase(Locale.ROOT))) {
+ throw semanticError(documentType, "language tag contains a duplicate variant", path);
+ }
+ index++;
+ }
+ }
+
+ private static boolean isVariant(String value) {
+ return (value.length() >= 5 && value.length() <= 8 && value.chars().allMatch(Character::isLetterOrDigit))
+ || (value.length() == 4
+ && Character.isDigit(value.charAt(0))
+ && value.chars().allMatch(Character::isLetterOrDigit));
+ }
+
+ private static boolean isLetters(String value, int length) {
+ return value.length() == length && value.chars().allMatch(Character::isLetter);
+ }
+
+ private static boolean isDigits(String value, int length) {
+ return value.length() == length && value.chars().allMatch(Character::isDigit);
+ }
+
+ private static void validateImages(List images, String documentType) {
+ if (images == null) {
+ return;
+ }
+ Set sources = new HashSet<>();
+ for (int index = 0; index < images.size(); index++) {
+ if (!sources.add(images.get(index).src())) {
+ throw semanticError(documentType, "image sources must be unique", "/images/" + index + "/src");
+ }
+ }
+ }
+
+ private static OdpValidationException semanticError(String documentType, String message, String path) {
+ return semanticError(documentType, message, path, null);
+ }
+
+ private static OdpValidationException semanticError(
+ String documentType, String message, String path, Throwable cause) {
+ return new OdpValidationException(
+ documentType, List.of(new ValidationIssue("semantic", message, Map.of(), path)), cause);
+ }
+
private static void validate(String json, String schemaName, String documentType) {
Schema schema = SCHEMAS.getSchema(SchemaLocation.of(SCHEMA_ORIGIN + schemaName));
List errors = schema.validate(
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 482ee85..cd4ff5c 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
@@ -29,7 +29,7 @@ public record Offering(
images = Copies.list(images);
localizations = Copies.list(localizations);
collectionIds = Copies.list(collectionIds);
- attributes = Copies.nodes(attributes);
+ attributes = Copies.nullableNodes(attributes);
actions = Copies.list(actions);
detailFields = Copies.list(detailFields);
additional = Copies.nodes(additional);
diff --git a/odp-core/src/test/java/org/offeringprotocol/odp/core/OdpJsonTest.java b/odp-core/src/test/java/org/offeringprotocol/odp/core/OdpJsonTest.java
index 56112a4..eba6f3a 100644
--- a/odp-core/src/test/java/org/offeringprotocol/odp/core/OdpJsonTest.java
+++ b/odp-core/src/test/java/org/offeringprotocol/odp/core/OdpJsonTest.java
@@ -1,6 +1,7 @@
package org.offeringprotocol.odp.core;
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 static org.junit.jupiter.api.Assertions.assertTrue;
@@ -42,6 +43,50 @@ void rejectsInvalidServiceDocuments() {
assertTrue(!exception.issues().isEmpty());
}
+ @Test
+ void enforcesServiceDocumentSemanticConstraints() {
+ String duplicateLocalization =
+ DOCUMENT.replace("\"localizations\":[\"en\"]", "\"localizations\":[\"en\",\"EN\"]");
+ String missingLanguage = DOCUMENT.replace("\"localizations\":[\"en\"]", "\"localizations\":[\"ja\"]");
+ String invalidLanguage = DOCUMENT.replace("\"language\":\"en\"", "\"language\":\"en-a\"")
+ .replace("\"localizations\":[\"en\"]", "\"localizations\":[\"en-a\"]");
+ String duplicateVariant = DOCUMENT.replace("\"language\":\"en\"", "\"language\":\"sl-rozaj-rozaj\"")
+ .replace("\"localizations\":[\"en\"]", "\"localizations\":[\"sl-rozaj-rozaj\"]");
+ String prohibitedWebUrl = DOCUMENT.replace(
+ "\"example_extension\":{\"enabled\":true}",
+ "\"web_url\":\"/store/\",\"example_extension\":{\"enabled\":true}");
+
+ assertThrows(OdpValidationException.class, () -> OdpJson.parseServiceDocument(duplicateLocalization));
+ assertThrows(OdpValidationException.class, () -> OdpJson.parseServiceDocument(missingLanguage));
+ assertThrows(OdpValidationException.class, () -> OdpJson.parseServiceDocument(invalidLanguage));
+ assertThrows(OdpValidationException.class, () -> OdpJson.parseServiceDocument(duplicateVariant));
+ assertThrows(OdpValidationException.class, () -> OdpJson.parseServiceDocument(prohibitedWebUrl));
+ }
+
+ @Test
+ void rejectsDuplicateOfferingImageSources() {
+ String offering = """
+ {
+ "odp_version":"1.0",
+ "id":"desk",
+ "name":"Standing desk",
+ "images":[{"src":"/desk.webp"},{"src":"/desk.webp"}]
+ }
+ """;
+
+ assertThrows(OdpValidationException.class, () -> OdpJson.parseOffering(offering));
+ }
+
+ @Test
+ void preservesAbsentOfferingAttributes() {
+ Offering offering = OdpJson.parseOffering("""
+ {"odp_version":"1.0","id":"desk","name":"Standing desk"}
+ """);
+
+ assertNull(offering.attributes());
+ assertTrue(!OdpJson.write(offering).contains("attributes"));
+ }
+
@Test
void resolvesOperationUrisAndCanonicalIdentity() {
URI operation =
diff --git a/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpService.java b/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpService.java
index b64d595..e76fe86 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
@@ -14,6 +14,7 @@
/** Framework-neutral ODP Service request handler. */
public final class OdpService {
+ private static final int MAXIMUM_REQUEST_BYTES = 65_536;
private static final String MEDIA_TYPE = "application/odp+json";
private static final String GET = "GET";
private static final String NOT_FOUND = "NOT_FOUND";
@@ -61,6 +62,11 @@ public ServiceDocument document() {
public OdpHttpResponse handle(OdpHttpRequest request) {
try {
+ if (request.body() != null
+ && request.body().getBytes(java.nio.charset.StandardCharsets.UTF_8).length
+ > MAXIMUM_REQUEST_BYTES) {
+ return problem(413, "REQUEST_TOO_LARGE", "ODP request exceeds its byte limit");
+ }
if (GET.equals(request.method()) && Odp.SERVICE_DOCUMENT_PATH.equals(request.path())) {
return json(200, serviceDocument);
}
diff --git a/odp-service/src/test/java/org/offeringprotocol/odp/service/OdpServiceTest.java b/odp-service/src/test/java/org/offeringprotocol/odp/service/OdpServiceTest.java
index 1fd8560..b315a70 100644
--- a/odp-service/src/test/java/org/offeringprotocol/odp/service/OdpServiceTest.java
+++ b/odp-service/src/test/java/org/offeringprotocol/odp/service/OdpServiceTest.java
@@ -43,6 +43,37 @@ void servesTheMinimumStaticCatalog() {
assertTrue(detail.body().contains("plant-1"));
}
+ @Test
+ void boundsRequestBodies() {
+ Offering offering = new Offering(
+ null,
+ Odp.VERSION,
+ "plant-1",
+ "Rubber Plant",
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ Map.of());
+ OdpService service = new OdpService(template(), StaticCatalog.create(List.of(offering), List.of()));
+
+ OdpHttpResponse boundary =
+ service.handle(new OdpHttpRequest("GET", "/.well-known/odp", Map.of(), Map.of(), "a".repeat(65_536)));
+ OdpHttpResponse exceeded =
+ service.handle(new OdpHttpRequest("GET", "/.well-known/odp", Map.of(), Map.of(), "a".repeat(65_537)));
+
+ assertEquals(200, boundary.status());
+ assertEquals(413, exceeded.status());
+ assertTrue(exceeded.body().contains("REQUEST_TOO_LARGE"));
+ }
+
private static ServiceDocument template() {
return new ServiceDocument(
Odp.VERSION,
diff --git a/pom.xml b/pom.xml
index 0d1b459..0e84989 100644
--- a/pom.xml
+++ b/pom.xml
@@ -42,6 +42,8 @@
odp-directory
odp-agent
odp-service
+ examples
+ tools/odp-conformance
@@ -53,6 +55,7 @@
3.2.1
3.0.7
0.8.15
+ 3.6.3
2.97.0
3.6.0
@@ -95,6 +98,11 @@
+
+ org.codehaus.mojo
+ exec-maven-plugin
+ ${exec-maven-plugin.version}
+
org.apache.maven.plugins
maven-compiler-plugin
diff --git a/scripts/run-agent-example.sh b/scripts/run-agent-example.sh
new file mode 100755
index 0000000..bd477fe
--- /dev/null
+++ b/scripts/run-agent-example.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+set -eu
+
+./mvnw --quiet --batch-mode --no-transfer-progress -DskipTests install
+exec ./mvnw --quiet --batch-mode --no-transfer-progress \
+ -f examples/pom.xml \
+ -Dexec.mainClass=org.offeringprotocol.odp.examples.AgentDiscovery \
+ -Dexec.args="$*" \
+ -Dexec.classpathScope=runtime \
+ org.codehaus.mojo:exec-maven-plugin:3.6.3:java
diff --git a/scripts/run-conformance-adapter.sh b/scripts/run-conformance-adapter.sh
new file mode 100755
index 0000000..0270ccb
--- /dev/null
+++ b/scripts/run-conformance-adapter.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+set -eu
+
+exec ./mvnw --quiet --batch-mode --no-transfer-progress \
+ -f tools/odp-conformance/pom.xml \
+ -Dexec.mainClass=org.offeringprotocol.odp.conformance.ConformanceAdapter \
+ -Dexec.classpathScope=runtime \
+ org.codehaus.mojo:exec-maven-plugin:3.6.3:java
diff --git a/scripts/run-conformance.sh b/scripts/run-conformance.sh
new file mode 100755
index 0000000..c7a480d
--- /dev/null
+++ b/scripts/run-conformance.sh
@@ -0,0 +1,19 @@
+#!/bin/sh
+set -eu
+
+specs_dir=${ODP_SPECS_DIR:-../odp-specs}
+output_dir=${ODP_CONFORMANCE_OUTPUT:-.conformance/reports}
+implementation_version=${ODP_JAVA_VERSION:-0.1.0}
+implementation_version=${implementation_version#v}
+
+./mvnw --quiet --batch-mode --no-transfer-progress -DskipTests install
+mkdir -p "$output_dir"
+
+for role in agent service; do
+ ruby "$specs_dir/ietf/scripts/run_conformance.rb" \
+ --role "$role" \
+ --implementation-name odp-java \
+ --implementation-version "$implementation_version" \
+ --output "$output_dir/$role.json" \
+ -- ./scripts/run-conformance-adapter.sh
+done
diff --git a/scripts/run-node-interoperability.sh b/scripts/run-node-interoperability.sh
new file mode 100755
index 0000000..9019b72
--- /dev/null
+++ b/scripts/run-node-interoperability.sh
@@ -0,0 +1,33 @@
+#!/bin/sh
+set -eu
+
+node_dir=${ODP_NODE_DIR:-../odp-node}
+port=${ODP_INTEROP_PORT:-4101}
+service_url="http://127.0.0.1:$port"
+log_file=${TMPDIR:-/tmp}/odp-node-java-interop.log
+package_manager=$(node -e \
+ "const fs = require('fs'); console.log(JSON.parse(fs.readFileSync(process.argv[1])).packageManager)" \
+ "$node_dir/package.json")
+
+./mvnw --quiet --batch-mode --no-transfer-progress -DskipTests install
+corepack "$package_manager" --dir "$node_dir" build
+HOST=127.0.0.1 PORT="$port" node "$node_dir/examples/odp-service-small/dist/index.js" >"$log_file" 2>&1 &
+service_pid=$!
+trap 'kill "$service_pid" 2>/dev/null || true' EXIT INT TERM
+
+attempt=0
+until curl --fail --silent --output /dev/null "$service_url/.well-known/odp"; do
+ attempt=$((attempt + 1))
+ if [ "$attempt" -ge 50 ]; then
+ sed -n '1,120p' "$log_file" >&2
+ exit 1
+ fi
+ sleep 0.1
+done
+
+./mvnw --quiet --batch-mode --no-transfer-progress \
+ -f tools/odp-conformance/pom.xml \
+ -Dexec.mainClass=org.offeringprotocol.odp.conformance.NodeInterop \
+ -Dexec.args="$service_url" \
+ -Dexec.classpathScope=runtime \
+ org.codehaus.mojo:exec-maven-plugin:3.6.3:java
diff --git a/scripts/run-small-service.sh b/scripts/run-small-service.sh
new file mode 100755
index 0000000..e5d9ff9
--- /dev/null
+++ b/scripts/run-small-service.sh
@@ -0,0 +1,9 @@
+#!/bin/sh
+set -eu
+
+./mvnw --quiet --batch-mode --no-transfer-progress -DskipTests install
+exec ./mvnw --quiet --batch-mode --no-transfer-progress \
+ -f examples/pom.xml \
+ -Dexec.mainClass=org.offeringprotocol.odp.examples.SmallService \
+ -Dexec.classpathScope=runtime \
+ org.codehaus.mojo:exec-maven-plugin:3.6.3:java
diff --git a/tools/odp-conformance/pom.xml b/tools/odp-conformance/pom.xml
new file mode 100644
index 0000000..fd81f34
--- /dev/null
+++ b/tools/odp-conformance/pom.xml
@@ -0,0 +1,44 @@
+
+
+ 4.0.0
+
+
+ org.offeringprotocol
+ odp-java
+ 0.1.0-SNAPSHOT
+ ../../pom.xml
+
+
+ odp-conformance
+ ODP Java Conformance Tools
+ Shared conformance adapter and Node.js interoperability probe.
+
+
+ org.offeringprotocol.odp.conformance
+ true
+
+
+
+
+ ${project.groupId}
+ odp-agent
+ ${project.version}
+
+
+ ${project.groupId}
+ odp-service
+ ${project.version}
+
+
+
+
+
+
+ org.codehaus.mojo
+ exec-maven-plugin
+
+
+
+
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
new file mode 100644
index 0000000..8233367
--- /dev/null
+++ b/tools/odp-conformance/src/main/java/org/offeringprotocol/odp/conformance/ConformanceAdapter.java
@@ -0,0 +1,320 @@
+package org.offeringprotocol.odp.conformance;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.EnumMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.offeringprotocol.odp.core.AuthenticationRequirement;
+import org.offeringprotocol.odp.core.Odp;
+import org.offeringprotocol.odp.core.OdpJson;
+import org.offeringprotocol.odp.core.OdpOperation;
+import org.offeringprotocol.odp.core.OdpUris;
+import org.offeringprotocol.odp.core.Offering;
+import org.offeringprotocol.odp.core.OperationDescriptor;
+import org.offeringprotocol.odp.core.Page;
+import org.offeringprotocol.odp.core.ResourceIdentity;
+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;
+
+/** Process adapter for the language-neutral ODP conformance harness. */
+public final class ConformanceAdapter {
+ private static final int MAXIMUM_MESSAGE_LENGTH = 1024;
+ private static final String AGENT_ROLE = "agent";
+ private static final String VALIDATE_PROBLEM = "validate-problem";
+ private static final JsonMapper JSON = JsonMapper.builder().build();
+ private static final Set AGENT_BASELINE = Set.of(
+ "enforce-compatibility",
+ "enforce-redirect-and-security",
+ "follow-pagination",
+ "get-offering",
+ "handle-errors-and-limits",
+ "honor-caching",
+ "inspect-service",
+ "list-offerings",
+ "process-localization",
+ "process-representations");
+
+ private ConformanceAdapter() {}
+
+ public static void main(String[] arguments) throws IOException {
+ try (var reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8))) {
+ String line = reader.readLine();
+ while (line != null) {
+ if (!line.isBlank()) {
+ System.out.println( // NOPMD - Standard output is the adapter protocol channel.
+ JSON.writeValueAsString(evaluate(JSON.readTree(line))));
+ }
+ line = reader.readLine();
+ }
+ }
+ }
+
+ private static Map evaluate(JsonNode request) {
+ int sequence = required(request, "sequence").asInt();
+ Map response = new LinkedHashMap<>();
+ response.put("protocol_version", "1");
+ response.put("sequence", sequence);
+ try {
+ Evaluation evaluation = evaluateCase(
+ required(required(request, "vector"), "subject").asText(),
+ required(request, "case"),
+ required(request, "role").asText());
+ response.put("status", evaluation.status());
+ if (evaluation.message() != null) {
+ response.put("message", evaluation.message());
+ }
+ } catch (RuntimeException exception) {
+ response.put("status", "failed");
+ response.put("message", truncate(exception.getMessage()));
+ }
+ return response;
+ }
+
+ private static Evaluation evaluateCase(String subject, JsonNode test, String role) {
+ return switch (subject) {
+ case "local-identifier" -> result(OdpUris.isLocalResourceIdentifier(text(test, "value")) == valid(test));
+ case "identity-comparison" -> evaluateIdentity(test);
+ case "service-origin" -> evaluateServiceOrigin(test);
+ case "resource-reference" -> evaluateReference(test);
+ case "service-document" -> parse(test, "document", OdpJson::parseServiceDocument);
+ case "collection-envelope" -> parse(test, "document", OdpJson::parseCollection);
+ case "offering-contract" ->
+ "full".equals(optionalText(test, "representation"))
+ ? parse(test, "document", OdpJson::parseOffering)
+ : skipped();
+ case "collection-search-contract" ->
+ "validate-request".equals(operation(test))
+ ? parse(test, "request", OdpJson::parseCollectionSearchRequest)
+ : skipped();
+ case "offering-search-contract" ->
+ "validate-request".equals(operation(test))
+ ? parse(test, "request", OdpJson::parseOfferingSearchRequest)
+ : skipped();
+ case "pagination-contract" -> evaluatePagination(test);
+ case "errors-limits-contract" -> evaluateErrorsAndLimits(test);
+ case "role-baseline" -> evaluateBaseline(test, role);
+ default -> skipped();
+ };
+ }
+
+ private static Evaluation evaluateIdentity(JsonNode test) {
+ ResourceIdentity left = decode(required(test, "left"), ResourceIdentity.class);
+ ResourceIdentity right = decode(required(test, "right"), ResourceIdentity.class);
+ return result(left.equals(right) == required(test, "same_identity").asBoolean());
+ }
+
+ private static Evaluation evaluateServiceOrigin(JsonNode test) {
+ boolean actual;
+ String value = text(test, "value");
+ try {
+ actual = OdpUris.deriveServiceOrigin(URI.create(value)).equals(value);
+ } catch (IllegalArgumentException exception) {
+ actual = false;
+ }
+ return result(actual == valid(test));
+ }
+
+ private static Evaluation evaluateReference(JsonNode test) {
+ boolean actual;
+ try {
+ OdpUris.resolveResourceReference(text(test, "value"), "https://service.example");
+ actual = true;
+ } catch (IllegalArgumentException exception) {
+ actual = false;
+ }
+ return result(actual == valid(test));
+ }
+
+ private static Evaluation evaluatePagination(JsonNode test) {
+ return switch (operation(test)) {
+ case "validate-page" -> parse(test, "page", value -> OdpJson.parsePage(value, JsonNode.class));
+ case "validate-limit" -> {
+ int limit = required(test, "limit").asInt();
+ yield result((limit >= 1 && limit <= 100) == valid(test));
+ }
+ case "validate-next" -> {
+ boolean actual;
+ try {
+ OdpUris.resolveContinuation(text(test, "next"), text(test, "service_origin"));
+ actual = true;
+ } catch (IllegalArgumentException exception) {
+ actual = false;
+ }
+ yield result(actual == valid(test));
+ }
+ default -> skipped();
+ };
+ }
+
+ private static Evaluation evaluateErrorsAndLimits(JsonNode test) {
+ if (VALIDATE_PROBLEM.equals(operation(test))) {
+ boolean actual;
+ try {
+ var problem =
+ OdpJson.parseProblemDetails(required(test, "problem").toString());
+ actual = problem.status() == required(test, "http_status").asInt();
+ } catch (IllegalArgumentException exception) {
+ actual = false;
+ }
+ return result(actual == valid(test));
+ }
+ if (!"validate-limit".equals(operation(test)) || !"request".equals(optionalText(test, "resource"))) {
+ return skipped();
+ }
+ return result((serviceRequestStatus(required(test, "bytes").asInt()) == 200) == valid(test));
+ }
+
+ private static int serviceRequestStatus(int byteCount) {
+ Map endpoints = new EnumMap<>(OdpOperation.class);
+ endpoints.put(
+ OdpOperation.LIST_OFFERINGS,
+ new OdpService.Endpoint(
+ AuthenticationRequirement.NOT_REQUIRED,
+ request -> new Page(null, Odp.VERSION, List.of(), null, Map.of())));
+ endpoints.put(
+ OdpOperation.GET_OFFERING,
+ new OdpService.Endpoint(AuthenticationRequirement.NOT_REQUIRED, request -> null));
+ endpoints.put(
+ OdpOperation.SEARCH_OFFERINGS,
+ new OdpService.Endpoint(
+ AuthenticationRequirement.NOT_REQUIRED,
+ request -> new Page(null, Odp.VERSION, List.of(), null, Map.of())));
+ OdpService service = new OdpService(document(List.of()), endpoints);
+ String prefix = "{\"odp_version\":\"1.0\",\"query\":\"gpu\"}";
+ String body = prefix + " ".repeat(Math.max(0, byteCount - prefix.length()));
+ return service.handle(new OdpHttpRequest(
+ "POST",
+ "/odp/offerings/search",
+ Map.of(),
+ Map.of("Content-Type", List.of("application/odp+json")),
+ body))
+ .status();
+ }
+
+ private static Evaluation evaluateBaseline(JsonNode test, String role) {
+ if (!text(test, "role").equals(role)) {
+ return skipped();
+ }
+ if (AGENT_ROLE.equals(role)) {
+ Set behaviors = new java.util.HashSet<>();
+ required(test, "behaviors").forEach(value -> behaviors.add(value.asText()));
+ return result(behaviors.containsAll(AGENT_BASELINE) == valid(test));
+ }
+ List operations = new ArrayList<>();
+ required(test, "operations")
+ .forEach(value -> operations.add(new OperationDescriptor(
+ AuthenticationRequirement.NOT_REQUIRED, OdpOperation.fromValue(value.asText()))));
+ boolean actual;
+ try {
+ OdpJson.parseServiceDocument(OdpJson.write(document(operations)));
+ OdpJson.parsePage(required(test, "list_response").toString(), Offering.class);
+ OdpJson.parseOffering(required(test, "get_response").toString());
+ actual = operations.stream()
+ .map(OperationDescriptor::name)
+ .collect(java.util.stream.Collectors.toSet())
+ .containsAll(Set.of(OdpOperation.LIST_OFFERINGS, OdpOperation.GET_OFFERING));
+ } catch (IllegalArgumentException exception) {
+ actual = false;
+ }
+ return result(actual == valid(test));
+ }
+
+ private static ServiceDocument document(List operations) {
+ return new ServiceDocument(
+ Odp.VERSION,
+ "Conformance Service",
+ "ODP conformance Service",
+ null,
+ "en",
+ List.of("en"),
+ null,
+ null,
+ null,
+ operations,
+ new ServiceDocument.Http("/odp", null),
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ Map.of());
+ }
+
+ private static Evaluation parse(JsonNode test, String field, Parser parser) {
+ boolean actual;
+ try {
+ parser.parse(required(test, field).toString());
+ actual = true;
+ } catch (IllegalArgumentException exception) {
+ actual = false;
+ }
+ return result(actual == valid(test));
+ }
+
+ private static T decode(JsonNode value, Class type) {
+ try {
+ return JSON.readValue(value.toString(), type);
+ } catch (JacksonException exception) {
+ throw new IllegalArgumentException("Conformance case has an invalid field", exception);
+ }
+ }
+
+ private static JsonNode required(JsonNode object, String name) {
+ JsonNode value = object.get(name);
+ if (value == null) {
+ throw new IllegalArgumentException("Conformance case omitted " + name);
+ }
+ return value;
+ }
+
+ private static String text(JsonNode object, String name) {
+ return required(object, name).asText();
+ }
+
+ private static String optionalText(JsonNode object, String name) {
+ JsonNode value = object.get(name);
+ return value == null ? null : value.asText();
+ }
+
+ private static String operation(JsonNode test) {
+ return optionalText(test, "operation");
+ }
+
+ private static boolean valid(JsonNode test) {
+ return required(test, "valid").asBoolean();
+ }
+
+ private static Evaluation result(boolean matches) {
+ return matches
+ ? new Evaluation("passed", null)
+ : new Evaluation("failed", "Public Java API result did not match the vector");
+ }
+
+ private static Evaluation skipped() {
+ return new Evaluation("skipped", "No public Java operation maps this vector case");
+ }
+
+ private static String truncate(String value) {
+ String message = value == null ? "Conformance evaluation failed" : value;
+ return message.length() <= MAXIMUM_MESSAGE_LENGTH ? message : message.substring(0, MAXIMUM_MESSAGE_LENGTH);
+ }
+
+ @FunctionalInterface
+ private interface Parser {
+ Object parse(String value);
+ }
+
+ private record Evaluation(String status, String message) {}
+}
diff --git a/tools/odp-conformance/src/main/java/org/offeringprotocol/odp/conformance/NodeInterop.java b/tools/odp-conformance/src/main/java/org/offeringprotocol/odp/conformance/NodeInterop.java
new file mode 100644
index 0000000..f61147b
--- /dev/null
+++ b/tools/odp-conformance/src/main/java/org/offeringprotocol/odp/conformance/NodeInterop.java
@@ -0,0 +1,45 @@
+package org.offeringprotocol.odp.conformance;
+
+import java.net.URI;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.offeringprotocol.odp.agent.OdpServiceClient;
+import org.offeringprotocol.odp.core.OdpOperation;
+
+/** Verifies the Java Agent against the Node.js reference Service. */
+public final class NodeInterop {
+ private static final int REQUIRED_ARGUMENTS = 1;
+
+ private NodeInterop() {}
+
+ public static void main(String[] arguments) {
+ if (arguments.length != REQUIRED_ARGUMENTS) {
+ throw new IllegalArgumentException("Usage: NodeInterop SERVICE_URL");
+ }
+ String serviceUrl = arguments[0];
+ OdpServiceClient client = OdpServiceClient.create(URI.create(serviceUrl));
+ if (!"Small Example Store".equals(client.inspection().document().name())) {
+ throw new IllegalStateException("Java Agent inspected an unexpected Node.js Service");
+ }
+ if (!client.inspection().supports(OdpOperation.LIST_OFFERINGS)
+ || !client.inspection().supports(OdpOperation.GET_OFFERING)) {
+ throw new IllegalStateException("Node.js Service omitted required operations");
+ }
+ Set identifiers = client.listOfferings("terse", null, null).items().stream()
+ .map(offering -> offering.id())
+ .collect(Collectors.toSet());
+ if (!identifiers.containsAll(Set.of("architecture-review", "incident-plan"))) {
+ throw new IllegalStateException("Node.js Service omitted expected Offerings");
+ }
+ var offering = client.getOffering("incident-plan", "full", null);
+ if (!"Incident Response Plan".equals(offering.name())
+ || offering.price() == null
+ || !"free".equals(offering.price().type())
+ || offering.actions() == null
+ || offering.actions().stream().noneMatch(action -> "download".equals(action.id()))) {
+ throw new IllegalStateException("Node.js full Offering did not match its advertised catalog");
+ }
+ System.out.println( // NOPMD - Console output reports successful interoperability.
+ "Java Agent interoperates with the Node.js example Service");
+ }
+}