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"); + } +}