From a6a2dc33954d35085a05ce0e33094fa17aedec9a Mon Sep 17 00:00:00 2001 From: Nas Kavian Date: Mon, 24 Aug 2026 19:11:46 -0400 Subject: [PATCH] feat(sdk): implement ODP role modules --- config/spotbugs-exclude.xml | 20 ++ .../offeringprotocol/odp/agent/OdpAgent.java | 72 ++++ .../odp/agent/OdpRequestException.java | 31 ++ .../odp/agent/OdpServiceClient.java | 235 ++++++++++++ .../odp/agent/OdpTransport.java | 10 + .../odp/agent/ServiceInspection.java | 21 ++ .../odp/agent/OdpServiceClientTest.java | 90 +++++ odp-core/pom.xml | 14 + .../odp/core/AuthenticationRequirement.java | 30 ++ .../offeringprotocol/odp/core/Collection.java | 31 ++ .../org/offeringprotocol/odp/core/Copies.java | 27 ++ .../odp/core/HttpMethods.java | 7 + .../org/offeringprotocol/odp/core/Odp.java | 9 + .../offeringprotocol/odp/core/OdpJson.java | 131 +++++++ .../odp/core/OdpOperation.java | 40 +++ .../odp/core/OdpPagination.java | 44 +++ .../offeringprotocol/odp/core/OdpUris.java | 106 ++++++ .../odp/core/OdpValidationException.java | 28 ++ .../offeringprotocol/odp/core/Offering.java | 76 ++++ .../odp/core/OfferingPage.java | 37 ++ .../odp/core/OperationDescriptor.java | 3 + .../org/offeringprotocol/odp/core/Page.java | 20 ++ .../odp/core/PaymentOption.java | 43 +++ .../odp/core/ProblemDetails.java | 25 ++ .../odp/core/ResourceIdentity.java | 19 + .../odp/core/ResourceImage.java | 9 + .../odp/core/SearchCapabilities.java | 89 +++++ .../odp/core/SearchRequests.java | 27 ++ .../odp/core/ServiceDocument.java | 63 ++++ .../odp/core/ValidationIssue.java | 13 + .../core/schemas/action-relation.schema.json | 9 + .../core/schemas/action-request.schema.json | 18 + .../odp/core/schemas/action.schema.json | 56 +++ .../attribute-schema-reference.schema.json | 6 + .../authentication-requirement.schema.json | 11 + .../schemas/capability-identifier.schema.json | 9 + .../core/schemas/capability-link.schema.json | 14 + .../collection-search-request.schema.json | 45 +++ .../odp/core/schemas/collection.schema.json | 72 ++++ .../core/schemas/detail-fields.schema.json | 16 + .../schemas/enrollment-protocol.schema.json | 15 + .../filter-capability-source.schema.json | 41 +++ .../filter-definition-page.schema.json | 25 ++ .../schemas/filter-definition.schema.json | 63 ++++ .../schemas/filter-expression.schema.json | 45 +++ .../core/schemas/filter-operator.schema.json | 14 + .../odp/core/schemas/filter-type.schema.json | 14 + .../odp/core/schemas/filter-unit.schema.json | 45 +++ .../schemas/http-action-target.schema.json | 36 ++ .../odp/core/schemas/index.txt | 55 +++ .../schemas/invalid-parameter.schema.json | 55 +++ ...local-resource-identifier-list.schema.json | 10 + .../local-resource-identifier.schema.json | 10 + .../odp/core/schemas/mcp-endpoint.schema.json | 29 ++ .../offering-search-request.schema.json | 68 ++++ .../offering-search-response.schema.json | 20 ++ .../odp/core/schemas/offering.schema.json | 92 +++++ .../schemas/openapi-action-target.schema.json | 20 ++ .../schemas/operation-descriptor.schema.json | 27 ++ .../core/schemas/page-envelope.schema.json | 24 ++ .../odp/core/schemas/page-limit.schema.json | 8 + .../core/schemas/payment-option.schema.json | 24 ++ .../core/schemas/payment-protocol.schema.json | 34 ++ .../core/schemas/price-preview.schema.json | 133 +++++++ .../odp/core/schemas/problem-code.schema.json | 9 + .../core/schemas/problem-details.schema.json | 66 ++++ .../core/schemas/protocol-version.schema.json | 8 + .../schemas/refinement-bucket.schema.json | 28 ++ .../core/schemas/refinement-group.schema.json | 24 ++ .../core/schemas/representation.schema.json | 11 + .../schemas/resource-identity.schema.json | 27 ++ .../core/schemas/resource-image.schema.json | 40 +++ .../schemas/resource-reference.schema.json | 19 + .../core/schemas/schema-reference.schema.json | 15 + .../schemas/search-capabilities.schema.json | 26 ++ .../service-branding-image.schema.json | 23 ++ .../core/schemas/service-branding.schema.json | 19 + .../core/schemas/service-document.schema.json | 337 ++++++++++++++++++ .../core/schemas/service-openapi.schema.json | 15 + .../core/schemas/service-origin.schema.json | 9 + .../schemas/service-protocols.schema.json | 92 +++++ .../sort-capability-source.schema.json | 41 +++ .../schemas/sort-definition-page.schema.json | 25 ++ .../core/schemas/sort-definition.schema.json | 35 ++ .../odp/core/schemas/sort-key.schema.json | 28 ++ .../schemas/top-level-document.schema.json | 16 + .../odp/core/OdpJsonTest.java | 55 +++ .../odp/directory/DirectoryClient.java | 182 ++++++++++ .../odp/directory/DirectoryEnvironment.java | 18 + .../odp/directory/DirectoryModels.java | 106 ++++++ .../directory/DirectoryRequestException.java | 24 ++ .../odp/directory/DirectoryClientTest.java | 24 ++ .../odp/service/CatalogHandler.java | 6 + .../odp/service/CatalogRequest.java | 10 + .../odp/service/OdpHttpRequest.java | 28 ++ .../odp/service/OdpHttpResponse.java | 9 + .../odp/service/OdpService.java | 175 +++++++++ .../odp/service/OdpServiceException.java | 22 ++ .../odp/service/StaticCatalog.java | 216 +++++++++++ .../odp/service/OdpServiceTest.java | 71 ++++ pom.xml | 10 + 101 files changed, 4402 insertions(+) create mode 100644 config/spotbugs-exclude.xml create mode 100644 odp-agent/src/main/java/org/offeringprotocol/odp/agent/OdpAgent.java create mode 100644 odp-agent/src/main/java/org/offeringprotocol/odp/agent/OdpRequestException.java create mode 100644 odp-agent/src/main/java/org/offeringprotocol/odp/agent/OdpServiceClient.java create mode 100644 odp-agent/src/main/java/org/offeringprotocol/odp/agent/OdpTransport.java create mode 100644 odp-agent/src/main/java/org/offeringprotocol/odp/agent/ServiceInspection.java create mode 100644 odp-agent/src/test/java/org/offeringprotocol/odp/agent/OdpServiceClientTest.java create mode 100644 odp-core/src/main/java/org/offeringprotocol/odp/core/AuthenticationRequirement.java create mode 100644 odp-core/src/main/java/org/offeringprotocol/odp/core/Collection.java create mode 100644 odp-core/src/main/java/org/offeringprotocol/odp/core/Copies.java create mode 100644 odp-core/src/main/java/org/offeringprotocol/odp/core/HttpMethods.java create mode 100644 odp-core/src/main/java/org/offeringprotocol/odp/core/Odp.java create mode 100644 odp-core/src/main/java/org/offeringprotocol/odp/core/OdpJson.java create mode 100644 odp-core/src/main/java/org/offeringprotocol/odp/core/OdpOperation.java create mode 100644 odp-core/src/main/java/org/offeringprotocol/odp/core/OdpPagination.java create mode 100644 odp-core/src/main/java/org/offeringprotocol/odp/core/OdpUris.java create mode 100644 odp-core/src/main/java/org/offeringprotocol/odp/core/OdpValidationException.java create mode 100644 odp-core/src/main/java/org/offeringprotocol/odp/core/Offering.java create mode 100644 odp-core/src/main/java/org/offeringprotocol/odp/core/OfferingPage.java create mode 100644 odp-core/src/main/java/org/offeringprotocol/odp/core/OperationDescriptor.java create mode 100644 odp-core/src/main/java/org/offeringprotocol/odp/core/Page.java create mode 100644 odp-core/src/main/java/org/offeringprotocol/odp/core/PaymentOption.java create mode 100644 odp-core/src/main/java/org/offeringprotocol/odp/core/ProblemDetails.java create mode 100644 odp-core/src/main/java/org/offeringprotocol/odp/core/ResourceIdentity.java create mode 100644 odp-core/src/main/java/org/offeringprotocol/odp/core/ResourceImage.java create mode 100644 odp-core/src/main/java/org/offeringprotocol/odp/core/SearchCapabilities.java create mode 100644 odp-core/src/main/java/org/offeringprotocol/odp/core/SearchRequests.java create mode 100644 odp-core/src/main/java/org/offeringprotocol/odp/core/ServiceDocument.java create mode 100644 odp-core/src/main/java/org/offeringprotocol/odp/core/ValidationIssue.java create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/action-relation.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/action-request.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/action.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/attribute-schema-reference.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/authentication-requirement.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/capability-identifier.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/capability-link.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/collection-search-request.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/collection.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/detail-fields.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/enrollment-protocol.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-capability-source.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-definition-page.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-definition.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-expression.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-operator.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-type.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/filter-unit.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/http-action-target.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/index.txt create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/invalid-parameter.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/local-resource-identifier-list.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/local-resource-identifier.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/mcp-endpoint.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/offering-search-request.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/offering-search-response.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/offering.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/openapi-action-target.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/operation-descriptor.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/page-envelope.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/page-limit.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/payment-option.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/payment-protocol.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/price-preview.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/problem-code.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/problem-details.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/protocol-version.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/refinement-bucket.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/refinement-group.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/representation.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/resource-identity.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/resource-image.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/resource-reference.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/schema-reference.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/search-capabilities.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-branding-image.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-branding.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-document.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-openapi.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-origin.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/service-protocols.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/sort-capability-source.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/sort-definition-page.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/sort-definition.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/sort-key.schema.json create mode 100644 odp-core/src/main/resources/org/offeringprotocol/odp/core/schemas/top-level-document.schema.json create mode 100644 odp-core/src/test/java/org/offeringprotocol/odp/core/OdpJsonTest.java create mode 100644 odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryClient.java create mode 100644 odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryEnvironment.java create mode 100644 odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryModels.java create mode 100644 odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryRequestException.java create mode 100644 odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryClientTest.java create mode 100644 odp-service/src/main/java/org/offeringprotocol/odp/service/CatalogHandler.java create mode 100644 odp-service/src/main/java/org/offeringprotocol/odp/service/CatalogRequest.java create mode 100644 odp-service/src/main/java/org/offeringprotocol/odp/service/OdpHttpRequest.java create mode 100644 odp-service/src/main/java/org/offeringprotocol/odp/service/OdpHttpResponse.java create mode 100644 odp-service/src/main/java/org/offeringprotocol/odp/service/OdpService.java create mode 100644 odp-service/src/main/java/org/offeringprotocol/odp/service/OdpServiceException.java create mode 100644 odp-service/src/main/java/org/offeringprotocol/odp/service/StaticCatalog.java create mode 100644 odp-service/src/test/java/org/offeringprotocol/odp/service/OdpServiceTest.java 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