diff --git a/config/spotbugs-exclude.xml b/config/spotbugs-exclude.xml
new file mode 100644
index 0000000..15d0a8e
--- /dev/null
+++ b/config/spotbugs-exclude.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OdpAgent.java b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OdpAgent.java
new file mode 100644
index 0000000..eb683b7
--- /dev/null
+++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OdpAgent.java
@@ -0,0 +1,72 @@
+package org.offeringprotocol.odp.agent;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import org.offeringprotocol.odp.core.Offering;
+import org.offeringprotocol.odp.core.SearchRequests;
+import org.offeringprotocol.odp.directory.DirectoryClient;
+import org.offeringprotocol.odp.directory.DirectoryModels;
+
+/** Two-stage directory-to-Service Offering discovery. */
+public final class OdpAgent {
+ private final DirectoryClient directory;
+ private final ServiceClientFactory serviceClients;
+
+ public OdpAgent(DirectoryClient directory) {
+ this(directory, origin -> OdpServiceClient.create(URI.create(origin)));
+ }
+
+ public OdpAgent(DirectoryClient directory, ServiceClientFactory serviceClients) {
+ this.directory = Objects.requireNonNull(directory);
+ this.serviceClients = Objects.requireNonNull(serviceClients);
+ }
+
+ public List searchOfferings(String query, int maximumServices, int offeringsPerService) {
+ if (maximumServices < 1 || maximumServices > 100) {
+ throw new IllegalArgumentException("maximumServices must be from 1 through 100");
+ }
+ if (offeringsPerService < 1 || offeringsPerService > 100) {
+ throw new IllegalArgumentException("offeringsPerService must be from 1 through 100");
+ }
+ DirectoryModels.SearchPage services =
+ directory.searchServices(new DirectoryModels.SearchRequest(query, null, maximumServices));
+ List events = new ArrayList<>();
+ for (DirectoryModels.Service service : services.items()) {
+ try {
+ OdpServiceClient client = serviceClients.create(service.serviceOrigin());
+ if (!client.inspection().supports(org.offeringprotocol.odp.core.OdpOperation.SEARCH_OFFERINGS)) {
+ continue;
+ }
+ var request = new SearchRequests.Offerings(
+ org.offeringprotocol.odp.core.Odp.VERSION,
+ query,
+ null,
+ null,
+ null,
+ null,
+ null,
+ offeringsPerService);
+ for (Offering offering :
+ client.searchOfferings(request, "terse", null).items()) {
+ events.add(new OfferingEvent(service, offering));
+ }
+ } catch (RuntimeException exception) {
+ events.add(new IssueEvent(service, exception.getMessage()));
+ }
+ }
+ return List.copyOf(events);
+ }
+
+ @FunctionalInterface
+ public interface ServiceClientFactory {
+ OdpServiceClient create(String serviceOrigin);
+ }
+
+ public sealed interface DiscoveryEvent permits OfferingEvent, IssueEvent {}
+
+ public record OfferingEvent(DirectoryModels.Service service, Offering offering) implements DiscoveryEvent {}
+
+ public record IssueEvent(DirectoryModels.Service service, String message) implements DiscoveryEvent {}
+}
diff --git a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OdpRequestException.java b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OdpRequestException.java
new file mode 100644
index 0000000..5fe025e
--- /dev/null
+++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OdpRequestException.java
@@ -0,0 +1,31 @@
+package org.offeringprotocol.odp.agent;
+
+import java.net.http.HttpHeaders;
+import org.offeringprotocol.odp.core.ProblemDetails;
+
+public final class OdpRequestException extends RuntimeException {
+ private static final long serialVersionUID = 1L;
+
+ private final int responseStatus;
+ private final transient HttpHeaders responseHeaders;
+ private final transient ProblemDetails problemDetails;
+
+ public OdpRequestException(int status, String message, HttpHeaders headers, ProblemDetails problem) {
+ super(message);
+ this.responseStatus = status;
+ this.responseHeaders = headers;
+ this.problemDetails = problem;
+ }
+
+ public int status() {
+ return responseStatus;
+ }
+
+ public HttpHeaders headers() {
+ return responseHeaders;
+ }
+
+ public ProblemDetails problem() {
+ return problemDetails;
+ }
+}
diff --git a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OdpServiceClient.java b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OdpServiceClient.java
new file mode 100644
index 0000000..0541fa9
--- /dev/null
+++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OdpServiceClient.java
@@ -0,0 +1,235 @@
+package org.offeringprotocol.odp.agent;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+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.OdpUris;
+import org.offeringprotocol.odp.core.Offering;
+import org.offeringprotocol.odp.core.OfferingPage;
+import org.offeringprotocol.odp.core.OperationDescriptor;
+import org.offeringprotocol.odp.core.Page;
+import org.offeringprotocol.odp.core.ProblemDetails;
+import org.offeringprotocol.odp.core.SearchRequests;
+
+/** Validated ODP Service inspection and catalog client. */
+public final class OdpServiceClient {
+ private static final int MAXIMUM_BYTES = 2_097_152;
+ private static final int MAXIMUM_REDIRECTS = 5;
+ private static final String GET = "GET";
+ private static final HttpClient DEFAULT_HTTP_CLIENT = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(10))
+ .followRedirects(HttpClient.Redirect.NEVER)
+ .build();
+
+ private final OdpTransport transport;
+ private final String serviceOrigin;
+ private final ServiceInspection serviceInspection;
+
+ private OdpServiceClient(OdpTransport transport, String serviceOrigin, ServiceInspection inspection) {
+ this.transport = transport;
+ this.serviceOrigin = serviceOrigin;
+ this.serviceInspection = inspection;
+ }
+
+ public static OdpServiceClient create(URI serviceUri) {
+ return create(
+ serviceUri, request -> DEFAULT_HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofByteArray()));
+ }
+
+ public static OdpServiceClient create(URI serviceUri, OdpTransport transport) {
+ Objects.requireNonNull(serviceUri, "serviceUri");
+ Objects.requireNonNull(transport, "transport");
+ String origin = OdpUris.deriveServiceOrigin(serviceUri);
+ URI documentUri = URI.create(origin).resolve(Odp.SERVICE_DOCUMENT_PATH);
+ String json = request(transport, documentUri, GET, null, null, 524_288);
+ var document = OdpJson.parseServiceDocument(json);
+ Map operations = new LinkedHashMap<>();
+ for (OperationDescriptor operation : document.operations()) {
+ operations.put(operation.name(), operation);
+ }
+ return new OdpServiceClient(
+ transport, origin, new ServiceInspection(origin, documentUri, document, Map.copyOf(operations)));
+ }
+
+ public ServiceInspection inspection() {
+ return serviceInspection;
+ }
+
+ public Page listCollections(String representation, Integer limit, String language) {
+ return collectionPage(
+ requestOperation(OdpOperation.LIST_COLLECTIONS, null, representation, limit, language, null));
+ }
+
+ public Page searchCollections(
+ SearchRequests.Collections request, String representation, String language) {
+ return collectionPage(requestOperation(
+ OdpOperation.SEARCH_COLLECTIONS, null, representation, null, language, OdpJson.write(request)));
+ }
+
+ public Collection getCollection(String id, String representation, String language) {
+ return OdpJson.parseCollection(
+ requestOperation(OdpOperation.GET_COLLECTION, id, representation, null, language, null));
+ }
+
+ public Page listCollectionOfferings(
+ String collectionId, String representation, Integer limit, String language) {
+ return offeringPage(requestOperation(
+ OdpOperation.LIST_COLLECTION_OFFERINGS, collectionId, representation, limit, language, null));
+ }
+
+ public Page listOfferings(String representation, Integer limit, String language) {
+ return offeringPage(requestOperation(OdpOperation.LIST_OFFERINGS, null, representation, limit, language, null));
+ }
+
+ public OfferingPage searchOfferings(SearchRequests.Offerings request, String representation, String language) {
+ OfferingPage page = OdpJson.parseOfferingSearchResponse(requestOperation(
+ OdpOperation.SEARCH_OFFERINGS, null, representation, null, language, OdpJson.write(request)));
+ page.items().forEach(item -> requireSummary(item.id(), item.name(), "Offering"));
+ return page;
+ }
+
+ public Offering getOffering(String id, String representation, String language) {
+ return OdpJson.parseOffering(
+ requestOperation(OdpOperation.GET_OFFERING, id, representation, null, language, null));
+ }
+
+ public Page continueCollections(String next, String language) {
+ URI target = OdpUris.resolveContinuation(next, serviceOrigin);
+ return collectionPage(request(transport, target, GET, null, language, MAXIMUM_BYTES));
+ }
+
+ public Page continueOfferings(String next, String language) {
+ URI target = OdpUris.resolveContinuation(next, serviceOrigin);
+ return offeringPage(request(transport, target, GET, null, language, MAXIMUM_BYTES));
+ }
+
+ private String requestOperation(
+ OdpOperation operation,
+ String identifier,
+ String representation,
+ Integer limit,
+ String language,
+ String body) {
+ if (!serviceInspection.supports(operation)) {
+ throw new IllegalStateException("Service does not advertise " + operation.value());
+ }
+ String selectedRepresentation = representation == null ? "terse" : representation;
+ if (!"terse".equals(selectedRepresentation) && !"full".equals(selectedRepresentation)) {
+ throw new IllegalArgumentException("representation must be terse or full");
+ }
+ if (limit != null && (limit < 1 || limit > 100)) {
+ throw new IllegalArgumentException("limit must be from 1 through 100");
+ }
+ URI target = OdpUris.buildOperationUri(
+ serviceInspection.document().http().endpointBase(), operation, serviceOrigin, identifier);
+ String separator = target.getQuery() == null ? "?" : "&";
+ target = URI.create(target + separator + "representation=" + selectedRepresentation
+ + (limit == null ? "" : "&limit=" + limit));
+ return request(transport, target, operation.method(), body, language, MAXIMUM_BYTES);
+ }
+
+ private static Page collectionPage(String json) {
+ Page page = OdpJson.parsePage(json, Collection.class);
+ page.items().forEach(item -> requireSummary(item.id(), item.name(), "Collection"));
+ return page;
+ }
+
+ private static Page offeringPage(String json) {
+ Page page = OdpJson.parsePage(json, Offering.class);
+ page.items().forEach(item -> requireSummary(item.id(), item.name(), "Offering"));
+ return page;
+ }
+
+ private static void requireSummary(String identifier, String name, String resourceType) {
+ if (!OdpUris.isLocalResourceIdentifier(identifier) || name == null || name.isBlank()) {
+ throw new IllegalArgumentException(resourceType + " summary is invalid");
+ }
+ }
+
+ private static String request(
+ OdpTransport transport, URI target, String method, String body, String language, int maximumBytes) {
+ URI current = target;
+ String currentMethod = method;
+ String currentBody = body;
+ boolean hasBody = body != null;
+ for (int redirects = 0; redirects <= MAXIMUM_REDIRECTS; redirects++) {
+ HttpRequest.Builder builder = HttpRequest.newBuilder(current)
+ .timeout(Duration.ofSeconds(30))
+ .header("Accept", "application/odp+json, application/problem+json");
+ if (language != null && !language.isBlank()) {
+ builder.header("Accept-Language", language);
+ }
+ if (!hasBody) {
+ builder.method(currentMethod, HttpRequest.BodyPublishers.noBody());
+ } else {
+ builder.header("Content-Type", "application/odp+json")
+ .method(currentMethod, HttpRequest.BodyPublishers.ofString(currentBody));
+ }
+ HttpResponse response;
+ try {
+ response = transport.send(builder.build());
+ } catch (IOException exception) {
+ throw new IllegalStateException("ODP request failed", exception);
+ } catch (InterruptedException exception) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException("ODP request was interrupted", exception);
+ }
+ int status = response.statusCode();
+ if (status == 301 || status == 302 || status == 303 || status == 307 || status == 308) {
+ if (redirects == MAXIMUM_REDIRECTS) {
+ throw new IllegalStateException("ODP response exceeded its redirect limit");
+ }
+ String location = response.headers()
+ .firstValue("Location")
+ .orElseThrow(() -> new IllegalStateException("ODP redirect omitted Location"));
+ URI next = current.resolve(location);
+ if (!OdpUris.deriveServiceOrigin(next).equals(OdpUris.deriveServiceOrigin(target))) {
+ throw new IllegalStateException("ODP redirect changed Service origin");
+ }
+ if (status == 303 || ((status == 301 || status == 302) && "POST".equals(currentMethod))) {
+ currentMethod = GET;
+ hasBody = false;
+ }
+ current = next;
+ } else {
+ byte[] bytes = response.body();
+ if (bytes.length > maximumBytes) {
+ throw new IllegalStateException("ODP response exceeds its byte limit");
+ }
+ String text = new String(bytes, StandardCharsets.UTF_8);
+ if (status < 200 || status > 299) {
+ ProblemDetails problem = null;
+ try {
+ problem = OdpJson.parseProblemDetails(text);
+ } catch (IllegalArgumentException ignored) {
+ // The HTTP status remains available when a peer does not return ODP Problem Details.
+ }
+ throw new OdpRequestException(
+ status,
+ problem == null ? "ODP request failed with HTTP " + status : problem.title(),
+ response.headers(),
+ problem);
+ }
+ String contentType =
+ response.headers().firstValue("Content-Type").orElse("");
+ if (!contentType.toLowerCase(Locale.ROOT).startsWith("application/odp+json")) {
+ throw new IllegalStateException("ODP response must use application/odp+json");
+ }
+ return text;
+ }
+ }
+ throw new IllegalStateException("ODP request produced no response");
+ }
+}
diff --git a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OdpTransport.java b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OdpTransport.java
new file mode 100644
index 0000000..28eff0a
--- /dev/null
+++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OdpTransport.java
@@ -0,0 +1,10 @@
+package org.offeringprotocol.odp.agent;
+
+import java.io.IOException;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+
+@FunctionalInterface
+public interface OdpTransport {
+ HttpResponse send(HttpRequest request) throws IOException, InterruptedException;
+}
diff --git a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/ServiceInspection.java b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/ServiceInspection.java
new file mode 100644
index 0000000..7e19775
--- /dev/null
+++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/ServiceInspection.java
@@ -0,0 +1,21 @@
+package org.offeringprotocol.odp.agent;
+
+import java.net.URI;
+import java.util.Map;
+import org.offeringprotocol.odp.core.OdpOperation;
+import org.offeringprotocol.odp.core.OperationDescriptor;
+import org.offeringprotocol.odp.core.ServiceDocument;
+
+public record ServiceInspection(
+ String serviceOrigin,
+ URI documentUri,
+ ServiceDocument document,
+ Map operations) {
+ public ServiceInspection {
+ operations = Map.copyOf(operations);
+ }
+
+ public boolean supports(OdpOperation operation) {
+ return operations.containsKey(operation);
+ }
+}
diff --git a/odp-agent/src/test/java/org/offeringprotocol/odp/agent/OdpServiceClientTest.java b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/OdpServiceClientTest.java
new file mode 100644
index 0000000..d992023
--- /dev/null
+++ b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/OdpServiceClientTest.java
@@ -0,0 +1,90 @@
+package org.offeringprotocol.odp.agent;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpHeaders;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import javax.net.ssl.SSLSession;
+import org.junit.jupiter.api.Test;
+import org.offeringprotocol.odp.core.OdpOperation;
+
+class OdpServiceClientTest {
+ @Test
+ void inspectsAndNavigatesAService() {
+ OdpTransport transport = request -> response(
+ request,
+ switch (request.uri().getPath()) {
+ case "/.well-known/odp" -> """
+ {"odp_version":"1.0","name":"Plant Store","description":"Plants for agents.",
+ "language":"en","localizations":["en"],"operations":[
+ {"authentication":"not-required","name":"get-offering"},
+ {"authentication":"not-required","name":"list-offerings"}],
+ "http":{"endpoint_base":"/odp"}}
+ """;
+ case "/odp/offerings" -> """
+ {"odp_version":"1.0","items":[
+ {"id":"rubber-plant","name":"Rubber Plant","description":"A resilient houseplant."}]}
+ """;
+ default -> throw new AssertionError("Unexpected request " + request.uri());
+ });
+
+ OdpServiceClient client = OdpServiceClient.create(URI.create("https://plants.example"), transport);
+
+ assertTrue(client.inspection().supports(OdpOperation.LIST_OFFERINGS));
+ assertEquals(
+ "Rubber Plant",
+ client.listOfferings("terse", 10, "en").items().get(0).name());
+ }
+
+ private static HttpResponse response(HttpRequest request, String body) {
+ return new HttpResponse<>() {
+ @Override
+ public int statusCode() {
+ return 200;
+ }
+
+ @Override
+ public HttpRequest request() {
+ return request;
+ }
+
+ @Override
+ public Optional> previousResponse() {
+ return Optional.empty();
+ }
+
+ @Override
+ public HttpHeaders headers() {
+ return HttpHeaders.of(Map.of("Content-Type", List.of("application/odp+json")), (a, b) -> true);
+ }
+
+ @Override
+ public byte[] body() {
+ return body.getBytes(StandardCharsets.UTF_8);
+ }
+
+ @Override
+ public Optional sslSession() {
+ return Optional.empty();
+ }
+
+ @Override
+ public URI uri() {
+ return request.uri();
+ }
+
+ @Override
+ public HttpClient.Version version() {
+ return HttpClient.Version.HTTP_1_1;
+ }
+ };
+ }
+}
diff --git a/odp-core/pom.xml b/odp-core/pom.xml
index d871e7b..925f596 100644
--- a/odp-core/pom.xml
+++ b/odp-core/pom.xml
@@ -17,4 +17,18 @@
org.offeringprotocol.odp.core
+
+
+
+ com.networknt
+ json-schema-validator
+ ${json-schema-validator.version}
+
+
+ tools.jackson.dataformat
+ jackson-dataformat-yaml
+
+
+
+
diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/AuthenticationRequirement.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/AuthenticationRequirement.java
new file mode 100644
index 0000000..b136c0b
--- /dev/null
+++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/AuthenticationRequirement.java
@@ -0,0 +1,30 @@
+package org.offeringprotocol.odp.core;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonValue;
+import java.util.Arrays;
+
+public enum AuthenticationRequirement {
+ NOT_REQUIRED("not-required"),
+ OPTIONAL("optional"),
+ REQUIRED("required");
+
+ private final String encodedValue;
+
+ AuthenticationRequirement(String value) {
+ this.encodedValue = value;
+ }
+
+ @JsonValue
+ public String value() {
+ return encodedValue;
+ }
+
+ @JsonCreator
+ public static AuthenticationRequirement fromValue(String value) {
+ return Arrays.stream(values())
+ .filter(requirement -> requirement.encodedValue.equals(value))
+ .findFirst()
+ .orElseThrow(() -> new IllegalArgumentException("Unsupported authentication requirement: " + value));
+ }
+}
diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/Collection.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/Collection.java
new file mode 100644
index 0000000..430e866
--- /dev/null
+++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/Collection.java
@@ -0,0 +1,31 @@
+package org.offeringprotocol.odp.core;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.List;
+import java.util.Map;
+import tools.jackson.databind.JsonNode;
+
+public record Collection(
+ @JsonProperty("auth_expands") Boolean authExpands,
+ @JsonProperty("odp_version") String odpVersion,
+ String id,
+ String name,
+ String description,
+ List images,
+ String language,
+ List localizations,
+ @JsonProperty("parent_ids") List parentIds,
+ @JsonProperty("web_url") String webUrl,
+ @JsonProperty("search_capabilities") SearchCapabilities searchCapabilities,
+ @JsonProperty("detail_fields") List detailFields,
+ @JsonAnySetter @JsonAnyGetter Map additional) {
+ public Collection {
+ images = Copies.list(images);
+ localizations = Copies.list(localizations);
+ parentIds = Copies.list(parentIds);
+ detailFields = Copies.list(detailFields);
+ additional = Copies.nodes(additional);
+ }
+}
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
new file mode 100644
index 0000000..af43bc9
--- /dev/null
+++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/Copies.java
@@ -0,0 +1,27 @@
+package org.offeringprotocol.odp.core;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import tools.jackson.databind.JsonNode;
+
+final class Copies {
+ private Copies() {}
+
+ static List list(List value) {
+ return value == null ? null : List.copyOf(value);
+ }
+
+ static Map map(Map value) {
+ return value == null ? Map.of() : Map.copyOf(value);
+ }
+
+ static Map nodes(Map value) {
+ if (value == null) {
+ return Map.of();
+ }
+ return value.entrySet().stream()
+ .collect(Collectors.toUnmodifiableMap(
+ Map.Entry::getKey, entry -> entry.getValue().deepCopy()));
+ }
+}
diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/HttpMethods.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/HttpMethods.java
new file mode 100644
index 0000000..f9d4e13
--- /dev/null
+++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/HttpMethods.java
@@ -0,0 +1,7 @@
+package org.offeringprotocol.odp.core;
+
+final class HttpMethods {
+ static final String GET = "GET";
+
+ private HttpMethods() {}
+}
diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/Odp.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/Odp.java
new file mode 100644
index 0000000..deaf993
--- /dev/null
+++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/Odp.java
@@ -0,0 +1,9 @@
+package org.offeringprotocol.odp.core;
+
+/** Protocol-wide constants. */
+public final class Odp {
+ public static final String VERSION = "1.0";
+ public static final String SERVICE_DOCUMENT_PATH = "/.well-known/odp";
+
+ private Odp() {}
+}
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
new file mode 100644
index 0000000..da0bef6
--- /dev/null
+++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpJson.java
@@ -0,0 +1,131 @@
+package org.offeringprotocol.odp.core;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.networknt.schema.InputFormat;
+import com.networknt.schema.Schema;
+import com.networknt.schema.SchemaLocation;
+import com.networknt.schema.SchemaRegistry;
+import com.networknt.schema.SpecificationVersion;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import tools.jackson.core.JacksonException;
+import tools.jackson.databind.JavaType;
+import tools.jackson.databind.json.JsonMapper;
+
+/** Validated JSON encoding and decoding for ODP documents. */
+public final class OdpJson {
+ private static final String SCHEMA_ORIGIN = "https://offeringprotocol.org/schemas/";
+ private static final String SCHEMA_PATH = "/org/offeringprotocol/odp/core/schemas/";
+ private static final JsonMapper MAPPER = JsonMapper.builder()
+ .changeDefaultPropertyInclusion(inclusion -> inclusion.withValueInclusion(JsonInclude.Include.NON_NULL))
+ .build();
+ private static final SchemaRegistry SCHEMAS = createRegistry();
+
+ private OdpJson() {}
+
+ public static ServiceDocument parseServiceDocument(String json) {
+ return parse(json, "service-document.schema.json", "Service Document", ServiceDocument.class);
+ }
+
+ public static Collection parseCollection(String json) {
+ return parse(json, "collection.schema.json", "Collection", Collection.class);
+ }
+
+ public static Offering parseOffering(String json) {
+ return parse(json, "offering.schema.json", "Offering", Offering.class);
+ }
+
+ public static ProblemDetails parseProblemDetails(String json) {
+ return parse(json, "problem-details.schema.json", "Problem Details", ProblemDetails.class);
+ }
+
+ public static SearchRequests.Collections parseCollectionSearchRequest(String json) {
+ return parse(
+ json,
+ "collection-search-request.schema.json",
+ "Collection search request",
+ SearchRequests.Collections.class);
+ }
+
+ public static SearchRequests.Offerings parseOfferingSearchRequest(String json) {
+ return parse(
+ json, "offering-search-request.schema.json", "Offering search request", SearchRequests.Offerings.class);
+ }
+
+ public static OfferingPage parseOfferingSearchResponse(String json) {
+ return parse(json, "offering-search-response.schema.json", "Offering search response", OfferingPage.class);
+ }
+
+ public static Page parsePage(String json, Class itemType) {
+ validate(json, "page-envelope.schema.json", "page envelope");
+ JavaType type = MAPPER.getTypeFactory().constructParametricType(Page.class, itemType);
+ return decode(json, type, "page envelope");
+ }
+
+ public static String write(Object value) {
+ try {
+ return MAPPER.writeValueAsString(value);
+ } catch (JacksonException exception) {
+ throw new IllegalArgumentException("Unable to encode ODP JSON", exception);
+ }
+ }
+
+ private static T parse(String json, String schemaName, String documentType, Class type) {
+ validate(json, schemaName, documentType);
+ return decode(json, MAPPER.getTypeFactory().constructType(type), documentType);
+ }
+
+ private static T decode(String json, JavaType type, String documentType) {
+ try {
+ return MAPPER.readValue(json, type);
+ } catch (JacksonException exception) {
+ throw new OdpValidationException(
+ documentType,
+ List.of(new ValidationIssue("json", exception.getMessage(), Map.of(), "")),
+ exception);
+ }
+ }
+
+ private static void validate(String json, String schemaName, String documentType) {
+ Schema schema = SCHEMAS.getSchema(SchemaLocation.of(SCHEMA_ORIGIN + schemaName));
+ List errors = schema.validate(
+ json,
+ InputFormat.JSON,
+ context -> context.executionConfig(config -> config.formatAssertionsEnabled(true)));
+ if (!errors.isEmpty()) {
+ List issues = errors.stream()
+ .map(error -> new ValidationIssue(
+ error.getKeyword(),
+ error.getMessage(),
+ Map.of(),
+ error.getInstanceLocation().toString()))
+ .toList();
+ throw new OdpValidationException(documentType, issues);
+ }
+ }
+
+ private static SchemaRegistry createRegistry() {
+ Map schemas = new HashMap<>();
+ for (String name :
+ readSchema("index.txt").lines().filter(line -> !line.isBlank()).toList()) {
+ schemas.put(SCHEMA_ORIGIN + name, readSchema(name));
+ }
+ return SchemaRegistry.withDefaultDialect(
+ SpecificationVersion.DRAFT_2020_12, builder -> builder.schemas(schemas));
+ }
+
+ private static String readSchema(String name) {
+ try (InputStream input = OdpJson.class.getResourceAsStream(SCHEMA_PATH + name)) {
+ if (input == null) {
+ throw new IllegalStateException("Missing bundled ODP schema " + name);
+ }
+ return new String(input.readAllBytes(), StandardCharsets.UTF_8);
+ } catch (IOException exception) {
+ throw new IllegalStateException("Unable to read bundled ODP schema " + name, exception);
+ }
+ }
+}
diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpOperation.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpOperation.java
new file mode 100644
index 0000000..cd305e6
--- /dev/null
+++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpOperation.java
@@ -0,0 +1,40 @@
+package org.offeringprotocol.odp.core;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonValue;
+import java.util.Arrays;
+
+public enum OdpOperation {
+ GET_COLLECTION("get-collection", HttpMethods.GET),
+ GET_OFFERING("get-offering", HttpMethods.GET),
+ LIST_COLLECTION_OFFERINGS("list-collection-offerings", HttpMethods.GET),
+ LIST_COLLECTIONS("list-collections", HttpMethods.GET),
+ LIST_OFFERINGS("list-offerings", HttpMethods.GET),
+ SEARCH_COLLECTIONS("search-collections", "POST"),
+ SEARCH_OFFERINGS("search-offerings", "POST");
+
+ private final String encodedValue;
+ private final String httpMethod;
+
+ OdpOperation(String value, String method) {
+ this.encodedValue = value;
+ this.httpMethod = method;
+ }
+
+ @JsonValue
+ public String value() {
+ return encodedValue;
+ }
+
+ public String method() {
+ return httpMethod;
+ }
+
+ @JsonCreator
+ public static OdpOperation fromValue(String value) {
+ return Arrays.stream(values())
+ .filter(operation -> operation.encodedValue.equals(value))
+ .findFirst()
+ .orElseThrow(() -> new IllegalArgumentException("Unsupported ODP operation: " + value));
+ }
+}
diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpPagination.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpPagination.java
new file mode 100644
index 0000000..7f9e43c
--- /dev/null
+++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpPagination.java
@@ -0,0 +1,44 @@
+package org.offeringprotocol.odp.core;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/** Bounded helpers for following opaque ODP continuation references. */
+public final class OdpPagination {
+ public static final int MAXIMUM_PAGES = 16;
+
+ private OdpPagination() {}
+
+ public static List> pages(Page first, PageLoader loader) {
+ List> pages = new ArrayList<>();
+ Set continuations = new HashSet<>();
+ Page page = first;
+ for (int count = 0; count < MAXIMUM_PAGES; count++) {
+ pages.add(page);
+ if (page.next() == null) {
+ return List.copyOf(pages);
+ }
+ if (!continuations.add(page.next())) {
+ throw new IllegalStateException("ODP pagination loop detected");
+ }
+ if (count == MAXIMUM_PAGES - 1) {
+ throw new IllegalStateException("ODP pagination exceeded the 16-page traversal limit");
+ }
+ page = loader.load(page.next());
+ }
+ throw new IllegalStateException("ODP pagination exceeded the 16-page traversal limit");
+ }
+
+ public static List items(Page first, PageLoader loader) {
+ return pages(first, loader).stream()
+ .flatMap(page -> page.items().stream())
+ .toList();
+ }
+
+ @FunctionalInterface
+ public interface PageLoader {
+ Page load(String continuation);
+ }
+}
diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpUris.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpUris.java
new file mode 100644
index 0000000..9c6f53d
--- /dev/null
+++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpUris.java
@@ -0,0 +1,106 @@
+package org.offeringprotocol.odp.core;
+
+import java.net.IDN;
+import java.net.URI;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.regex.Pattern;
+
+/** ODP origin, operation, and reference resolution. */
+public final class OdpUris {
+ private static final Pattern IDENTIFIER = Pattern.compile("[A-Za-z0-9._~-]{1,128}");
+
+ private OdpUris() {}
+
+ public static String deriveServiceOrigin(URI serviceDocumentUri) {
+ requireSecure(serviceDocumentUri);
+ if (serviceDocumentUri.getUserInfo() != null) {
+ throw new IllegalArgumentException("Service URI cannot contain user information");
+ }
+ String host = IDN.toASCII(serviceDocumentUri.getHost()).toLowerCase(Locale.ROOT);
+ int port = serviceDocumentUri.getPort();
+ boolean defaultPort = ("https".equals(serviceDocumentUri.getScheme()) && port == 443)
+ || ("http".equals(serviceDocumentUri.getScheme()) && port == 80);
+ return serviceDocumentUri.getScheme() + "://" + host + (port < 0 || defaultPort ? "" : ":" + port);
+ }
+
+ public static URI resolveResourceReference(String reference, String serviceOrigin) {
+ Objects.requireNonNull(reference, "reference");
+ if (reference.startsWith("//")
+ || (!reference.startsWith("/")
+ && !reference.startsWith("https://")
+ && !reference.startsWith("http://localhost")
+ && !reference.startsWith("http://127.0.0.1")
+ && !reference.startsWith("http://[::1]"))) {
+ throw new IllegalArgumentException(
+ "ODP resource reference must be an origin-relative absolute path or secure absolute URI");
+ }
+ URI resolved = URI.create(serviceOrigin).resolve(reference);
+ if (resolved.getFragment() != null || resolved.getUserInfo() != null) {
+ throw new IllegalArgumentException("ODP resource reference cannot contain a fragment or user information");
+ }
+ requireSecure(resolved);
+ return resolved;
+ }
+
+ public static URI resolveContinuation(String reference, String serviceOrigin) {
+ URI resolved = resolveResourceReference(reference, serviceOrigin);
+ if (!deriveServiceOrigin(resolved).equals(deriveServiceOrigin(URI.create(serviceOrigin)))) {
+ throw new IllegalArgumentException("ODP continuation reference must remain on the Service origin");
+ }
+ return resolved;
+ }
+
+ public static URI buildOperationUri(
+ String endpointBase, OdpOperation operation, String serviceOrigin, String identifier) {
+ if (!endpointBase.startsWith("/") || endpointBase.startsWith("//")) {
+ throw new IllegalArgumentException("ODP endpoint base must be an origin-relative absolute path");
+ }
+ String suffix =
+ switch (operation) {
+ case LIST_COLLECTIONS -> "/collections";
+ case SEARCH_COLLECTIONS -> "/collections/search";
+ case GET_COLLECTION -> "/collections/" + requireIdentifier(identifier, operation);
+ case LIST_COLLECTION_OFFERINGS ->
+ "/collections/" + requireIdentifier(identifier, operation) + "/offerings";
+ case LIST_OFFERINGS -> "/offerings";
+ case SEARCH_OFFERINGS -> "/offerings/search";
+ case GET_OFFERING -> "/offerings/" + requireIdentifier(identifier, operation);
+ };
+ if ((operation == OdpOperation.LIST_COLLECTIONS
+ || operation == OdpOperation.SEARCH_COLLECTIONS
+ || operation == OdpOperation.LIST_OFFERINGS
+ || operation == OdpOperation.SEARCH_OFFERINGS)
+ && identifier != null) {
+ throw new IllegalArgumentException(operation.value() + " does not accept a resource identifier");
+ }
+ return resolveResourceReference(endpointBase.replaceFirst("/$", "") + suffix, serviceOrigin);
+ }
+
+ public static boolean isLocalResourceIdentifier(String value) {
+ return value != null
+ && !".".equals(value)
+ && !"..".equals(value)
+ && IDENTIFIER.matcher(value).matches();
+ }
+
+ private static String requireIdentifier(String identifier, OdpOperation operation) {
+ if (!isLocalResourceIdentifier(identifier)) {
+ throw new IllegalArgumentException(operation.value() + " requires a valid local resource identifier");
+ }
+ return identifier;
+ }
+
+ private static void requireSecure(URI uri) {
+ if (uri.getHost() == null) {
+ throw new IllegalArgumentException("ODP URI must include a host");
+ }
+ boolean loopback = "localhost".equals(uri.getHost())
+ || "127.0.0.1".equals(uri.getHost()) // NOPMD - ODP explicitly permits loopback HTTP.
+ || "[::1]".equals(uri.getHost())
+ || "::1".equals(uri.getHost()); // NOPMD - ODP explicitly permits loopback HTTP.
+ if (!"https".equals(uri.getScheme()) && !("http".equals(uri.getScheme()) && loopback)) {
+ throw new IllegalArgumentException("ODP URI must use HTTPS except on loopback hosts");
+ }
+ }
+}
diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpValidationException.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpValidationException.java
new file mode 100644
index 0000000..2f6f029
--- /dev/null
+++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpValidationException.java
@@ -0,0 +1,28 @@
+package org.offeringprotocol.odp.core;
+
+import java.util.List;
+
+public final class OdpValidationException extends IllegalArgumentException {
+ private static final long serialVersionUID = 1L;
+
+ private final String invalidDocumentType;
+ private final List validationIssues;
+
+ public OdpValidationException(String documentType, List issues) {
+ this(documentType, issues, null);
+ }
+
+ public OdpValidationException(String documentType, List issues, Throwable cause) {
+ super("Invalid ODP " + documentType, cause);
+ this.invalidDocumentType = documentType;
+ this.validationIssues = List.copyOf(issues);
+ }
+
+ public String documentType() {
+ return invalidDocumentType;
+ }
+
+ public List issues() {
+ return validationIssues;
+ }
+}
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
new file mode 100644
index 0000000..482ee85
--- /dev/null
+++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/Offering.java
@@ -0,0 +1,76 @@
+package org.offeringprotocol.odp.core;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.List;
+import java.util.Map;
+import tools.jackson.databind.JsonNode;
+
+public record Offering(
+ @JsonProperty("auth_expands") Boolean authExpands,
+ @JsonProperty("odp_version") String odpVersion,
+ String id,
+ String name,
+ String description,
+ List images,
+ String language,
+ List localizations,
+ @JsonProperty("web_url") String webUrl,
+ @JsonProperty("collection_ids") List collectionIds,
+ PricePreview price,
+ SchemaReference schema,
+ Map attributes,
+ List actions,
+ @JsonProperty("detail_fields") List detailFields,
+ @JsonAnySetter @JsonAnyGetter Map additional) {
+
+ public Offering {
+ images = Copies.list(images);
+ localizations = Copies.list(localizations);
+ collectionIds = Copies.list(collectionIds);
+ attributes = Copies.nodes(attributes);
+ actions = Copies.list(actions);
+ detailFields = Copies.list(detailFields);
+ additional = Copies.nodes(additional);
+ }
+
+ public record PricePreview(
+ String type,
+ String amount,
+ String currency,
+ String minimum,
+ String maximum,
+ String unit,
+ @JsonAnySetter @JsonAnyGetter Map additional) {
+ public PricePreview {
+ additional = Copies.nodes(additional);
+ }
+ }
+
+ public record SchemaReference(String url) {}
+
+ public record Action(
+ AuthenticationRequirement authentication,
+ String id,
+ String rel,
+ String description,
+ HttpTarget http,
+ OpenApiTarget openapi) {}
+
+ public record HttpTarget(
+ String href,
+ String method,
+ ActionRequest request,
+ @JsonProperty("response_content_types") List responseContentTypes) {
+ public HttpTarget {
+ responseContentTypes = Copies.list(responseContentTypes);
+ }
+ }
+
+ public record ActionRequest(
+ @JsonProperty("content_type") String contentType, SchemaReference schema) {}
+
+ public record OpenApiTarget(
+ @JsonProperty("operation_id") String operationId, String url) {}
+}
diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/OfferingPage.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/OfferingPage.java
new file mode 100644
index 0000000..58a7f8b
--- /dev/null
+++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/OfferingPage.java
@@ -0,0 +1,37 @@
+package org.offeringprotocol.odp.core;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.List;
+import java.util.Map;
+import tools.jackson.databind.JsonNode;
+
+public record OfferingPage(
+ @JsonProperty("auth_expands") Boolean authExpands,
+ @JsonProperty("odp_version") String odpVersion,
+ List items,
+ String next,
+ List refinements,
+ @JsonAnySetter @JsonAnyGetter Map additional) {
+ public OfferingPage {
+ items = List.copyOf(items);
+ refinements = Copies.list(refinements);
+ additional = Copies.nodes(additional);
+ }
+
+ public record RefinementGroup(@JsonProperty("filter_id") String filterId, List values) {
+ public RefinementGroup {
+ values = List.copyOf(values);
+ }
+ }
+
+ public record RefinementBucket(
+ JsonNode value,
+ long count,
+ @JsonProperty("count_relation") String countRelation) {
+ public RefinementBucket {
+ value = value.deepCopy();
+ }
+ }
+}
diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/OperationDescriptor.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/OperationDescriptor.java
new file mode 100644
index 0000000..79a3b7a
--- /dev/null
+++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/OperationDescriptor.java
@@ -0,0 +1,3 @@
+package org.offeringprotocol.odp.core;
+
+public record OperationDescriptor(AuthenticationRequirement authentication, OdpOperation name) {}
diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/Page.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/Page.java
new file mode 100644
index 0000000..88e00df
--- /dev/null
+++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/Page.java
@@ -0,0 +1,20 @@
+package org.offeringprotocol.odp.core;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.List;
+import java.util.Map;
+import tools.jackson.databind.JsonNode;
+
+public record Page(
+ @JsonProperty("auth_expands") Boolean authExpands,
+ @JsonProperty("odp_version") String odpVersion,
+ List items,
+ String next,
+ @JsonAnySetter @JsonAnyGetter Map additional) {
+ public Page {
+ items = List.copyOf(items);
+ additional = Copies.map(additional);
+ }
+}
diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/PaymentOption.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/PaymentOption.java
new file mode 100644
index 0000000..fbb4c30
--- /dev/null
+++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/PaymentOption.java
@@ -0,0 +1,43 @@
+package org.offeringprotocol.odp.core;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonValue;
+import java.util.Arrays;
+
+public enum PaymentOption {
+ ALGORAND("algorand"),
+ APTOS("aptos"),
+ ARBITRUM("arbitrum"),
+ AVALANCHE("avalanche"),
+ BASE("base"),
+ CARD("card"),
+ ETHEREUM("ethereum"),
+ HEDERA("hedera"),
+ INFLOW("inflow"),
+ LIGHTNING("lightning"),
+ POLYGON("polygon"),
+ SOLANA("solana"),
+ STELLAR("stellar"),
+ STRIPE("stripe"),
+ TEMPO("tempo"),
+ TON("ton");
+
+ private final String encodedValue;
+
+ PaymentOption(String value) {
+ this.encodedValue = value;
+ }
+
+ @JsonValue
+ public String value() {
+ return encodedValue;
+ }
+
+ @JsonCreator
+ public static PaymentOption fromValue(String value) {
+ return Arrays.stream(values())
+ .filter(option -> option.encodedValue.equals(value))
+ .findFirst()
+ .orElseThrow(() -> new IllegalArgumentException("Unsupported payment option: " + value));
+ }
+}
diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/ProblemDetails.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/ProblemDetails.java
new file mode 100644
index 0000000..1acc247
--- /dev/null
+++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/ProblemDetails.java
@@ -0,0 +1,25 @@
+package org.offeringprotocol.odp.core;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.List;
+import java.util.Map;
+import tools.jackson.databind.JsonNode;
+
+public record ProblemDetails(
+ String type,
+ String title,
+ int status,
+ String code,
+ String detail,
+ String instance,
+ @JsonProperty("invalid_params") List invalidParams,
+ @JsonAnySetter @JsonAnyGetter Map additional) {
+ public ProblemDetails {
+ invalidParams = Copies.list(invalidParams);
+ additional = Copies.nodes(additional);
+ }
+
+ public record InvalidParameter(String in, String name, String reason) {}
+}
diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/ResourceIdentity.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/ResourceIdentity.java
new file mode 100644
index 0000000..49f3567
--- /dev/null
+++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/ResourceIdentity.java
@@ -0,0 +1,19 @@
+package org.offeringprotocol.odp.core;
+
+import java.net.URI;
+
+public record ResourceIdentity(String service, String type, String id) {
+ public static ResourceIdentity create(URI serviceDocumentUri, String type, String id) {
+ if (!"collection".equals(type) && !"offering".equals(type)) {
+ throw new IllegalArgumentException("Invalid ODP resource type");
+ }
+ if (!OdpUris.isLocalResourceIdentifier(id)) {
+ throw new IllegalArgumentException("Invalid ODP local resource identifier");
+ }
+ return new ResourceIdentity(OdpUris.deriveServiceOrigin(serviceDocumentUri), type, id);
+ }
+
+ public String key() {
+ return service + '\0' + type + '\0' + id;
+ }
+}
diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/ResourceImage.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/ResourceImage.java
new file mode 100644
index 0000000..2611b29
--- /dev/null
+++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/ResourceImage.java
@@ -0,0 +1,9 @@
+package org.offeringprotocol.odp.core;
+
+public record ResourceImage(String alt, Integer height, String src, String type, Integer width) {
+ public ResourceImage {
+ if (src == null || src.isBlank()) {
+ throw new IllegalArgumentException("Image source is required");
+ }
+ }
+}
diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/SearchCapabilities.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/SearchCapabilities.java
new file mode 100644
index 0000000..2f1978b
--- /dev/null
+++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/SearchCapabilities.java
@@ -0,0 +1,89 @@
+package org.offeringprotocol.odp.core;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.List;
+import java.util.Map;
+import tools.jackson.databind.JsonNode;
+
+public record SearchCapabilities(
+ FilterSource filters,
+ SortSource sorts,
+ @JsonAnySetter @JsonAnyGetter Map additional) {
+ public SearchCapabilities {
+ additional = Copies.nodes(additional);
+ }
+
+ public record Link(String href) {}
+
+ public record FilterSource(List inline, Link linked) {
+ public FilterSource {
+ inline = Copies.list(inline);
+ }
+ }
+
+ public record SortSource(List inline, Link linked) {
+ public SortSource {
+ inline = Copies.list(inline);
+ }
+ }
+
+ public record FilterDefinition(
+ String id,
+ String title,
+ String description,
+ String type,
+ List operators,
+ FilterUnit unit,
+ Boolean refinable,
+ @JsonAnySetter @JsonAnyGetter Map additional) {
+ public FilterDefinition {
+ operators = List.copyOf(operators);
+ additional = Copies.nodes(additional);
+ }
+ }
+
+ public record FilterUnit(
+ String system,
+ String code,
+ String title,
+ @JsonAnySetter @JsonAnyGetter Map additional) {
+ public FilterUnit {
+ additional = Copies.nodes(additional);
+ }
+ }
+
+ public record SortDefinition(
+ String id,
+ String title,
+ String description,
+ List keys,
+ @JsonAnySetter @JsonAnyGetter Map additional) {
+ public SortDefinition {
+ keys = List.copyOf(keys);
+ additional = Copies.nodes(additional);
+ }
+ }
+
+ public record SortKey(
+ @JsonProperty("filter_id") String filterId,
+ String direction,
+ String missing,
+ @JsonAnySetter @JsonAnyGetter Map additional) {
+ public SortKey {
+ additional = Copies.nodes(additional);
+ }
+ }
+
+ public record FilterExpression(
+ String id,
+ String operator,
+ JsonNode value,
+ @JsonAnySetter @JsonAnyGetter Map additional) {
+ public FilterExpression {
+ value = value.deepCopy();
+ additional = Copies.nodes(additional);
+ }
+ }
+}
diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/SearchRequests.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/SearchRequests.java
new file mode 100644
index 0000000..3cd4983
--- /dev/null
+++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/SearchRequests.java
@@ -0,0 +1,27 @@
+package org.offeringprotocol.odp.core;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.List;
+
+public interface SearchRequests {
+ public record Collections(
+ @JsonProperty("odp_version") String odpVersion,
+ String query,
+ @JsonProperty("parent_id") String parentId,
+ Integer limit) {}
+
+ public record Offerings(
+ @JsonProperty("odp_version") String odpVersion,
+ String query,
+ List filters,
+ @JsonProperty("collection_id") String collectionId,
+ @JsonProperty("include_descendants") Boolean includeDescendants,
+ String sort,
+ List refinements,
+ Integer limit) {
+ public Offerings {
+ filters = Copies.list(filters);
+ refinements = Copies.list(refinements);
+ }
+ }
+}
diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/ServiceDocument.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/ServiceDocument.java
new file mode 100644
index 0000000..aef0641
--- /dev/null
+++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/ServiceDocument.java
@@ -0,0 +1,63 @@
+package org.offeringprotocol.odp.core;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.List;
+import java.util.Map;
+import tools.jackson.databind.JsonNode;
+
+public record ServiceDocument(
+ @JsonProperty("odp_version") String odpVersion,
+ String name,
+ String description,
+ @JsonProperty("documentation_url") String documentationUrl,
+ String language,
+ List localizations,
+ List mcp,
+ List keywords,
+ Branding branding,
+ List operations,
+ Http http,
+ Protocols protocols,
+ @JsonProperty("payment_origins") List paymentOrigins,
+ @JsonProperty("search_capabilities") SearchCapabilities searchCapabilities,
+ @JsonProperty("status_url") String statusUrl,
+ @JsonProperty("support_url") String supportUrl,
+ @JsonProperty("website_url") String websiteUrl,
+ @JsonAnySetter @JsonAnyGetter Map additional) {
+
+ public ServiceDocument {
+ localizations = Copies.list(localizations);
+ mcp = Copies.list(mcp);
+ keywords = Copies.list(keywords);
+ operations = Copies.list(operations);
+ paymentOrigins = Copies.list(paymentOrigins);
+ additional = Copies.nodes(additional);
+ }
+
+ public record Http(@JsonProperty("endpoint_base") String endpointBase, OpenApi openapi) {}
+
+ public record OpenApi(String url) {}
+
+ public record Branding(BrandingImage icon, BrandingImage logo) {}
+
+ public record BrandingImage(String src, String type) {}
+
+ public record McpEndpoint(String description, String name, String type, String url) {}
+
+ public record Protocols(List enrollment, List payments) {
+ public Protocols {
+ enrollment = Copies.list(enrollment);
+ payments = Copies.list(payments);
+ }
+ }
+
+ public record EnrollmentProtocol(String name) {}
+
+ public record PaymentProtocol(AuthenticationRequirement authentication, String name, List options) {
+ public PaymentProtocol {
+ options = Copies.list(options);
+ }
+ }
+}
diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/ValidationIssue.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/ValidationIssue.java
new file mode 100644
index 0000000..cebc3f5
--- /dev/null
+++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/ValidationIssue.java
@@ -0,0 +1,13 @@
+package org.offeringprotocol.odp.core;
+
+import java.io.Serializable;
+import java.util.Map;
+
+public record ValidationIssue(String keyword, String message, Map params, String path)
+ implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ public ValidationIssue {
+ params = Copies.map(params);
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/action-relation.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/action-relation.schema.json
new file mode 100644
index 0000000..890b09f
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/action-relation.schema.json
@@ -0,0 +1,9 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/action-relation.schema.json",
+ "title": "ODP Action relation",
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 64,
+ "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$"
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/action-request.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/action-request.schema.json
new file mode 100644
index 0000000..10548c5
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/action-request.schema.json
@@ -0,0 +1,18 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/action-request.schema.json",
+ "title": "ODP compact Action request body",
+ "type": "object",
+ "additionalProperties": false,
+ "minProperties": 1,
+ "properties": {
+ "content_type": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255
+ },
+ "schema": {
+ "$ref": "schema-reference.schema.json"
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/action.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/action.schema.json
new file mode 100644
index 0000000..8dcc132
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/action.schema.json
@@ -0,0 +1,56 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/action.schema.json",
+ "title": "ODP Offering Action",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "authentication",
+ "id",
+ "rel"
+ ],
+ "properties": {
+ "authentication": {
+ "$ref": "authentication-requirement.schema.json"
+ },
+ "id": {
+ "$ref": "local-resource-identifier.schema.json"
+ },
+ "rel": {
+ "$ref": "action-relation.schema.json"
+ },
+ "description": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 512
+ },
+ "http": {
+ "$ref": "http-action-target.schema.json"
+ },
+ "openapi": {
+ "$ref": "openapi-action-target.schema.json"
+ }
+ },
+ "oneOf": [
+ {
+ "required": [
+ "http"
+ ],
+ "not": {
+ "required": [
+ "openapi"
+ ]
+ }
+ },
+ {
+ "required": [
+ "openapi"
+ ],
+ "not": {
+ "required": [
+ "http"
+ ]
+ }
+ }
+ ]
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/attribute-schema-reference.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/attribute-schema-reference.schema.json
new file mode 100644
index 0000000..0520ca7
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/attribute-schema-reference.schema.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/attribute-schema-reference.schema.json",
+ "title": "ODP Attribute Schema reference",
+ "$ref": "schema-reference.schema.json"
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/authentication-requirement.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/authentication-requirement.schema.json
new file mode 100644
index 0000000..cfd2ad0
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/authentication-requirement.schema.json
@@ -0,0 +1,11 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/authentication-requirement.schema.json",
+ "title": "ODP Authentication Requirement",
+ "type": "string",
+ "enum": [
+ "not-required",
+ "optional",
+ "required"
+ ]
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/capability-identifier.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/capability-identifier.schema.json
new file mode 100644
index 0000000..bc37d16
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/capability-identifier.schema.json
@@ -0,0 +1,9 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/capability-identifier.schema.json",
+ "title": "ODP Service-local capability identifier",
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 64,
+ "pattern": "^[A-Za-z0-9._~-]+$"
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/capability-link.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/capability-link.schema.json
new file mode 100644
index 0000000..86e179a
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/capability-link.schema.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/capability-link.schema.json",
+ "title": "ODP linked capability source",
+ "type": "object",
+ "required": [
+ "href"
+ ],
+ "properties": {
+ "href": {
+ "$ref": "resource-reference.schema.json"
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/collection-search-request.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/collection-search-request.schema.json
new file mode 100644
index 0000000..78d268b
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/collection-search-request.schema.json
@@ -0,0 +1,45 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/collection-search-request.schema.json",
+ "title": "ODP Collection search request",
+ "type": "object",
+ "required": [
+ "odp_version"
+ ],
+ "anyOf": [
+ {
+ "required": [
+ "query"
+ ]
+ },
+ {
+ "required": [
+ "parent_id"
+ ]
+ }
+ ],
+ "properties": {
+ "odp_version": {
+ "const": "1.0"
+ },
+ "query": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 256,
+ "pattern": "\\S"
+ },
+ "parent_id": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "local-resource-identifier.schema.json"
+ }
+ ]
+ },
+ "limit": {
+ "$ref": "page-limit.schema.json"
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/collection.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/collection.schema.json
new file mode 100644
index 0000000..0a14238
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/collection.schema.json
@@ -0,0 +1,72 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/collection.schema.json",
+ "title": "ODP Full Collection Representation",
+ "type": "object",
+ "required": [
+ "odp_version",
+ "id",
+ "name"
+ ],
+ "properties": {
+ "auth_expands": {
+ "const": true
+ },
+ "odp_version": {
+ "const": "1.0"
+ },
+ "id": {
+ "$ref": "local-resource-identifier.schema.json"
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128
+ },
+ "description": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024
+ },
+ "images": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 16,
+ "items": {
+ "$ref": "resource-image.schema.json"
+ }
+ },
+ "language": {
+ "type": "string",
+ "minLength": 2,
+ "maxLength": 35
+ },
+ "localizations": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 16,
+ "uniqueItems": true,
+ "items": {
+ "type": "string",
+ "minLength": 2,
+ "maxLength": 35
+ }
+ },
+ "parent_ids": {
+ "allOf": [
+ {
+ "$ref": "local-resource-identifier-list.schema.json"
+ },
+ {
+ "minItems": 1
+ }
+ ]
+ },
+ "web_url": {
+ "$ref": "resource-reference.schema.json"
+ },
+ "search_capabilities": {
+ "$ref": "search-capabilities.schema.json"
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/detail-fields.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/detail-fields.schema.json
new file mode 100644
index 0000000..13478c7
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/detail-fields.schema.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/detail-fields.schema.json",
+ "title": "ODP detail fields",
+ "description": "An exhaustive bounded list of fields present in a Full Representation and omitted from a Terse Representation.",
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 32,
+ "uniqueItems": true,
+ "items": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 256,
+ "pattern": "^(?:/(?:[\\x20-\\x2E\\x30-\\x7D]|~[01])*)+$"
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/enrollment-protocol.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/enrollment-protocol.schema.json
new file mode 100644
index 0000000..da2e08e
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/enrollment-protocol.schema.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/enrollment-protocol.schema.json",
+ "title": "ODP Enrollment Protocol Descriptor",
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "properties": {
+ "name": {
+ "const": "aep"
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-capability-source.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-capability-source.schema.json
new file mode 100644
index 0000000..0d8db9e
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-capability-source.schema.json
@@ -0,0 +1,41 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/filter-capability-source.schema.json",
+ "title": "ODP Filter Definition source",
+ "type": "object",
+ "oneOf": [
+ {
+ "required": [
+ "inline"
+ ],
+ "not": {
+ "required": [
+ "linked"
+ ]
+ }
+ },
+ {
+ "required": [
+ "linked"
+ ],
+ "not": {
+ "required": [
+ "inline"
+ ]
+ }
+ }
+ ],
+ "properties": {
+ "inline": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 32,
+ "items": {
+ "$ref": "filter-definition.schema.json"
+ }
+ },
+ "linked": {
+ "$ref": "capability-link.schema.json"
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-definition-page.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-definition-page.schema.json
new file mode 100644
index 0000000..389f8f2
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-definition-page.schema.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/filter-definition-page.schema.json",
+ "title": "ODP Filter Definition page",
+ "type": "object",
+ "required": [
+ "odp_version",
+ "items"
+ ],
+ "properties": {
+ "odp_version": {
+ "const": "1.0"
+ },
+ "items": {
+ "type": "array",
+ "maxItems": 100,
+ "items": {
+ "$ref": "filter-definition.schema.json"
+ }
+ },
+ "next": {
+ "$ref": "resource-reference.schema.json"
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-definition.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-definition.schema.json
new file mode 100644
index 0000000..399e5d5
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-definition.schema.json
@@ -0,0 +1,63 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/filter-definition.schema.json",
+ "title": "ODP Filter Definition",
+ "type": "object",
+ "required": [
+ "id",
+ "title",
+ "description",
+ "type",
+ "operators"
+ ],
+ "properties": {
+ "id": {
+ "$ref": "capability-identifier.schema.json"
+ },
+ "title": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128
+ },
+ "description": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024
+ },
+ "type": {
+ "$ref": "filter-type.schema.json"
+ },
+ "operators": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 7,
+ "uniqueItems": true,
+ "items": {
+ "$ref": "filter-operator.schema.json"
+ }
+ },
+ "unit": {
+ "$ref": "filter-unit.schema.json"
+ },
+ "refinable": {
+ "const": true
+ }
+ },
+ "if": {
+ "required": [
+ "refinable"
+ ]
+ },
+ "then": {
+ "properties": {
+ "operators": {
+ "contains": {
+ "enum": [
+ "eq",
+ "in"
+ ]
+ }
+ }
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-expression.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-expression.schema.json
new file mode 100644
index 0000000..b2f3274
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-expression.schema.json
@@ -0,0 +1,45 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/filter-expression.schema.json",
+ "title": "ODP Filter Expression",
+ "type": "object",
+ "required": [
+ "id",
+ "operator",
+ "value"
+ ],
+ "properties": {
+ "id": {
+ "$ref": "capability-identifier.schema.json"
+ },
+ "operator": {
+ "$ref": "filter-operator.schema.json"
+ },
+ "value": {
+ "oneOf": [
+ {
+ "type": [
+ "string",
+ "boolean",
+ "integer",
+ "number"
+ ]
+ },
+ {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 100,
+ "uniqueItems": true,
+ "items": {
+ "type": [
+ "string",
+ "boolean",
+ "integer",
+ "number"
+ ]
+ }
+ }
+ ]
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-operator.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-operator.schema.json
new file mode 100644
index 0000000..06402dd
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-operator.schema.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/filter-operator.schema.json",
+ "title": "ODP filter operator",
+ "enum": [
+ "eq",
+ "in",
+ "lt",
+ "lte",
+ "gt",
+ "gte",
+ "exists"
+ ]
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-type.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-type.schema.json
new file mode 100644
index 0000000..502647e
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-type.schema.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/filter-type.schema.json",
+ "title": "ODP filter value type",
+ "enum": [
+ "string",
+ "boolean",
+ "integer",
+ "number",
+ "decimal",
+ "date",
+ "date-time"
+ ]
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-unit.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-unit.schema.json
new file mode 100644
index 0000000..4794c58
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-unit.schema.json
@@ -0,0 +1,45 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/filter-unit.schema.json",
+ "title": "ODP filter unit",
+ "oneOf": [
+ {
+ "type": "object",
+ "required": [
+ "system",
+ "code"
+ ],
+ "properties": {
+ "system": {
+ "const": "ucum"
+ },
+ "code": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 64
+ }
+ }
+ },
+ {
+ "type": "object",
+ "required": [
+ "system",
+ "code",
+ "title"
+ ],
+ "properties": {
+ "system": {
+ "const": "service"
+ },
+ "code": {
+ "$ref": "capability-identifier.schema.json"
+ },
+ "title": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128
+ }
+ }
+ }
+ ]
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/http-action-target.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/http-action-target.schema.json
new file mode 100644
index 0000000..73c7340
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/http-action-target.schema.json
@@ -0,0 +1,36 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/http-action-target.schema.json",
+ "title": "ODP compact HTTP Action target",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "href",
+ "method"
+ ],
+ "properties": {
+ "href": {
+ "$ref": "resource-reference.schema.json"
+ },
+ "method": {
+ "enum": [
+ "GET",
+ "POST"
+ ]
+ },
+ "request": {
+ "$ref": "action-request.schema.json"
+ },
+ "response_content_types": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 8,
+ "uniqueItems": true,
+ "items": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255
+ }
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/index.txt b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/index.txt
new file mode 100644
index 0000000..4f36528
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/index.txt
@@ -0,0 +1,55 @@
+action-relation.schema.json
+action-request.schema.json
+action.schema.json
+attribute-schema-reference.schema.json
+authentication-requirement.schema.json
+capability-identifier.schema.json
+capability-link.schema.json
+collection-search-request.schema.json
+collection.schema.json
+detail-fields.schema.json
+enrollment-protocol.schema.json
+filter-capability-source.schema.json
+filter-definition-page.schema.json
+filter-definition.schema.json
+filter-expression.schema.json
+filter-operator.schema.json
+filter-type.schema.json
+filter-unit.schema.json
+http-action-target.schema.json
+invalid-parameter.schema.json
+local-resource-identifier-list.schema.json
+local-resource-identifier.schema.json
+mcp-endpoint.schema.json
+offering-search-request.schema.json
+offering-search-response.schema.json
+offering.schema.json
+openapi-action-target.schema.json
+operation-descriptor.schema.json
+page-envelope.schema.json
+page-limit.schema.json
+payment-option.schema.json
+payment-protocol.schema.json
+price-preview.schema.json
+problem-code.schema.json
+problem-details.schema.json
+protocol-version.schema.json
+refinement-bucket.schema.json
+refinement-group.schema.json
+representation.schema.json
+resource-identity.schema.json
+resource-image.schema.json
+resource-reference.schema.json
+schema-reference.schema.json
+search-capabilities.schema.json
+service-branding-image.schema.json
+service-branding.schema.json
+service-document.schema.json
+service-openapi.schema.json
+service-origin.schema.json
+service-protocols.schema.json
+sort-capability-source.schema.json
+sort-definition-page.schema.json
+sort-definition.schema.json
+sort-key.schema.json
+top-level-document.schema.json
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/invalid-parameter.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/invalid-parameter.schema.json
new file mode 100644
index 0000000..f03c733
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/invalid-parameter.schema.json
@@ -0,0 +1,55 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/invalid-parameter.schema.json",
+ "title": "ODP invalid parameter",
+ "type": "object",
+ "required": [
+ "in",
+ "name",
+ "reason"
+ ],
+ "properties": {
+ "in": {
+ "enum": [
+ "query",
+ "body",
+ "header",
+ "path"
+ ]
+ },
+ "name": {
+ "type": "string",
+ "maxLength": 256
+ },
+ "reason": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024
+ }
+ },
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "in": {
+ "const": "body"
+ }
+ }
+ },
+ "then": {
+ "properties": {
+ "name": {
+ "pattern": "^(?:|/(?:[^~/]|~[01])*)*$"
+ }
+ }
+ },
+ "else": {
+ "properties": {
+ "name": {
+ "minLength": 1
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/local-resource-identifier-list.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/local-resource-identifier-list.schema.json
new file mode 100644
index 0000000..5304d7e
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/local-resource-identifier-list.schema.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/local-resource-identifier-list.schema.json",
+ "title": "ODP local resource identifier list",
+ "type": "array",
+ "uniqueItems": true,
+ "items": {
+ "$ref": "local-resource-identifier.schema.json"
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/local-resource-identifier.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/local-resource-identifier.schema.json
new file mode 100644
index 0000000..f32b757
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/local-resource-identifier.schema.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/local-resource-identifier.schema.json",
+ "title": "ODP local resource identifier",
+ "description": "A path-safe Service-created identifier scoped by Service origin and resource type.",
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128,
+ "pattern": "^(?!\\.{1,2}$)[A-Za-z0-9._~-]+$"
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/mcp-endpoint.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/mcp-endpoint.schema.json
new file mode 100644
index 0000000..5712ca3
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/mcp-endpoint.schema.json
@@ -0,0 +1,29 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/mcp-endpoint.schema.json",
+ "title": "ODP MCP Endpoint Descriptor",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "type",
+ "url"
+ ],
+ "properties": {
+ "description": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128
+ },
+ "type": {
+ "const": "streamable-http"
+ },
+ "url": {
+ "$ref": "resource-reference.schema.json"
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/offering-search-request.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/offering-search-request.schema.json
new file mode 100644
index 0000000..fcdc4e1
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/offering-search-request.schema.json
@@ -0,0 +1,68 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/offering-search-request.schema.json",
+ "title": "ODP Offering search request",
+ "type": "object",
+ "required": [
+ "odp_version"
+ ],
+ "anyOf": [
+ {
+ "required": [
+ "query"
+ ]
+ },
+ {
+ "required": [
+ "filters"
+ ]
+ }
+ ],
+ "dependentRequired": {
+ "include_descendants": [
+ "collection_id"
+ ]
+ },
+ "properties": {
+ "odp_version": {
+ "const": "1.0"
+ },
+ "query": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 256,
+ "pattern": "\\S"
+ },
+ "filters": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 32,
+ "uniqueItems": true,
+ "items": {
+ "$ref": "filter-expression.schema.json"
+ }
+ },
+ "collection_id": {
+ "$ref": "local-resource-identifier.schema.json"
+ },
+ "include_descendants": {
+ "type": "boolean",
+ "default": false
+ },
+ "sort": {
+ "$ref": "capability-identifier.schema.json"
+ },
+ "refinements": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 16,
+ "uniqueItems": true,
+ "items": {
+ "$ref": "capability-identifier.schema.json"
+ }
+ },
+ "limit": {
+ "$ref": "page-limit.schema.json"
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/offering-search-response.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/offering-search-response.schema.json
new file mode 100644
index 0000000..1ed3604
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/offering-search-response.schema.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/offering-search-response.schema.json",
+ "title": "ODP Offering search response",
+ "allOf": [
+ {
+ "$ref": "page-envelope.schema.json"
+ }
+ ],
+ "properties": {
+ "refinements": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 16,
+ "items": {
+ "$ref": "refinement-group.schema.json"
+ }
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/offering.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/offering.schema.json
new file mode 100644
index 0000000..e271997
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/offering.schema.json
@@ -0,0 +1,92 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/offering.schema.json",
+ "title": "ODP Full Offering Representation",
+ "type": "object",
+ "required": [
+ "odp_version",
+ "id",
+ "name"
+ ],
+ "properties": {
+ "auth_expands": {
+ "const": true
+ },
+ "odp_version": {
+ "const": "1.0"
+ },
+ "id": {
+ "$ref": "local-resource-identifier.schema.json"
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128
+ },
+ "description": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024
+ },
+ "images": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 16,
+ "items": {
+ "$ref": "resource-image.schema.json"
+ }
+ },
+ "language": {
+ "type": "string",
+ "minLength": 2,
+ "maxLength": 35
+ },
+ "localizations": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 16,
+ "uniqueItems": true,
+ "items": {
+ "type": "string",
+ "minLength": 2,
+ "maxLength": 35
+ }
+ },
+ "web_url": {
+ "$ref": "resource-reference.schema.json"
+ },
+ "collection_ids": {
+ "allOf": [
+ {
+ "$ref": "local-resource-identifier-list.schema.json"
+ },
+ {
+ "minItems": 1
+ }
+ ]
+ },
+ "price": {
+ "$ref": "price-preview.schema.json"
+ },
+ "schema": {
+ "$ref": "attribute-schema-reference.schema.json"
+ },
+ "attributes": {
+ "type": "object",
+ "minProperties": 1
+ },
+ "actions": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 16,
+ "items": {
+ "$ref": "action.schema.json"
+ }
+ }
+ },
+ "dependentRequired": {
+ "attributes": [
+ "schema"
+ ]
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/openapi-action-target.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/openapi-action-target.schema.json
new file mode 100644
index 0000000..12ceb8e
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/openapi-action-target.schema.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/openapi-action-target.schema.json",
+ "title": "ODP OpenAPI Action target",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "operation_id"
+ ],
+ "properties": {
+ "url": {
+ "$ref": "resource-reference.schema.json"
+ },
+ "operation_id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/operation-descriptor.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/operation-descriptor.schema.json
new file mode 100644
index 0000000..a1bb2ba
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/operation-descriptor.schema.json
@@ -0,0 +1,27 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/operation-descriptor.schema.json",
+ "title": "ODP Operation Descriptor",
+ "type": "object",
+ "required": [
+ "authentication",
+ "name"
+ ],
+ "properties": {
+ "authentication": {
+ "$ref": "authentication-requirement.schema.json"
+ },
+ "name": {
+ "enum": [
+ "get-collection",
+ "get-offering",
+ "list-collection-offerings",
+ "list-collections",
+ "list-offerings",
+ "search-collections",
+ "search-offerings"
+ ]
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/page-envelope.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/page-envelope.schema.json
new file mode 100644
index 0000000..81ac24b
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/page-envelope.schema.json
@@ -0,0 +1,24 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/page-envelope.schema.json",
+ "title": "ODP page envelope",
+ "type": "object",
+ "required": [
+ "odp_version",
+ "items"
+ ],
+ "properties": {
+ "auth_expands": {
+ "const": true
+ },
+ "odp_version": {
+ "const": "1.0"
+ },
+ "items": {
+ "type": "array"
+ },
+ "next": {
+ "$ref": "resource-reference.schema.json"
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/page-limit.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/page-limit.schema.json
new file mode 100644
index 0000000..d4951d8
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/page-limit.schema.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/page-limit.schema.json",
+ "title": "ODP requested page limit",
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/payment-option.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/payment-option.schema.json
new file mode 100644
index 0000000..ae1548a
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/payment-option.schema.json
@@ -0,0 +1,24 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/payment-option.schema.json",
+ "title": "ODP Payment Option",
+ "type": "string",
+ "enum": [
+ "algorand",
+ "aptos",
+ "arbitrum",
+ "avalanche",
+ "base",
+ "card",
+ "ethereum",
+ "hedera",
+ "inflow",
+ "lightning",
+ "polygon",
+ "solana",
+ "stellar",
+ "stripe",
+ "tempo",
+ "ton"
+ ]
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/payment-protocol.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/payment-protocol.schema.json
new file mode 100644
index 0000000..cdcf349
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/payment-protocol.schema.json
@@ -0,0 +1,34 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/payment-protocol.schema.json",
+ "title": "ODP Payment Protocol Descriptor",
+ "type": "object",
+ "required": [
+ "authentication",
+ "name"
+ ],
+ "properties": {
+ "authentication": {
+ "enum": [
+ "not-required",
+ "required"
+ ]
+ },
+ "name": {
+ "enum": [
+ "mpp",
+ "x402"
+ ]
+ },
+ "options": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 16,
+ "uniqueItems": true,
+ "items": {
+ "$ref": "payment-option.schema.json"
+ }
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/price-preview.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/price-preview.schema.json
new file mode 100644
index 0000000..f55a6b6
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/price-preview.schema.json
@@ -0,0 +1,133 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/price-preview.schema.json",
+ "title": "ODP Price Preview",
+ "$defs": {
+ "decimal": {
+ "type": "string",
+ "pattern": "^(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?$"
+ },
+ "currency": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 64
+ }
+ },
+ "oneOf": [
+ {
+ "type": "object",
+ "required": [
+ "type"
+ ],
+ "properties": {
+ "type": {
+ "const": "free"
+ }
+ }
+ },
+ {
+ "type": "object",
+ "required": [
+ "type",
+ "amount",
+ "currency"
+ ],
+ "properties": {
+ "type": {
+ "enum": [
+ "fixed",
+ "starting_at"
+ ]
+ },
+ "amount": {
+ "$ref": "#/$defs/decimal"
+ },
+ "currency": {
+ "$ref": "#/$defs/currency"
+ }
+ }
+ },
+ {
+ "type": "object",
+ "required": [
+ "type",
+ "minimum",
+ "maximum",
+ "currency"
+ ],
+ "properties": {
+ "type": {
+ "const": "range"
+ },
+ "minimum": {
+ "$ref": "#/$defs/decimal"
+ },
+ "maximum": {
+ "$ref": "#/$defs/decimal"
+ },
+ "currency": {
+ "$ref": "#/$defs/currency"
+ }
+ }
+ },
+ {
+ "type": "object",
+ "required": [
+ "type",
+ "amount",
+ "currency",
+ "unit"
+ ],
+ "properties": {
+ "type": {
+ "const": "metered"
+ },
+ "amount": {
+ "$ref": "#/$defs/decimal"
+ },
+ "currency": {
+ "$ref": "#/$defs/currency"
+ },
+ "unit": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128
+ }
+ }
+ },
+ {
+ "type": "object",
+ "required": [
+ "type"
+ ],
+ "properties": {
+ "type": {
+ "const": "quote"
+ }
+ }
+ },
+ {
+ "type": "object",
+ "required": [
+ "type"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 64,
+ "not": {
+ "enum": [
+ "free",
+ "fixed",
+ "range",
+ "starting_at",
+ "metered",
+ "quote"
+ ]
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/problem-code.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/problem-code.schema.json
new file mode 100644
index 0000000..d0a79fe
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/problem-code.schema.json
@@ -0,0 +1,9 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/problem-code.schema.json",
+ "title": "ODP problem code",
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 64,
+ "pattern": "^[A-Z][A-Z0-9_]*$"
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/problem-details.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/problem-details.schema.json
new file mode 100644
index 0000000..277f5ab
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/problem-details.schema.json
@@ -0,0 +1,66 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/problem-details.schema.json",
+ "title": "ODP Problem Details",
+ "type": "object",
+ "required": [
+ "type",
+ "title",
+ "status",
+ "code"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "format": "uri",
+ "pattern": "^https://offeringprotocol\\.org/problems/[a-z0-9][a-z0-9-]*$"
+ },
+ "title": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128
+ },
+ "status": {
+ "type": "integer",
+ "minimum": 400,
+ "maximum": 599
+ },
+ "code": {
+ "$ref": "problem-code.schema.json"
+ },
+ "detail": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 2048
+ },
+ "instance": {
+ "type": "string",
+ "format": "uri-reference",
+ "maxLength": 2048
+ },
+ "invalid_params": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 32,
+ "items": {
+ "$ref": "invalid-parameter.schema.json"
+ }
+ }
+ },
+ "allOf": [
+ {
+ "if": {
+ "required": [
+ "invalid_params"
+ ]
+ },
+ "then": {
+ "properties": {
+ "code": {
+ "const": "INVALID_REQUEST"
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/protocol-version.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/protocol-version.schema.json
new file mode 100644
index 0000000..77b941b
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/protocol-version.schema.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/protocol-version.schema.json",
+ "title": "ODP protocol version",
+ "description": "The major and minor version governing one top-level ODP document.",
+ "type": "string",
+ "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$"
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/refinement-bucket.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/refinement-bucket.schema.json
new file mode 100644
index 0000000..810ec12
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/refinement-bucket.schema.json
@@ -0,0 +1,28 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/refinement-bucket.schema.json",
+ "title": "ODP Refinement Bucket",
+ "type": "object",
+ "required": [
+ "value",
+ "count"
+ ],
+ "properties": {
+ "value": {
+ "type": [
+ "string",
+ "boolean",
+ "integer",
+ "number"
+ ]
+ },
+ "count": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 9007199254740991
+ },
+ "count_relation": {
+ "const": "lower_bound"
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/refinement-group.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/refinement-group.schema.json
new file mode 100644
index 0000000..042b441
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/refinement-group.schema.json
@@ -0,0 +1,24 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/refinement-group.schema.json",
+ "title": "ODP Refinement Group",
+ "type": "object",
+ "required": [
+ "filter_id",
+ "values"
+ ],
+ "properties": {
+ "filter_id": {
+ "$ref": "capability-identifier.schema.json"
+ },
+ "values": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 32,
+ "uniqueItems": true,
+ "items": {
+ "$ref": "refinement-bucket.schema.json"
+ }
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/representation.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/representation.schema.json
new file mode 100644
index 0000000..31807ec
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/representation.schema.json
@@ -0,0 +1,11 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/representation.schema.json",
+ "title": "ODP representation selection",
+ "description": "The requested completeness level for Collection or Offering representations.",
+ "type": "string",
+ "enum": [
+ "terse",
+ "full"
+ ]
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/resource-identity.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/resource-identity.schema.json
new file mode 100644
index 0000000..61aa759
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/resource-identity.schema.json
@@ -0,0 +1,27 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/resource-identity.schema.json",
+ "title": "ODP resource identity",
+ "description": "The structured identity composed by an Agent from Service origin, resource type, and local identifier.",
+ "type": "object",
+ "required": [
+ "service",
+ "type",
+ "id"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "service": {
+ "$ref": "service-origin.schema.json"
+ },
+ "type": {
+ "enum": [
+ "collection",
+ "offering"
+ ]
+ },
+ "id": {
+ "$ref": "local-resource-identifier.schema.json"
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/resource-image.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/resource-image.schema.json
new file mode 100644
index 0000000..e2a83aa
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/resource-image.schema.json
@@ -0,0 +1,40 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/resource-image.schema.json",
+ "title": "ODP Resource image",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "src"
+ ],
+ "properties": {
+ "alt": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024
+ },
+ "height": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 65535
+ },
+ "src": {
+ "$ref": "resource-reference.schema.json"
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "image/avif",
+ "image/jpeg",
+ "image/png",
+ "image/svg+xml",
+ "image/webp"
+ ]
+ },
+ "width": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 65535
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/resource-reference.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/resource-reference.schema.json
new file mode 100644
index 0000000..540963e
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/resource-reference.schema.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/resource-reference.schema.json",
+ "title": "ODP resource reference",
+ "description": "An origin-relative absolute-path reference or an absolute HTTPS URL, with loopback HTTP allowed for development.",
+ "type": "string",
+ "format": "uri-reference",
+ "anyOf": [
+ {
+ "pattern": "^/(?!/)[^#]*$"
+ },
+ {
+ "pattern": "^https://(?![^/?#]*@)[^#]+$"
+ },
+ {
+ "pattern": "^http://(?:localhost|127\\.0\\.0\\.1|\\[::1\\])(?::[0-9]+)?(?:/[^#]*)?$"
+ }
+ ]
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/schema-reference.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/schema-reference.schema.json
new file mode 100644
index 0000000..1f0bc45
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/schema-reference.schema.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/schema-reference.schema.json",
+ "title": "ODP JSON Schema reference",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "url"
+ ],
+ "properties": {
+ "url": {
+ "$ref": "resource-reference.schema.json"
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/search-capabilities.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/search-capabilities.schema.json
new file mode 100644
index 0000000..dc53a37
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/search-capabilities.schema.json
@@ -0,0 +1,26 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/search-capabilities.schema.json",
+ "title": "ODP search capability advertisement",
+ "type": "object",
+ "anyOf": [
+ {
+ "required": [
+ "filters"
+ ]
+ },
+ {
+ "required": [
+ "sorts"
+ ]
+ }
+ ],
+ "properties": {
+ "filters": {
+ "$ref": "filter-capability-source.schema.json"
+ },
+ "sorts": {
+ "$ref": "sort-capability-source.schema.json"
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-branding-image.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-branding-image.schema.json
new file mode 100644
index 0000000..afe83ef
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-branding-image.schema.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/service-branding-image.schema.json",
+ "title": "ODP Service branding image",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "src"
+ ],
+ "properties": {
+ "src": {
+ "$ref": "resource-reference.schema.json"
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "image/png",
+ "image/svg+xml",
+ "image/webp"
+ ]
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-branding.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-branding.schema.json
new file mode 100644
index 0000000..012c15e
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-branding.schema.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/service-branding.schema.json",
+ "title": "ODP Service branding",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "icon",
+ "logo"
+ ],
+ "properties": {
+ "icon": {
+ "$ref": "service-branding-image.schema.json"
+ },
+ "logo": {
+ "$ref": "service-branding-image.schema.json"
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-document.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-document.schema.json
new file mode 100644
index 0000000..e20b4a9
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-document.schema.json
@@ -0,0 +1,337 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/service-document.schema.json",
+ "title": "ODP Service Document",
+ "type": "object",
+ "required": [
+ "odp_version",
+ "name",
+ "description",
+ "language",
+ "localizations",
+ "operations",
+ "http"
+ ],
+ "properties": {
+ "odp_version": {
+ "const": "1.0"
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128
+ },
+ "description": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024
+ },
+ "documentation_url": {
+ "$ref": "resource-reference.schema.json"
+ },
+ "language": {
+ "type": "string",
+ "minLength": 2,
+ "maxLength": 35
+ },
+ "localizations": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 16,
+ "uniqueItems": true,
+ "items": {
+ "type": "string",
+ "minLength": 2,
+ "maxLength": 35
+ }
+ },
+ "mcp": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "$ref": "mcp-endpoint.schema.json"
+ }
+ },
+ "keywords": {
+ "type": "array",
+ "maxItems": 32,
+ "uniqueItems": true,
+ "items": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 64,
+ "pattern": "^\\S(?:[\\s\\S]*\\S)?$"
+ }
+ },
+ "branding": {
+ "$ref": "service-branding.schema.json"
+ },
+ "operations": {
+ "type": "array",
+ "minItems": 2,
+ "maxItems": 7,
+ "allOf": [
+ {
+ "contains": {
+ "properties": {
+ "name": {
+ "const": "list-offerings"
+ }
+ },
+ "required": [
+ "name"
+ ]
+ }
+ },
+ {
+ "contains": {
+ "properties": {
+ "name": {
+ "const": "get-offering"
+ }
+ },
+ "required": [
+ "name"
+ ]
+ }
+ }
+ ],
+ "items": {
+ "$ref": "operation-descriptor.schema.json"
+ }
+ },
+ "http": {
+ "type": "object",
+ "required": [
+ "endpoint_base"
+ ],
+ "properties": {
+ "endpoint_base": {
+ "type": "string",
+ "maxLength": 2048,
+ "pattern": "^/(?!/)[A-Za-z0-9._~!$&'()*+,;=:@%/-]*$"
+ },
+ "openapi": {
+ "$ref": "service-openapi.schema.json"
+ }
+ }
+ },
+ "protocols": {
+ "$ref": "service-protocols.schema.json"
+ },
+ "payment_origins": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 16,
+ "items": {
+ "$ref": "service-origin.schema.json"
+ }
+ },
+ "search_capabilities": {
+ "$ref": "search-capabilities.schema.json"
+ },
+ "status_url": {
+ "$ref": "resource-reference.schema.json"
+ },
+ "support_url": {
+ "$ref": "resource-reference.schema.json"
+ },
+ "website_url": {
+ "$ref": "resource-reference.schema.json"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/unique-operation-names"
+ },
+ {
+ "if": {
+ "properties": {
+ "operations": {
+ "contains": {
+ "properties": {
+ "authentication": {
+ "enum": [
+ "optional",
+ "required"
+ ]
+ }
+ },
+ "required": [
+ "authentication"
+ ]
+ }
+ }
+ },
+ "required": [
+ "operations"
+ ]
+ },
+ "then": {
+ "properties": {
+ "protocols": {
+ "required": [
+ "enrollment"
+ ]
+ }
+ },
+ "required": [
+ "protocols"
+ ]
+ }
+ }
+ ],
+ "$defs": {
+ "unique-operation-names": {
+ "allOf": [
+ {
+ "$ref": "#/$defs/at-most-one-get-collection"
+ },
+ {
+ "$ref": "#/$defs/at-most-one-get-offering"
+ },
+ {
+ "$ref": "#/$defs/at-most-one-list-collection-offerings"
+ },
+ {
+ "$ref": "#/$defs/at-most-one-list-collections"
+ },
+ {
+ "$ref": "#/$defs/at-most-one-list-offerings"
+ },
+ {
+ "$ref": "#/$defs/at-most-one-search-collections"
+ },
+ {
+ "$ref": "#/$defs/at-most-one-search-offerings"
+ }
+ ]
+ },
+ "at-most-one-get-collection": {
+ "properties": {
+ "operations": {
+ "contains": {
+ "properties": {
+ "name": {
+ "const": "get-collection"
+ }
+ },
+ "required": [
+ "name"
+ ]
+ },
+ "minContains": 0,
+ "maxContains": 1
+ }
+ }
+ },
+ "at-most-one-get-offering": {
+ "properties": {
+ "operations": {
+ "contains": {
+ "properties": {
+ "name": {
+ "const": "get-offering"
+ }
+ },
+ "required": [
+ "name"
+ ]
+ },
+ "minContains": 0,
+ "maxContains": 1
+ }
+ }
+ },
+ "at-most-one-list-collection-offerings": {
+ "properties": {
+ "operations": {
+ "contains": {
+ "properties": {
+ "name": {
+ "const": "list-collection-offerings"
+ }
+ },
+ "required": [
+ "name"
+ ]
+ },
+ "minContains": 0,
+ "maxContains": 1
+ }
+ }
+ },
+ "at-most-one-list-collections": {
+ "properties": {
+ "operations": {
+ "contains": {
+ "properties": {
+ "name": {
+ "const": "list-collections"
+ }
+ },
+ "required": [
+ "name"
+ ]
+ },
+ "minContains": 0,
+ "maxContains": 1
+ }
+ }
+ },
+ "at-most-one-list-offerings": {
+ "properties": {
+ "operations": {
+ "contains": {
+ "properties": {
+ "name": {
+ "const": "list-offerings"
+ }
+ },
+ "required": [
+ "name"
+ ]
+ },
+ "minContains": 0,
+ "maxContains": 1
+ }
+ }
+ },
+ "at-most-one-search-collections": {
+ "properties": {
+ "operations": {
+ "contains": {
+ "properties": {
+ "name": {
+ "const": "search-collections"
+ }
+ },
+ "required": [
+ "name"
+ ]
+ },
+ "minContains": 0,
+ "maxContains": 1
+ }
+ }
+ },
+ "at-most-one-search-offerings": {
+ "properties": {
+ "operations": {
+ "contains": {
+ "properties": {
+ "name": {
+ "const": "search-offerings"
+ }
+ },
+ "required": [
+ "name"
+ ]
+ },
+ "minContains": 0,
+ "maxContains": 1
+ }
+ }
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-openapi.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-openapi.schema.json
new file mode 100644
index 0000000..978145d
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-openapi.schema.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/service-openapi.schema.json",
+ "title": "ODP Service OpenAPI reference",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "url"
+ ],
+ "properties": {
+ "url": {
+ "$ref": "resource-reference.schema.json"
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-origin.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-origin.schema.json
new file mode 100644
index 0000000..bc69859
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-origin.schema.json
@@ -0,0 +1,9 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/service-origin.schema.json",
+ "title": "ODP Service origin",
+ "description": "The canonical serialized origin of the final ODP Service Document URL.",
+ "type": "string",
+ "format": "uri",
+ "pattern": "^(?:https://(?![^/?#]*@)(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?|\\[[0-9a-f:]+\\])(?::(?!443$)[0-9]{1,5})?|http://(?:localhost|127\\.0\\.0\\.1|\\[::1\\])(?::(?!80$)[0-9]{1,5})?)$"
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-protocols.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-protocols.schema.json
new file mode 100644
index 0000000..d8fa313
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-protocols.schema.json
@@ -0,0 +1,92 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/service-protocols.schema.json",
+ "title": "ODP Service protocol support",
+ "type": "object",
+ "minProperties": 1,
+ "properties": {
+ "enrollment": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 1,
+ "uniqueItems": true,
+ "items": {
+ "$ref": "enrollment-protocol.schema.json"
+ }
+ },
+ "payments": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 2,
+ "uniqueItems": true,
+ "items": {
+ "$ref": "payment-protocol.schema.json"
+ }
+ }
+ },
+ "allOf": [
+ {
+ "properties": {
+ "payments": {
+ "contains": {
+ "properties": {
+ "name": {
+ "const": "mpp"
+ }
+ },
+ "required": [
+ "name"
+ ]
+ },
+ "minContains": 0,
+ "maxContains": 1
+ }
+ }
+ },
+ {
+ "properties": {
+ "payments": {
+ "contains": {
+ "properties": {
+ "name": {
+ "const": "x402"
+ }
+ },
+ "required": [
+ "name"
+ ]
+ },
+ "minContains": 0,
+ "maxContains": 1
+ }
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "payments": {
+ "contains": {
+ "properties": {
+ "authentication": {
+ "const": "required"
+ }
+ },
+ "required": [
+ "authentication"
+ ]
+ }
+ }
+ },
+ "required": [
+ "payments"
+ ]
+ },
+ "then": {
+ "required": [
+ "enrollment"
+ ]
+ }
+ }
+ ],
+ "additionalProperties": false
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/sort-capability-source.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/sort-capability-source.schema.json
new file mode 100644
index 0000000..c8bae6d
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/sort-capability-source.schema.json
@@ -0,0 +1,41 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/sort-capability-source.schema.json",
+ "title": "ODP Sort Definition source",
+ "type": "object",
+ "oneOf": [
+ {
+ "required": [
+ "inline"
+ ],
+ "not": {
+ "required": [
+ "linked"
+ ]
+ }
+ },
+ {
+ "required": [
+ "linked"
+ ],
+ "not": {
+ "required": [
+ "inline"
+ ]
+ }
+ }
+ ],
+ "properties": {
+ "inline": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 16,
+ "items": {
+ "$ref": "sort-definition.schema.json"
+ }
+ },
+ "linked": {
+ "$ref": "capability-link.schema.json"
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/sort-definition-page.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/sort-definition-page.schema.json
new file mode 100644
index 0000000..6f4efe3
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/sort-definition-page.schema.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/sort-definition-page.schema.json",
+ "title": "ODP Sort Definition page",
+ "type": "object",
+ "required": [
+ "odp_version",
+ "items"
+ ],
+ "properties": {
+ "odp_version": {
+ "const": "1.0"
+ },
+ "items": {
+ "type": "array",
+ "maxItems": 100,
+ "items": {
+ "$ref": "sort-definition.schema.json"
+ }
+ },
+ "next": {
+ "$ref": "resource-reference.schema.json"
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/sort-definition.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/sort-definition.schema.json
new file mode 100644
index 0000000..583d37e
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/sort-definition.schema.json
@@ -0,0 +1,35 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/sort-definition.schema.json",
+ "title": "ODP Sort Definition",
+ "type": "object",
+ "required": [
+ "id",
+ "title",
+ "description",
+ "keys"
+ ],
+ "properties": {
+ "id": {
+ "$ref": "capability-identifier.schema.json"
+ },
+ "title": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128
+ },
+ "description": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024
+ },
+ "keys": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 3,
+ "items": {
+ "$ref": "sort-key.schema.json"
+ }
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/sort-key.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/sort-key.schema.json
new file mode 100644
index 0000000..ef1b590
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/sort-key.schema.json
@@ -0,0 +1,28 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/sort-key.schema.json",
+ "title": "ODP Sort Key",
+ "type": "object",
+ "required": [
+ "filter_id",
+ "direction",
+ "missing"
+ ],
+ "properties": {
+ "filter_id": {
+ "$ref": "capability-identifier.schema.json"
+ },
+ "direction": {
+ "enum": [
+ "ascending",
+ "descending"
+ ]
+ },
+ "missing": {
+ "enum": [
+ "first",
+ "last"
+ ]
+ }
+ }
+}
diff --git a/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/top-level-document.schema.json b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/top-level-document.schema.json
new file mode 100644
index 0000000..d591ffa
--- /dev/null
+++ b/odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/top-level-document.schema.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://offeringprotocol.org/schemas/top-level-document.schema.json",
+ "title": "ODP top-level document",
+ "description": "The version-bearing envelope shared by top-level ODP request and response documents.",
+ "type": "object",
+ "required": [
+ "odp_version"
+ ],
+ "additionalProperties": true,
+ "properties": {
+ "odp_version": {
+ "$ref": "protocol-version.schema.json"
+ }
+ }
+}
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
new file mode 100644
index 0000000..56112a4
--- /dev/null
+++ b/odp-core/src/test/java/org/offeringprotocol/odp/core/OdpJsonTest.java
@@ -0,0 +1,55 @@
+package org.offeringprotocol.odp.core;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.net.URI;
+import org.junit.jupiter.api.Test;
+
+class OdpJsonTest {
+ private static final String DOCUMENT = """
+ {
+ "odp_version":"1.0",
+ "name":"Example Service",
+ "description":"An ODP Service used by the Java tests.",
+ "language":"en",
+ "localizations":["en"],
+ "operations":[
+ {"authentication":"not-required","name":"get-offering"},
+ {"authentication":"not-required","name":"list-offerings"}
+ ],
+ "http":{"endpoint_base":"/odp"},
+ "example_extension":{"enabled":true}
+ }
+ """;
+
+ @Test
+ void parsesAndPreservesServiceDocumentExtensions() {
+ ServiceDocument document = OdpJson.parseServiceDocument(DOCUMENT);
+
+ assertEquals("Example Service", document.name());
+ assertTrue(document.additional().containsKey("example_extension"));
+ assertTrue(OdpJson.write(document).contains("example_extension"));
+ }
+
+ @Test
+ void rejectsInvalidServiceDocuments() {
+ OdpValidationException exception = assertThrows(
+ OdpValidationException.class, () -> OdpJson.parseServiceDocument("{\"odp_version\":\"1.0\"}"));
+
+ assertEquals("Service Document", exception.documentType());
+ assertTrue(!exception.issues().isEmpty());
+ }
+
+ @Test
+ void resolvesOperationUrisAndCanonicalIdentity() {
+ URI operation =
+ OdpUris.buildOperationUri("/odp", OdpOperation.GET_OFFERING, "https://EXAMPLE.com:443", "plant-1");
+ ResourceIdentity identity =
+ ResourceIdentity.create(URI.create("https://EXAMPLE.com/.well-known/odp"), "offering", "plant-1");
+
+ assertEquals("https://EXAMPLE.com:443/odp/offerings/plant-1", operation.toString());
+ assertEquals("https://example.com\0offering\0plant-1", identity.key());
+ }
+}
diff --git a/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryClient.java b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryClient.java
new file mode 100644
index 0000000..ec1fe19
--- /dev/null
+++ b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryClient.java
@@ -0,0 +1,182 @@
+package org.offeringprotocol.odp.directory;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import tools.jackson.core.JacksonException;
+import tools.jackson.databind.json.JsonMapper;
+
+/** Client for the canonical ODP directory. */
+public final class DirectoryClient {
+ private static final int MAXIMUM_BYTES = 524_288;
+ private static final int MAXIMUM_REDIRECTS = 5;
+ private static final JsonMapper JSON = JsonMapper.builder()
+ .changeDefaultPropertyInclusion(inclusion -> inclusion.withValueInclusion(JsonInclude.Include.NON_NULL))
+ .build();
+
+ private final DirectoryEnvironment selectedEnvironment;
+ private final HttpClient httpClient;
+
+ private DirectoryClient(DirectoryEnvironment environment, HttpClient httpClient) {
+ this.selectedEnvironment = environment;
+ this.httpClient = httpClient;
+ }
+
+ public static DirectoryClient create() {
+ return create(DirectoryEnvironment.PRODUCTION);
+ }
+
+ public static DirectoryClient create(DirectoryEnvironment environment) {
+ HttpClient client = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(10))
+ .followRedirects(HttpClient.Redirect.NEVER)
+ .build();
+ return new DirectoryClient(Objects.requireNonNull(environment), client);
+ }
+
+ public static DirectoryClient create(DirectoryEnvironment environment, HttpClient httpClient) {
+ return new DirectoryClient(Objects.requireNonNull(environment), Objects.requireNonNull(httpClient));
+ }
+
+ public DirectoryEnvironment environment() {
+ return selectedEnvironment;
+ }
+
+ public DirectoryModels.SearchPage searchServices(DirectoryModels.SearchRequest request) {
+ Objects.requireNonNull(request, "request");
+ return decode(
+ send(selectedEnvironment.origin().resolve("/v1/services/search"), "POST", encode(request)),
+ DirectoryModels.SearchPage.class);
+ }
+
+ public DirectoryModels.SearchPage continueSearchServices(String next) {
+ URI uri = resolveContinuation(next);
+ return decode(send(uri, "GET", null), DirectoryModels.SearchPage.class);
+ }
+
+ public List suggestServices(String prefix, Integer limit) {
+ if (prefix == null || prefix.isBlank() || prefix.length() > 128) {
+ throw new IllegalArgumentException("prefix must contain from 1 through 128 characters");
+ }
+ if (limit != null && (limit < 1 || limit > 25)) {
+ throw new IllegalArgumentException("limit must be from 1 through 25");
+ }
+ String query = "?prefix=" + URLEncoder.encode(prefix, StandardCharsets.UTF_8)
+ + (limit == null ? "" : "&limit=" + limit);
+ String json = send(selectedEnvironment.origin().resolve("/v1/services/suggestions" + query), "GET", null);
+ try {
+ DirectoryModels.Suggestions suggestions = JSON.readValue(json, DirectoryModels.Suggestions.class);
+ return suggestions.items();
+ } catch (JacksonException exception) {
+ throw new IllegalArgumentException("Directory suggestions response is invalid", exception);
+ }
+ }
+
+ private String send(URI uri, String method, String body) {
+ URI current = uri;
+ String currentMethod = method;
+ String currentBody = body;
+ boolean hasBody = body != null;
+ for (int redirects = 0; redirects <= MAXIMUM_REDIRECTS; redirects++) {
+ HttpRequest.Builder builder = HttpRequest.newBuilder(current)
+ .timeout(Duration.ofSeconds(30))
+ .header("Accept", "application/json");
+ if (!hasBody) {
+ builder.method(currentMethod, HttpRequest.BodyPublishers.noBody());
+ } else {
+ builder.header("Content-Type", "application/json")
+ .method(currentMethod, HttpRequest.BodyPublishers.ofString(currentBody));
+ }
+ HttpResponse response;
+ try {
+ response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray());
+ } catch (IOException exception) {
+ throw new IllegalStateException("Directory request failed", exception);
+ } catch (InterruptedException exception) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException("Directory request was interrupted", exception);
+ }
+ int status = response.statusCode();
+ if (!isRedirect(status)) {
+ return consume(response);
+ }
+ if (redirects == MAXIMUM_REDIRECTS) {
+ throw new IllegalStateException("Directory response exceeded its redirect limit");
+ }
+ String location = response.headers()
+ .firstValue("Location")
+ .orElseThrow(() -> new IllegalStateException("Directory redirect omitted Location"));
+ current = requireDirectoryOrigin(current.resolve(location));
+ if (status == 303 || ((status == 301 || status == 302) && "POST".equals(currentMethod))) {
+ currentMethod = "GET";
+ hasBody = false;
+ }
+ }
+ throw new IllegalStateException("Directory request produced no response");
+ }
+
+ private String consume(HttpResponse response) {
+ byte[] body = response.body();
+ if (body.length > MAXIMUM_BYTES) {
+ throw new IllegalStateException("Directory response exceeds its byte limit");
+ }
+ String text = new String(body, StandardCharsets.UTF_8);
+ if (response.statusCode() < 200 || response.statusCode() > 299) {
+ throw new DirectoryRequestException(
+ response.statusCode(),
+ text.isEmpty() ? "Directory request failed with HTTP " + response.statusCode() : text,
+ response.headers());
+ }
+ String contentType = response.headers().firstValue("Content-Type").orElse("");
+ if (!contentType.toLowerCase(Locale.ROOT).startsWith("application/json")) {
+ throw new IllegalStateException("Directory response must use application/json");
+ }
+ return text;
+ }
+
+ private URI resolveContinuation(String next) {
+ if (next == null || next.isBlank() || next.length() > 2048) {
+ throw new IllegalArgumentException("next must contain from 1 through 2048 characters");
+ }
+ return requireDirectoryOrigin(selectedEnvironment.origin().resolve(next));
+ }
+
+ private URI requireDirectoryOrigin(URI uri) {
+ URI origin = selectedEnvironment.origin();
+ if (!uri.getScheme().equalsIgnoreCase(origin.getScheme())
+ || !uri.getAuthority().equalsIgnoreCase(origin.getAuthority())
+ || uri.getUserInfo() != null) {
+ throw new IllegalArgumentException("Directory continuation must remain on the canonical origin");
+ }
+ return uri;
+ }
+
+ private static boolean isRedirect(int status) {
+ return status == 301 || status == 302 || status == 303 || status == 307 || status == 308;
+ }
+
+ private static String encode(Object value) {
+ try {
+ return JSON.writeValueAsString(value);
+ } catch (JacksonException exception) {
+ throw new IllegalArgumentException("Directory request is not encodable", exception);
+ }
+ }
+
+ private static T decode(String json, Class type) {
+ try {
+ return JSON.readValue(json, type);
+ } catch (JacksonException exception) {
+ throw new IllegalArgumentException("Directory response is invalid", exception);
+ }
+ }
+}
diff --git a/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryEnvironment.java b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryEnvironment.java
new file mode 100644
index 0000000..571738f
--- /dev/null
+++ b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryEnvironment.java
@@ -0,0 +1,18 @@
+package org.offeringprotocol.odp.directory;
+
+import java.net.URI;
+
+public enum DirectoryEnvironment {
+ PRODUCTION("https://api.inflowpay.ai"),
+ SANDBOX("https://sandbox.inflowpay.ai");
+
+ private final URI canonicalOrigin;
+
+ DirectoryEnvironment(String origin) {
+ this.canonicalOrigin = URI.create(origin);
+ }
+
+ public URI origin() {
+ return canonicalOrigin;
+ }
+}
diff --git a/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryModels.java b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryModels.java
new file mode 100644
index 0000000..8ac2a21
--- /dev/null
+++ b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryModels.java
@@ -0,0 +1,106 @@
+package org.offeringprotocol.odp.directory;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.offeringprotocol.odp.core.AuthenticationRequirement;
+import org.offeringprotocol.odp.core.OdpOperation;
+import org.offeringprotocol.odp.core.OperationDescriptor;
+import org.offeringprotocol.odp.core.PaymentOption;
+import org.offeringprotocol.odp.core.ServiceDocument;
+import tools.jackson.databind.JsonNode;
+
+public interface DirectoryModels {
+ public record SearchRequest(String query, ServiceFilters filters, Integer limit) {
+ public SearchRequest {
+ if (limit != null && (limit < 1 || limit > 100)) {
+ throw new IllegalArgumentException("limit must be from 1 through 100");
+ }
+ }
+ }
+
+ public record ServiceFilters(
+ List enrollment,
+ List keywords,
+ List operations,
+ List payments) {
+ public ServiceFilters {
+ enrollment = enrollment == null ? List.of() : List.copyOf(enrollment);
+ keywords = keywords == null ? List.of() : List.copyOf(keywords);
+ operations = operations == null ? List.of() : List.copyOf(operations);
+ payments = payments == null ? List.of() : List.copyOf(payments);
+ }
+ }
+
+ public record OperationFilter(AuthenticationRequirement authentication, OdpOperation name) {}
+
+ public record PaymentFilter(AuthenticationRequirement authentication, String name, List options) {
+ public PaymentFilter {
+ options = options == null ? List.of() : List.copyOf(options);
+ }
+ }
+
+ public record Service(
+ @JsonProperty("service_origin") String serviceOrigin,
+ String name,
+ String description,
+ @JsonProperty("documentation_url") String documentationUrl,
+ String language,
+ List localizations,
+ List keywords,
+ List operations,
+ ServiceDocument.Protocols protocols,
+ @JsonProperty("indexed_at") Instant indexedAt,
+ @JsonProperty("status_url") String statusUrl,
+ @JsonProperty("support_url") String supportUrl,
+ @JsonProperty("website_url") String websiteUrl,
+ @JsonAnySetter @JsonAnyGetter Map additional) {
+ public Service {
+ localizations = localizations == null ? List.of() : List.copyOf(localizations);
+ keywords = keywords == null ? List.of() : List.copyOf(keywords);
+ operations = operations == null ? List.of() : List.copyOf(operations);
+ additional = additional == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(additional));
+ }
+ }
+
+ public record Facet(T value, long count) {}
+
+ public record PaymentOptionFacetValue(String name, PaymentOption option) {}
+
+ public record Facets(
+ List> enrollment,
+ List> keywords,
+ List> operations,
+ List> payments,
+ @JsonProperty("payment_options") List> paymentOptions) {
+ public Facets {
+ enrollment = enrollment == null ? List.of() : List.copyOf(enrollment);
+ keywords = keywords == null ? List.of() : List.copyOf(keywords);
+ operations = operations == null ? List.of() : List.copyOf(operations);
+ payments = payments == null ? List.of() : List.copyOf(payments);
+ paymentOptions = paymentOptions == null ? List.of() : List.copyOf(paymentOptions);
+ }
+ }
+
+ public record SearchPage(
+ List items,
+ String next,
+ Facets facets,
+ @JsonAnySetter @JsonAnyGetter Map additional) {
+ public SearchPage {
+ items = items == null ? List.of() : List.copyOf(items);
+ additional = additional == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(additional));
+ }
+ }
+
+ public record Suggestions(List items) {
+ public Suggestions {
+ items = items == null ? List.of() : List.copyOf(items);
+ }
+ }
+}
diff --git a/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryRequestException.java b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryRequestException.java
new file mode 100644
index 0000000..bc31cd4
--- /dev/null
+++ b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryRequestException.java
@@ -0,0 +1,24 @@
+package org.offeringprotocol.odp.directory;
+
+import java.net.http.HttpHeaders;
+
+public final class DirectoryRequestException extends RuntimeException {
+ private static final long serialVersionUID = 1L;
+
+ private final int responseStatus;
+ private final transient HttpHeaders responseHeaders;
+
+ public DirectoryRequestException(int status, String message, HttpHeaders headers) {
+ super(message);
+ this.responseStatus = status;
+ this.responseHeaders = headers;
+ }
+
+ public int status() {
+ return responseStatus;
+ }
+
+ public HttpHeaders headers() {
+ return responseHeaders;
+ }
+}
diff --git a/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryClientTest.java b/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryClientTest.java
new file mode 100644
index 0000000..283e22d
--- /dev/null
+++ b/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryClientTest.java
@@ -0,0 +1,24 @@
+package org.offeringprotocol.odp.directory;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+
+class DirectoryClientTest {
+ @Test
+ void selectsOnlyCanonicalEnvironments() {
+ assertEquals(DirectoryEnvironment.PRODUCTION, DirectoryClient.create().environment());
+ assertEquals(
+ "https://sandbox.inflowpay.ai",
+ DirectoryClient.create(DirectoryEnvironment.SANDBOX)
+ .environment()
+ .origin()
+ .toString());
+ }
+
+ @Test
+ void rejectsInvalidSearchLimitsBeforeTransport() {
+ assertThrows(IllegalArgumentException.class, () -> new DirectoryModels.SearchRequest("plants", null, 101));
+ }
+}
diff --git a/odp-service/src/main/java/org/offeringprotocol/odp/service/CatalogHandler.java b/odp-service/src/main/java/org/offeringprotocol/odp/service/CatalogHandler.java
new file mode 100644
index 0000000..96e8db2
--- /dev/null
+++ b/odp-service/src/main/java/org/offeringprotocol/odp/service/CatalogHandler.java
@@ -0,0 +1,6 @@
+package org.offeringprotocol.odp.service;
+
+@FunctionalInterface
+public interface CatalogHandler {
+ Object handle(CatalogRequest request);
+}
diff --git a/odp-service/src/main/java/org/offeringprotocol/odp/service/CatalogRequest.java b/odp-service/src/main/java/org/offeringprotocol/odp/service/CatalogRequest.java
new file mode 100644
index 0000000..54857e7
--- /dev/null
+++ b/odp-service/src/main/java/org/offeringprotocol/odp/service/CatalogRequest.java
@@ -0,0 +1,10 @@
+package org.offeringprotocol.odp.service;
+
+public record CatalogRequest(
+ String identifier,
+ String representation,
+ Integer limit,
+ String cursor,
+ String language,
+ String body,
+ OdpHttpRequest request) {}
diff --git a/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpHttpRequest.java b/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpHttpRequest.java
new file mode 100644
index 0000000..e45d23b
--- /dev/null
+++ b/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpHttpRequest.java
@@ -0,0 +1,28 @@
+package org.offeringprotocol.odp.service;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+public record OdpHttpRequest(
+ String method, String path, Map> query, Map> headers, String body) {
+ public OdpHttpRequest {
+ query = query.entrySet().stream()
+ .collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, entry -> List.copyOf(entry.getValue())));
+ headers = headers.entrySet().stream()
+ .collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, entry -> List.copyOf(entry.getValue())));
+ }
+
+ public String queryValue(String name) {
+ List values = query.get(name);
+ return values == null || values.isEmpty() ? null : values.get(0);
+ }
+
+ public String headerValue(String name) {
+ return headers.entrySet().stream()
+ .filter(entry -> entry.getKey().equalsIgnoreCase(name))
+ .flatMap(entry -> entry.getValue().stream())
+ .findFirst()
+ .orElse(null);
+ }
+}
diff --git a/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpHttpResponse.java b/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpHttpResponse.java
new file mode 100644
index 0000000..b395c05
--- /dev/null
+++ b/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpHttpResponse.java
@@ -0,0 +1,9 @@
+package org.offeringprotocol.odp.service;
+
+import java.util.Map;
+
+public record OdpHttpResponse(int status, Map headers, String body) {
+ public OdpHttpResponse {
+ headers = Map.copyOf(headers);
+ }
+}
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
new file mode 100644
index 0000000..b64d595
--- /dev/null
+++ b/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpService.java
@@ -0,0 +1,175 @@
+package org.offeringprotocol.odp.service;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+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.OperationDescriptor;
+import org.offeringprotocol.odp.core.ProblemDetails;
+import org.offeringprotocol.odp.core.ServiceDocument;
+
+/** Framework-neutral ODP Service request handler. */
+public final class OdpService {
+ private static final String MEDIA_TYPE = "application/odp+json";
+ private static final String GET = "GET";
+ private static final String NOT_FOUND = "NOT_FOUND";
+
+ private final ServiceDocument serviceDocument;
+ private final Map endpoints;
+ private final String endpointBase;
+
+ public OdpService(ServiceDocument template, Map endpoints) {
+ Objects.requireNonNull(template, "template");
+ if (!endpoints.containsKey(OdpOperation.LIST_OFFERINGS) || !endpoints.containsKey(OdpOperation.GET_OFFERING)) {
+ throw new IllegalArgumentException("ODP Services require list-offerings and get-offering handlers");
+ }
+ this.endpoints = Map.copyOf(endpoints);
+ this.endpointBase = template.http().endpointBase().replaceFirst("/$", "");
+ List operations = endpoints.entrySet().stream()
+ .sorted(Map.Entry.comparingByKey())
+ .map(entry -> new OperationDescriptor(entry.getValue().authentication(), entry.getKey()))
+ .toList();
+ this.serviceDocument = new ServiceDocument(
+ template.odpVersion(),
+ template.name(),
+ template.description(),
+ template.documentationUrl(),
+ template.language(),
+ template.localizations(),
+ template.mcp(),
+ template.keywords(),
+ template.branding(),
+ operations,
+ template.http(),
+ template.protocols(),
+ template.paymentOrigins(),
+ template.searchCapabilities(),
+ template.statusUrl(),
+ template.supportUrl(),
+ template.websiteUrl(),
+ template.additional());
+ OdpJson.parseServiceDocument(OdpJson.write(this.serviceDocument));
+ }
+
+ public ServiceDocument document() {
+ return serviceDocument;
+ }
+
+ public OdpHttpResponse handle(OdpHttpRequest request) {
+ try {
+ if (GET.equals(request.method()) && Odp.SERVICE_DOCUMENT_PATH.equals(request.path())) {
+ return json(200, serviceDocument);
+ }
+ Route route = route(request);
+ Endpoint endpoint = endpoints.get(route.operation());
+ if (endpoint == null) {
+ return problem(404, NOT_FOUND, "ODP endpoint not found");
+ }
+ String representation = request.queryValue("representation");
+ if (representation == null) {
+ representation = "terse";
+ }
+ if (!"terse".equals(representation) && !"full".equals(representation)) {
+ return problem(400, "INVALID_REQUEST", "representation must be terse or full");
+ }
+ Integer limit = integerQuery(request, "limit", 1, 100);
+ CatalogRequest catalogRequest = new CatalogRequest(
+ route.identifier(),
+ representation,
+ limit,
+ request.queryValue("cursor"),
+ request.headerValue("Accept-Language"),
+ request.body(),
+ request);
+ Object response = endpoint.handler().handle(catalogRequest);
+ if (response == null) {
+ return problem(404, NOT_FOUND, "ODP resource not found");
+ }
+ return json(200, response);
+ } catch (OdpServiceException exception) {
+ return problem(exception.status(), exception.code(), exception.getMessage());
+ } catch (IllegalArgumentException exception) {
+ return problem(400, "INVALID_REQUEST", exception.getMessage());
+ }
+ }
+
+ private Route route(OdpHttpRequest request) {
+ if (!request.path().startsWith(endpointBase + "/")) {
+ throw new OdpServiceException(404, NOT_FOUND, "ODP endpoint not found");
+ }
+ String path = request.path().substring(endpointBase.length());
+ String[] parts = path.split("/");
+ if (GET.equals(request.method()) && "/collections".equals(path)) {
+ return new Route(OdpOperation.LIST_COLLECTIONS, null);
+ }
+ if ("POST".equals(request.method()) && "/collections/search".equals(path)) {
+ return new Route(OdpOperation.SEARCH_COLLECTIONS, null);
+ }
+ if (GET.equals(request.method()) && parts.length == 3 && "collections".equals(parts[1])) {
+ return new Route(OdpOperation.GET_COLLECTION, parts[2]);
+ }
+ if (GET.equals(request.method())
+ && parts.length == 4
+ && "collections".equals(parts[1])
+ && "offerings".equals(parts[3])) {
+ return new Route(OdpOperation.LIST_COLLECTION_OFFERINGS, parts[2]);
+ }
+ if (GET.equals(request.method()) && "/offerings".equals(path)) {
+ return new Route(OdpOperation.LIST_OFFERINGS, null);
+ }
+ if ("POST".equals(request.method()) && "/offerings/search".equals(path)) {
+ return new Route(OdpOperation.SEARCH_OFFERINGS, null);
+ }
+ if (GET.equals(request.method()) && parts.length == 3 && "offerings".equals(parts[1])) {
+ return new Route(OdpOperation.GET_OFFERING, parts[2]);
+ }
+ throw new OdpServiceException(404, NOT_FOUND, "ODP endpoint not found");
+ }
+
+ private static Integer integerQuery(OdpHttpRequest request, String name, int minimum, int maximum) {
+ String value = request.queryValue(name);
+ if (value == null) {
+ return null;
+ }
+ try {
+ int parsed = Integer.parseInt(value);
+ if (parsed < minimum || parsed > maximum) {
+ throw new NumberFormatException();
+ }
+ return parsed;
+ } catch (NumberFormatException exception) {
+ throw new IllegalArgumentException(name + " must be from " + minimum + " through " + maximum, exception);
+ }
+ }
+
+ private static OdpHttpResponse json(int status, Object body) {
+ return new OdpHttpResponse(status, Map.of("Content-Type", MEDIA_TYPE), OdpJson.write(body));
+ }
+
+ private static OdpHttpResponse problem(int status, String code, String detail) {
+ ProblemDetails problem = new ProblemDetails(
+ "https://offeringprotocol.org/problems/"
+ + code.toLowerCase(Locale.ROOT).replace('_', '-'),
+ detail,
+ status,
+ code,
+ detail,
+ null,
+ null,
+ Map.of());
+ return new OdpHttpResponse(status, Map.of("Content-Type", "application/problem+json"), OdpJson.write(problem));
+ }
+
+ public record Endpoint(AuthenticationRequirement authentication, CatalogHandler handler) {
+ public Endpoint {
+ Objects.requireNonNull(authentication);
+ Objects.requireNonNull(handler);
+ }
+ }
+
+ private record Route(OdpOperation operation, String identifier) {}
+}
diff --git a/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpServiceException.java b/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpServiceException.java
new file mode 100644
index 0000000..b7e4341
--- /dev/null
+++ b/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpServiceException.java
@@ -0,0 +1,22 @@
+package org.offeringprotocol.odp.service;
+
+public final class OdpServiceException extends RuntimeException {
+ private static final long serialVersionUID = 1L;
+
+ private final int responseStatus;
+ private final String problemCode;
+
+ public OdpServiceException(int status, String code, String message) {
+ super(message);
+ this.responseStatus = status;
+ this.problemCode = code;
+ }
+
+ public int status() {
+ return responseStatus;
+ }
+
+ public String code() {
+ return problemCode;
+ }
+}
diff --git a/odp-service/src/main/java/org/offeringprotocol/odp/service/StaticCatalog.java b/odp-service/src/main/java/org/offeringprotocol/odp/service/StaticCatalog.java
new file mode 100644
index 0000000..f3bd563
--- /dev/null
+++ b/odp-service/src/main/java/org/offeringprotocol/odp/service/StaticCatalog.java
@@ -0,0 +1,216 @@
+package org.offeringprotocol.odp.service;
+
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.MessageDigest;
+import java.security.SecureRandom;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import org.offeringprotocol.odp.core.AuthenticationRequirement;
+import org.offeringprotocol.odp.core.Collection;
+import org.offeringprotocol.odp.core.Odp;
+import org.offeringprotocol.odp.core.OdpOperation;
+import org.offeringprotocol.odp.core.Offering;
+import org.offeringprotocol.odp.core.Page;
+
+/** In-memory catalog handlers for small ODP Services. */
+public final class StaticCatalog {
+ private static final int MINIMUM_KEY_BYTES = 32;
+ private static final int CURSOR_PARTS = 2;
+ private static final int CURSOR_VALUES = 5;
+ private static final SecureRandom RANDOM = new SecureRandom();
+
+ private StaticCatalog() {}
+
+ public static Map create(
+ List offerings, List collections) {
+ byte[] continuationKey = new byte[MINIMUM_KEY_BYTES];
+ RANDOM.nextBytes(continuationKey);
+ return create(offerings, collections, continuationKey);
+ }
+
+ public static Map create(
+ List offerings, List collections, byte[] continuationKey) {
+ if (continuationKey.length < MINIMUM_KEY_BYTES) {
+ throw new IllegalArgumentException("continuationKey must contain at least 32 bytes");
+ }
+ byte[] key = continuationKey.clone();
+ Map offeringsById = unique(offerings, Offering::id, "Offering");
+ Map collectionsById = unique(collections, Collection::id, "Collection");
+ Map handlers = new LinkedHashMap<>();
+ handlers.put(
+ OdpOperation.LIST_OFFERINGS,
+ endpoint(request -> page(offerings, request, StaticCatalog::terseOffering, key)));
+ handlers.put(
+ OdpOperation.GET_OFFERING,
+ endpoint(request ->
+ represent(offeringsById.get(request.identifier()), request, StaticCatalog::terseOffering)));
+ if (!collections.isEmpty()) {
+ handlers.put(
+ OdpOperation.LIST_COLLECTIONS,
+ endpoint(request -> page(collections, request, StaticCatalog::terseCollection, key)));
+ handlers.put(
+ OdpOperation.GET_COLLECTION,
+ endpoint(request -> represent(
+ collectionsById.get(request.identifier()), request, StaticCatalog::terseCollection)));
+ handlers.put(OdpOperation.LIST_COLLECTION_OFFERINGS, endpoint(request -> {
+ if (!collectionsById.containsKey(request.identifier())) {
+ return null;
+ }
+ List matches = offerings.stream()
+ .filter(offering -> offering.collectionIds() != null
+ && offering.collectionIds().contains(request.identifier()))
+ .toList();
+ return page(matches, request, StaticCatalog::terseOffering, key);
+ }));
+ }
+ return Map.copyOf(handlers);
+ }
+
+ private static OdpService.Endpoint endpoint(CatalogHandler handler) {
+ return new OdpService.Endpoint(AuthenticationRequirement.NOT_REQUIRED, handler);
+ }
+
+ private static Page page(
+ List values, CatalogRequest request, Function terseRepresentation, byte[] continuationKey) {
+ int limit = request.limit() == null ? 50 : request.limit();
+ int offset = request.cursor() == null ? 0 : decodeCursor(request.cursor(), request, limit, continuationKey);
+ List items = values.stream()
+ .skip(offset)
+ .limit(limit)
+ .map(value -> "full".equals(request.representation()) ? value : terseRepresentation.apply(value))
+ .toList();
+ int nextOffset = offset + items.size();
+ String next = nextOffset >= values.size()
+ ? null
+ : request.request().path() + "?cursor="
+ + encodeCursor(nextOffset, request, limit, continuationKey)
+ + "&representation=" + request.representation() + "&limit=" + limit;
+ return new Page<>(null, Odp.VERSION, items, next, Map.of());
+ }
+
+ private static String encodeCursor(int offset, CatalogRequest request, int limit, byte[] continuationKey) {
+ String payload = Base64.getUrlEncoder()
+ .withoutPadding()
+ .encodeToString((Instant.now().plusSeconds(3600).getEpochSecond() + "\n" + offset + "\n" + limit + "\n"
+ + request.representation() + "\n"
+ + request.request().path())
+ .getBytes(StandardCharsets.UTF_8));
+ return payload + "." + Base64.getUrlEncoder().withoutPadding().encodeToString(sign(payload, continuationKey));
+ }
+
+ private static int decodeCursor(String cursor, CatalogRequest request, int limit, byte[] continuationKey) {
+ String[] parts = cursor.split("\\.", -1);
+ if (parts.length != CURSOR_PARTS) {
+ throw expiredCursor();
+ }
+ byte[] supplied;
+ String decoded;
+ try {
+ supplied = Base64.getUrlDecoder().decode(parts[1]);
+ decoded = new String(Base64.getUrlDecoder().decode(parts[0]), StandardCharsets.UTF_8);
+ } catch (IllegalArgumentException exception) {
+ throw expiredCursor(exception);
+ }
+ if (!MessageDigest.isEqual(supplied, sign(parts[0], continuationKey))) {
+ throw expiredCursor();
+ }
+ String[] values = decoded.split("\n", -1);
+ if (values.length != CURSOR_VALUES) {
+ throw expiredCursor();
+ }
+ try {
+ int offset = Integer.parseInt(values[1]);
+ if (Long.parseLong(values[0]) < Instant.now().getEpochSecond()
+ || offset < 0
+ || Integer.parseInt(values[2]) != limit
+ || !request.representation().equals(values[3])
+ || !request.request().path().equals(values[4])) {
+ throw expiredCursor();
+ }
+ return offset;
+ } catch (NumberFormatException exception) {
+ throw expiredCursor(exception);
+ }
+ }
+
+ private static byte[] sign(String payload, byte[] key) {
+ try {
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(key, "HmacSHA256"));
+ return mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
+ } catch (GeneralSecurityException exception) {
+ throw new IllegalStateException("HmacSHA256 is unavailable", exception);
+ }
+ }
+
+ private static OdpServiceException expiredCursor() {
+ return new OdpServiceException(410, "CONTINUATION_EXPIRED", "Continuation is unavailable");
+ }
+
+ private static OdpServiceException expiredCursor(Throwable cause) {
+ OdpServiceException exception = expiredCursor();
+ exception.initCause(cause);
+ return exception;
+ }
+
+ private static T represent(T value, CatalogRequest request, Function terseRepresentation) {
+ if (value == null || "full".equals(request.representation())) {
+ return value;
+ }
+ return terseRepresentation.apply(value);
+ }
+
+ private static Offering terseOffering(Offering value) {
+ return new Offering(
+ value.authExpands(),
+ null,
+ value.id(),
+ value.name(),
+ value.description(),
+ value.images() == null ? null : value.images().stream().limit(1).toList(),
+ value.language(),
+ value.localizations(),
+ value.webUrl(),
+ value.collectionIds(),
+ value.price(),
+ value.schema(),
+ value.attributes(),
+ null,
+ null,
+ value.additional());
+ }
+
+ private static Collection terseCollection(Collection value) {
+ return new Collection(
+ value.authExpands(),
+ null,
+ value.id(),
+ value.name(),
+ value.description(),
+ value.images() == null ? null : value.images().stream().limit(1).toList(),
+ value.language(),
+ value.localizations(),
+ value.parentIds(),
+ value.webUrl(),
+ value.searchCapabilities(),
+ null,
+ value.additional());
+ }
+
+ private static Map unique(List values, Function identifier, String resourceType) {
+ Map result = new LinkedHashMap<>();
+ for (T value : values) {
+ if (result.put(identifier.apply(value), value) != null) {
+ throw new IllegalArgumentException(resourceType + " identifiers must be unique");
+ }
+ }
+ return result;
+ }
+}
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
new file mode 100644
index 0000000..1fd8560
--- /dev/null
+++ b/odp-service/src/test/java/org/offeringprotocol/odp/service/OdpServiceTest.java
@@ -0,0 +1,71 @@
+package org.offeringprotocol.odp.service;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import org.offeringprotocol.odp.core.Odp;
+import org.offeringprotocol.odp.core.Offering;
+import org.offeringprotocol.odp.core.ServiceDocument;
+
+class OdpServiceTest {
+ @Test
+ void servesTheMinimumStaticCatalog() {
+ Offering offering = new Offering(
+ null,
+ Odp.VERSION,
+ "plant-1",
+ "Rubber Plant",
+ "A resilient houseplant.",
+ 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 document = service.handle(request("GET", "/.well-known/odp", Map.of()));
+ OdpHttpResponse list =
+ service.handle(request("GET", "/odp/offerings", Map.of("representation", List.of("full"))));
+ OdpHttpResponse detail = service.handle(request("GET", "/odp/offerings/plant-1", Map.of()));
+
+ assertEquals(200, document.status());
+ assertTrue(document.body().contains("list-offerings"));
+ assertTrue(list.body().contains("Rubber Plant"));
+ assertTrue(detail.body().contains("plant-1"));
+ }
+
+ private static ServiceDocument template() {
+ return new ServiceDocument(
+ Odp.VERSION,
+ "Plant Store",
+ "Plants for agents.",
+ null,
+ "en",
+ List.of("en"),
+ null,
+ List.of("plants"),
+ null,
+ List.of(),
+ new ServiceDocument.Http("/odp", null),
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ Map.of());
+ }
+
+ private static OdpHttpRequest request(String method, String path, Map> query) {
+ return new OdpHttpRequest(method, path, query, Map.of(), null);
+ }
+}
diff --git a/pom.xml b/pom.xml
index 5a09e2f..0d1b459 100644
--- a/pom.xml
+++ b/pom.xml
@@ -50,6 +50,8 @@
UTF-8
6.1.3
+ 3.2.1
+ 3.0.7
0.8.15
2.97.0
@@ -72,6 +74,13 @@
pom
import
+
+ tools.jackson
+ jackson-bom
+ ${jackson.version}
+ pom
+ import
+
@@ -217,6 +226,7 @@
${spotbugs-maven-plugin.version}
Max
+ ${maven.multiModuleProjectDirectory}/config/spotbugs-exclude.xml
true
Low