From d385816a19e2270bd1be32bb93e076578152a073 Mon Sep 17 00:00:00 2001 From: jabdikadyr Date: Tue, 15 Sep 2026 16:35:49 +0600 Subject: [PATCH] fix(sdk): close conformance gaps in the agent, directory and service --- README.md | 8 +- .../odp/examples/SmallService.java | 34 +- .../odp/examples/SearchIntegrationTest.java | 102 ++ odp-agent/README.md | 115 ++- odp-agent/pom.xml | 5 + .../odp/agent/ApacheTransport.java | 339 +++++++ .../odp/agent/AttributeSchemaResolver.java | 7 +- .../odp/agent/OdpServiceClient.java | 414 ++++++-- .../odp/agent/OdpTransport.java | 5 + .../odp/agent/OfferingSearchDetails.java | 16 + .../offeringprotocol/odp/agent/ReadRetry.java | 118 +++ .../odp/agent/SearchCapabilityResolver.java | 177 ++++ .../odp/agent/SearchCapabilityResult.java | 23 + .../odp/agent/SecureDestinations.java | 70 ++ .../odp/agent/SupportingJsonClient.java | 26 +- .../odp/agent/ApacheTlsTest.java | 96 ++ .../odp/agent/ApacheTransportTest.java | 328 ++++++ .../odp/agent/ModelsTest.java | 89 ++ .../odp/agent/OdpAgentTest.java | 190 ++++ .../odp/agent/OfferingDetailsTest.java | 19 + .../odp/agent/ReadRetryTest.java | 237 +++++ .../agent/RepresentationConformanceTest.java | 223 ++++ .../offeringprotocol/odp/agent/Responses.java | 111 ++ .../odp/agent/SearchCapabilityTest.java | 300 ++++++ .../odp/agent/SecureDestinationsTest.java | 112 ++ .../odp/agent/SupportingGraphTest.java | 324 ++++++ .../odp/agent/SupportingResourceTest.java | 183 ++++ .../odp/agent/TransportConformanceTest.java | 286 ++++++ odp-core/README.md | 43 +- .../odp/core/OdpAddresses.java | 120 +++ .../offeringprotocol/odp/core/OdpJson.java | 226 +++-- .../odp/core/OdpMediaType.java | 81 ++ .../odp/core/OdpPagination.java | 79 ++ .../odp/core/OdpResponseLimitException.java | 17 + .../odp/core/OfferingPage.java | 5 + .../odp/core/SearchCatalog.java | 237 +++++ odp-directory/README.md | 41 +- .../odp/directory/BoundedBodySubscriber.java | 60 ++ .../odp/directory/DirectoryClient.java | 269 ++++- .../odp/directory/DirectoryModels.java | 3 + .../odp/directory/DirectoryOrigins.java | 94 ++ .../directory/BoundedBodySubscriberTest.java | 57 ++ .../odp/directory/DirectoryModelsTest.java | 100 ++ .../odp/directory/DirectoryOriginsTest.java | 124 +++ .../odp/directory/DirectoryTransportTest.java | 7 +- .../odp/directory/ResultConformanceTest.java | 229 +++++ .../offeringprotocol/odp/directory/Stub.java | 209 ++++ .../directory/TransportConformanceTest.java | 293 ++++++ .../jackson2/Jackson2JsonProviderTest.java | 65 ++ .../odp/core/OdpJsonTest.java | 82 ++ .../odp/core/OdpMediaTypeTest.java | 42 + .../odp/core/OdpPaginationTest.java | 146 +++ .../odp/core/SearchCatalogTest.java | 113 +++ odp-service/README.md | 98 +- .../odp/service/OdpHttpRequest.java | 21 +- .../odp/service/OdpHttpResponse.java | 4 +- .../odp/service/OdpService.java | 860 ++++++++++++++-- .../odp/service/OdpServiceException.java | 41 +- .../odp/service/StaticCatalog.java | 132 ++- .../odp/service/CachingConformanceTest.java | 199 ++++ .../offeringprotocol/odp/service/Catalog.java | 151 +++ .../odp/service/EdgeCaseTest.java | 400 ++++++++ .../odp/service/LanguageConformanceTest.java | 170 ++++ .../odp/service/OdpServiceTest.java | 253 ----- .../odp/service/ProblemConformanceTest.java | 178 ++++ .../RepresentationConformanceTest.java | 249 +++++ .../odp/service/SearchValidationTest.java | 53 + .../odp/service/ServiceDocumentTest.java | 203 ++++ .../odp/service/StaticCatalogTest.java | 409 ++++++++ .../odp/service/TransportConformanceTest.java | 377 +++++++ pom.xml | 36 +- scripts/run-conformance.sh | 2 +- .../odp/conformance/ConformanceAdapter.java | 958 +++++++++++++++++- .../conformance/InteroperabilityAgent.java | 5 +- .../odp/conformance/NodeInterop.java | 5 +- 75 files changed, 10845 insertions(+), 658 deletions(-) create mode 100644 examples/src/test/java/org/offeringprotocol/odp/examples/SearchIntegrationTest.java create mode 100644 odp-agent/src/main/java/org/offeringprotocol/odp/agent/ApacheTransport.java create mode 100644 odp-agent/src/main/java/org/offeringprotocol/odp/agent/OfferingSearchDetails.java create mode 100644 odp-agent/src/main/java/org/offeringprotocol/odp/agent/ReadRetry.java create mode 100644 odp-agent/src/main/java/org/offeringprotocol/odp/agent/SearchCapabilityResolver.java create mode 100644 odp-agent/src/main/java/org/offeringprotocol/odp/agent/SearchCapabilityResult.java create mode 100644 odp-agent/src/main/java/org/offeringprotocol/odp/agent/SecureDestinations.java create mode 100644 odp-agent/src/test/java/org/offeringprotocol/odp/agent/ApacheTlsTest.java create mode 100644 odp-agent/src/test/java/org/offeringprotocol/odp/agent/ApacheTransportTest.java create mode 100644 odp-agent/src/test/java/org/offeringprotocol/odp/agent/ModelsTest.java create mode 100644 odp-agent/src/test/java/org/offeringprotocol/odp/agent/OdpAgentTest.java create mode 100644 odp-agent/src/test/java/org/offeringprotocol/odp/agent/ReadRetryTest.java create mode 100644 odp-agent/src/test/java/org/offeringprotocol/odp/agent/RepresentationConformanceTest.java create mode 100644 odp-agent/src/test/java/org/offeringprotocol/odp/agent/Responses.java create mode 100644 odp-agent/src/test/java/org/offeringprotocol/odp/agent/SearchCapabilityTest.java create mode 100644 odp-agent/src/test/java/org/offeringprotocol/odp/agent/SecureDestinationsTest.java create mode 100644 odp-agent/src/test/java/org/offeringprotocol/odp/agent/SupportingGraphTest.java create mode 100644 odp-agent/src/test/java/org/offeringprotocol/odp/agent/SupportingResourceTest.java create mode 100644 odp-agent/src/test/java/org/offeringprotocol/odp/agent/TransportConformanceTest.java create mode 100644 odp-core/src/main/java/org/offeringprotocol/odp/core/OdpAddresses.java create mode 100644 odp-core/src/main/java/org/offeringprotocol/odp/core/OdpMediaType.java create mode 100644 odp-core/src/main/java/org/offeringprotocol/odp/core/OdpResponseLimitException.java create mode 100644 odp-core/src/main/java/org/offeringprotocol/odp/core/SearchCatalog.java create mode 100644 odp-directory/src/main/java/org/offeringprotocol/odp/directory/BoundedBodySubscriber.java create mode 100644 odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryOrigins.java create mode 100644 odp-directory/src/test/java/org/offeringprotocol/odp/directory/BoundedBodySubscriberTest.java create mode 100644 odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryModelsTest.java create mode 100644 odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryOriginsTest.java create mode 100644 odp-directory/src/test/java/org/offeringprotocol/odp/directory/ResultConformanceTest.java create mode 100644 odp-directory/src/test/java/org/offeringprotocol/odp/directory/Stub.java create mode 100644 odp-directory/src/test/java/org/offeringprotocol/odp/directory/TransportConformanceTest.java create mode 100644 odp-json-jackson3/src/test/java/org/offeringprotocol/odp/core/OdpMediaTypeTest.java create mode 100644 odp-json-jackson3/src/test/java/org/offeringprotocol/odp/core/OdpPaginationTest.java create mode 100644 odp-json-jackson3/src/test/java/org/offeringprotocol/odp/core/SearchCatalogTest.java create mode 100644 odp-service/src/test/java/org/offeringprotocol/odp/service/CachingConformanceTest.java create mode 100644 odp-service/src/test/java/org/offeringprotocol/odp/service/Catalog.java create mode 100644 odp-service/src/test/java/org/offeringprotocol/odp/service/EdgeCaseTest.java create mode 100644 odp-service/src/test/java/org/offeringprotocol/odp/service/LanguageConformanceTest.java delete mode 100644 odp-service/src/test/java/org/offeringprotocol/odp/service/OdpServiceTest.java create mode 100644 odp-service/src/test/java/org/offeringprotocol/odp/service/ProblemConformanceTest.java create mode 100644 odp-service/src/test/java/org/offeringprotocol/odp/service/RepresentationConformanceTest.java create mode 100644 odp-service/src/test/java/org/offeringprotocol/odp/service/SearchValidationTest.java create mode 100644 odp-service/src/test/java/org/offeringprotocol/odp/service/ServiceDocumentTest.java create mode 100644 odp-service/src/test/java/org/offeringprotocol/odp/service/StaticCatalogTest.java create mode 100644 odp-service/src/test/java/org/offeringprotocol/odp/service/TransportConformanceTest.java diff --git a/README.md b/README.md index 2c66bab..6bdacd7 100644 --- a/README.md +++ b/README.md @@ -189,14 +189,16 @@ and state-changing requests. ## Runtime boundaries -Applications own persistent caching, authentication context, authorization, catalog persistence, +Applications own HTTP response caching, authentication context, authorization, catalog persistence, indexing, rate limiting, and Action execution. The clients enforce ODP document validation, same-origin redirect and continuation rules, response-size limits, and fixed production or sandbox directory selection. `OdpServiceClient` fetches and validates its Service Document when the client is created and retains -that inspection for the client's lifetime. The Java SDK does not maintain a persistent cache or -refresh a live client automatically; applications choose when to reuse or recreate clients. +that inspection for the client's lifetime. The clients have no built-in HTTP response cache, in memory +or on disk, and do not refresh a live client automatically. Applications choose when to reuse or +recreate clients. Application-provided caches must honor HTTP cache directives and validators and +keep responses isolated by authentication context. ## Runnable examples diff --git a/examples/src/main/java/org/offeringprotocol/odp/examples/SmallService.java b/examples/src/main/java/org/offeringprotocol/odp/examples/SmallService.java index 80e8075..aebfb53 100644 --- a/examples/src/main/java/org/offeringprotocol/odp/examples/SmallService.java +++ b/examples/src/main/java/org/offeringprotocol/odp/examples/SmallService.java @@ -43,6 +43,7 @@ public static void main(String[] arguments) throws IOException { .filter(offering -> (offering.name() + " " + offering.description()) .toLowerCase(Locale.ROOT) .contains(normalized)) + .map(offering -> embedded(offering, "full".equals(request.representation()))) .toList(); return new Page<>(null, Odp.VERSION, matches, null, Map.of()); })); @@ -83,8 +84,12 @@ private static void handle(OdpService service, HttpExchange exchange) throws IOE OdpHttpResponse response = service.handle(request); response.headers().forEach(exchange.getResponseHeaders()::set); byte[] body = response.body().getBytes(StandardCharsets.UTF_8); - exchange.sendResponseHeaders(response.status(), body.length); - exchange.getResponseBody().write(body); + // This server writes Content-Length itself, and a 304 or a HEAD carries no body at all. + exchange.getResponseHeaders().remove("Content-Length"); + exchange.sendResponseHeaders(response.status(), body.length == 0 ? -1 : body.length); + if (body.length > 0) { + exchange.getResponseBody().write(body); + } exchange.close(); System.out.printf( // NOPMD - Request logging makes the example observable. "%s %s -> %d%n", request.method(), exchange.getRequestURI(), response.status()); @@ -104,6 +109,31 @@ private static Map> query(String rawQuery) { return result; } + /** + * One item of a search page. VER-04: it inherits the version of the page carrying it, so it does + * not restate one. OFR-55: a Terse Offering advertises its Actions through {@code detail_fields} + * rather than carrying them. + */ + private static Offering embedded(Offering value, boolean full) { + return new Offering( + value.authExpands(), + null, + value.id(), + value.name(), + value.description(), + value.images(), + value.language(), + value.localizations(), + value.webUrl(), + value.collectionIds(), + value.price(), + value.schema(), + value.attributes(), + full ? value.actions() : null, + full || value.actions() == null ? null : List.of("/actions"), + value.additional()); + } + private static Collection collection() { return new Collection( null, diff --git a/examples/src/test/java/org/offeringprotocol/odp/examples/SearchIntegrationTest.java b/examples/src/test/java/org/offeringprotocol/odp/examples/SearchIntegrationTest.java new file mode 100644 index 0000000..e5ea97f --- /dev/null +++ b/examples/src/test/java/org/offeringprotocol/odp/examples/SearchIntegrationTest.java @@ -0,0 +1,102 @@ +package org.offeringprotocol.odp.examples; + +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 com.sun.net.httpserver.HttpServer; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.offeringprotocol.odp.agent.OdpRequestException; +import org.offeringprotocol.odp.agent.OdpServiceClient; +import org.offeringprotocol.odp.core.AuthenticationRequirement; +import org.offeringprotocol.odp.core.OdpJson; +import org.offeringprotocol.odp.core.OdpOperation; +import org.offeringprotocol.odp.core.SearchCatalog; +import org.offeringprotocol.odp.service.OdpHttpRequest; +import org.offeringprotocol.odp.service.OdpService; +import org.offeringprotocol.odp.service.StaticCatalog; + +class SearchIntegrationTest { + @Test + void agentAndServiceAgreeOnStructuredSearchOverHttp() throws Exception { + var capabilities = OdpJson.parseSearchCapabilities(""" + {"filters":{"inline":[{"id":"size","title":"Size","description":"Size", + "type":"integer","operators":["eq"],"refinable":true}]}} + """); + var catalog = new SearchCatalog(capabilities.filters().inline(), List.of()); + AtomicInteger searches = new AtomicInteger(); + var endpoints = new EnumMap(StaticCatalog.create(List.of(), List.of())); + endpoints.put( + OdpOperation.SEARCH_OFFERINGS, + new OdpService.Endpoint(AuthenticationRequirement.NOT_REQUIRED, request -> { + searches.incrementAndGet(); + return OdpJson.parseOfferingSearchResponse(""" + {"odp_version":"1.0","items":[{"id":"item","name":"Item"}], + "refinements":[{"filter_id":"size","values":[{"value":1,"count":1}]}]} + """); + })); + var service = OdpService.builder("Catalog", "Searchable catalog", "en", "/odp") + .endpoints(endpoints) + .searchCapabilities(capabilities) + .searchCatalog(request -> catalog) + .build(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + try { + Map> query = new LinkedHashMap<>(); + String raw = exchange.getRequestURI().getRawQuery(); + if (raw != null) + for (String pair : raw.split("&")) { + String[] parts = pair.split("=", 2); + query.computeIfAbsent( + URLDecoder.decode(parts[0], StandardCharsets.UTF_8), key -> new ArrayList<>()) + .add(parts.length == 2 ? URLDecoder.decode(parts[1], StandardCharsets.UTF_8) : ""); + } + var response = service.handle(new OdpHttpRequest( + exchange.getRequestMethod(), + exchange.getRequestURI().getPath(), + query, + exchange.getRequestHeaders(), + new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8))); + response.headers() + .forEach((name, value) -> exchange.getResponseHeaders().set(name, value)); + byte[] body = response.body().getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(response.status(), body.length); + exchange.getResponseBody().write(body); + } finally { + exchange.close(); + } + }); + server.start(); + try { + var client = OdpServiceClient.create( + URI.create("http://127.0.0.1:" + server.getAddress().getPort()), + OdpServiceClient.localDevelopmentTransport()); + var request = OdpJson.parseOfferingSearchRequest( + "{\"odp_version\":\"1.0\",\"query\":\"item\",\"refinements\":[\"size\"]}"); + var result = client.searchOfferingsDetails(request, "terse", "en"); + assertTrue(result.issues().isEmpty()); + assertEquals("item", result.page().items().get(0).id()); + assertEquals("integer", result.refinements().get(0).filter().type()); + var invalid = OdpJson.parseOfferingSearchRequest( + "{\"odp_version\":\"1.0\",\"filters\":[{\"id\":\"size\",\"operator\":\"eq\",\"value\":1.5}]}"); + assertEquals( + 400, + assertThrows(OdpRequestException.class, () -> client.searchOfferings(invalid, "terse", "en")) + .status()); + assertEquals(1, searches.get()); + } finally { + server.stop(0); + } + } +} diff --git a/odp-agent/README.md b/odp-agent/README.md index 3ca98f7..969ba89 100644 --- a/odp-agent/README.md +++ b/odp-agent/README.md @@ -80,7 +80,17 @@ same-origin redirects and bounds Service Document and catalog response bodies. The Service Document is fetched once during `OdpServiceClient.create(...)` and retained for that client's lifetime. Recreate the client when the application needs a refreshed Service Document. -The SDK does not maintain a persistent cache. +The client has no built-in HTTP response cache, in memory or on disk. Catalog and supporting-document +requests use the transport on each call. Applications that add caching must honor HTTP cache +directives and validators and keep responses isolated by authentication context. The retained +Service inspection is a snapshot, not a freshness-managed HTTP cache. + +Read-only ODP requests retry HTTP 429 and 503 at most three times within a 30-second retry +window. `Retry-After` is honored when its delay fits within that window. A 503 without that header +uses exponential backoff with jitter. Missing delay information on 429, malformed delays, +non-transient responses, and transport failures return without automatic retry. Interruption stops +waiting. This policy also applies to supporting-document retrieval; it never executes or retries +an Offering Action. ## Navigate Collections and Offerings @@ -96,7 +106,8 @@ Page offerings = service.listOfferings("terse", 25, "en"); Offering details = service.getOffering("rubber-plant", "full", "en"); ``` -Representation is `terse` or `full`; passing `null` selects `terse`. Language is sent through +Representation is `terse` or `full`. Passing `null` selects `full` for individual Offering and +Collection retrieval, and `terse` for list and search methods. Language is sent through `Accept-Language` when it is nonblank. Limits must be from 1 through 100. Search requests preserve the protocol's structured filters, Collection scope, sort identifier, @@ -148,8 +159,11 @@ Attribute Schema processing is limited to 256 KiB per document, 16 documents, ei levels, and one MiB for the complete graph. Each Attribute Schema request has a 30-second timeout and accepts at most 16 JSON nesting levels. These are fixed SDK safety ceilings. -Actions are normalized to absolute compact HTTP or OpenAPI targets. Resolve the supporting document -for one explicitly selected Action without invoking it: +Actions are normalized to absolute compact HTTP or OpenAPI targets. +Invalid descriptors and duplicate Action identifiers are omitted and reported through +`OfferingDetails.issues()`. Unrelated Actions and Offering fields remain available. + +Resolve the supporting document for one explicitly selected Action without invoking it: ```java ResolvedAction action = service.resolveAction("rubber-plant", "purchase", "en"); @@ -165,39 +179,98 @@ Compact HTTP request schemas follow the same bounded resolution rules as Attribu targets require a JSON OpenAPI 3.1 document containing exactly one matching `operationId`; each OpenAPI document is limited to one MiB and 32 JSON nesting levels. +## Search with advertised capabilities + +Use `resolveSearchCapabilities(collectionId, language)` to obtain indexed Filter Definitions, +Sort Definitions with their resolved Filters, and scoped issues. Pass `null` for the Collection +to use only Service-wide definitions. The resolver does not visit ancestors or descendants. +Linked sources use the Service transport, including its authentication handling, and are accepted +only after all pages have been validated. Invalid sources do not discard valid sources. + +```java +SearchCapabilityResult capabilities = service.resolveSearchCapabilities(null, "en"); +capabilities.catalog().filters().forEach((id, definition) -> showFilter(definition)); + +SearchRequests.Offerings request = new SearchRequests.Offerings( + "1.0", "office plants", null, null, null, null, null, 20); +OfferingSearchDetails result = service.searchOfferingsDetails(request, "terse", "en"); +``` + +`searchOfferingsDetails` resolves capabilities, validates the request against them, and returns +Offerings together with normalized refinements and issues. Invalid refinement groups are omitted +without discarding the Offerings or valid groups. Each normalized group includes its Filter +Definition, so the caller can interpret the values and units without joining identifiers itself. +Scoped HTTP failures retain the status, headers, and parsed problem in `responseFailure()`; the SDK +does not enroll, pay, or automatically retry them. Thread interruption stops resolution. + +The lower-level `searchOfferings` sends the supplied request without fetching capability sources. +It validates the response structure and requested refinement identifiers, but does not perform +definition-dependent validation. Callers managing their own capability catalog can use +`SearchCatalog.validateRequest` and `validateRefinements` directly. Catalogs are request-context +dependent: do not reuse them across different Services, selected Collections, or access contexts. +Create a fresh Service client when changing its authentication context; its Service Document is +the snapshot retrieved during inspection. + ## Continue a response Continuation values are opaque. Pass `next` unchanged to the matching continuation method: ```java Page page = service.listOfferings("terse", 25, "en"); +consume(page.items()); while (page.next() != null) { - page = service.continueOfferings(page.next(), "en"); + page = service.continueOfferings(page.next(), "terse", "en"); consume(page.items()); } ``` +Pass the original representation explicitly so each continuation response receives the same +validation. The client does not interpret or rewrite the opaque URL to recover this selection. Use `continueCollections` for Collection pages. `OdpPagination` in Core can collect a bounded traversal and rejects loops after at most 16 pages. Applications following pages directly should apply their own total page and item limits. +To consume items incrementally without an asynchronous framework: + +```java +Iterator offerings = OdpPagination.iterate( + () -> service.listOfferings("terse", 25, "en"), + next -> service.continueOfferings(next, "terse", "en"), + 100); +while (offerings.hasNext()) { + consume(offerings.next()); +} +``` + +The iterator blocks while fetching a page, does not prefetch, and stops after the total item limit. +Stop calling it to stop fetching. Previously delivered items remain usable if a subsequent page +fails; the iterator rethrows that failure without fetching again. The same helper accepts Collection +pages. For Offering search, supply `() -> service.searchOfferings(request, "terse", "en").asPage()` +as the first loader; subsequent pages use `continueOfferings`. Refinement groups remain on the original +`OfferingPage`, not on individual iterated items. Applications own asynchronous wrapping and must +not use an iterator concurrently. + ## Authentication and payment transport The default client performs anonymous HTTP requests. ODP advertises authentication requirements and payment protocols but does not implement AEP, MPP, or x402 credentials in this module. -Supply `OdpTransport` when the application needs to control the HTTP stack. This complete example -uses a dedicated JDK client without adding credentials: +The built-in transport uses Apache HttpClient 5 internally. It validates every resolved address, +connects to a validated address without a second DNS lookup, and verifies the connected peer before +sending the HTTP request. HTTPS certificate and hostname verification remain enabled. System proxies, +automatic redirects, automatic retries, and cookie storage are disabled. Response bodies are bounded +while reading, including decompressed bodies and error responses. Connections are pooled by hostname +and pinned address. If connection establishment fails, another validated address can be attempted +within the request timeout, after repeating DNS validation. An HTTP request is not replayed after a +response or a failure while sending or reading it. -```java -HttpClient httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(10)) - .followRedirects(HttpClient.Redirect.NEVER) - .build(); +Apache's runtime dependencies are HttpCore, HttpCore HTTP/2, and the SLF4J API. No Kotlin runtime or +logging backend is required. Its types are not part of the ODP public API. + +Use the built-in transport explicitly when composing clients: -OdpTransport transport = request -> httpClient.send( - request, - HttpResponse.BodyHandlers.ofByteArray()); +```java +OdpTransport transport = OdpServiceClient.defaultTransport(); OdpServiceClient service = OdpServiceClient.create( URI.create("https://service.example"), @@ -205,11 +278,16 @@ OdpServiceClient service = OdpServiceClient.create( ``` The transport receives the complete ODP `HttpRequest` and must return an -`HttpResponse`. An application that supports AEP, MPP, or x402 replaces the lambda with its +`HttpResponse`. An application that supports AEP, MPP, or x402 supplies its protocol-aware transport and performs challenge handling before returning the final response. Keep credentials scoped to the intended Service and authenticated principal. `OdpAgent` accepts a `ServiceClientFactory` when federated discovery needs the same custom transport for each Service. +Custom transports are responsible for the same destination and credential protections. Override +`send(request, maximumBytes)` to enforce the byte budget during the download; the compatibility +default delegates to `send(request)` and cannot prevent that implementation from buffering too much +data. Client-side checks still reject an oversized returned body. + Attribute Schema, Action request-schema, and OpenAPI requests use the default anonymous transport so credentials added by the catalog transport are not forwarded to supporting-resource origins. Supply an explicit anonymous supporting transport as the third argument when the application needs to @@ -236,6 +314,11 @@ headers, and parsed ODP Problem Details when supplied. Invalid protocol document `OdpValidationException`. Invalid local arguments use `IllegalArgumentException`; unsupported operations and transport-boundary failures use `IllegalStateException`. +Response byte and nesting-depth limits throw `OdpResponseLimitException` with +`code()` equal to `RESPONSE_LIMIT_EXCEEDED` and `retryable()` equal to `false`. +This is a local rejection, not an HTTP error. Attribute Schema limit failures remain +scoped issues: affected attributes are omitted, while other Offering fields remain usable. + ## Related documentation - [Directory integration](../odp-directory/README.md) diff --git a/odp-agent/pom.xml b/odp-agent/pom.xml index 00fb6d2..381f21f 100644 --- a/odp-agent/pom.xml +++ b/odp-agent/pom.xml @@ -19,6 +19,11 @@ + + org.apache.httpcomponents.client5 + httpclient5 + ${httpclient5.version} + ${project.groupId} odp-core diff --git a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/ApacheTransport.java b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/ApacheTransport.java new file mode 100644 index 0000000..9d12cb4 --- /dev/null +++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/ApacheTransport.java @@ -0,0 +1,339 @@ +package org.offeringprotocol.odp.agent; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.Socket; +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.ByteBuffer; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Flow; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSession; +import org.apache.hc.client5.http.ConnectTimeoutException; +import org.apache.hc.client5.http.HttpHostConnectException; +import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; +import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.DefaultHttpClientConnectionOperator; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.client5.http.impl.routing.DefaultRoutePlanner; +import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.client5.http.ssl.ClientTlsStrategyBuilder; +import org.apache.hc.client5.http.ssl.TlsSocketStrategy; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.config.RegistryBuilder; +import org.apache.hc.core5.http.impl.io.HttpRequestExecutor; +import org.apache.hc.core5.http.io.HttpClientConnection; +import org.apache.hc.core5.http.io.HttpResponseInformationCallback; +import org.apache.hc.core5.http.io.entity.ByteArrayEntity; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.pool.PoolConcurrencyPolicy; +import org.apache.hc.core5.pool.PoolReusePolicy; +import org.apache.hc.core5.util.TimeValue; +import org.apache.hc.core5.util.Timeout; +import org.offeringprotocol.odp.core.OdpResponseLimitException; + +final class ApacheTransport implements OdpTransport { + private static final int MAXIMUM_BYTES = 1_048_576; + private static final int MAXIMUM_PROBLEM_BYTES = 16_384; + private static final CloseableHttpClient CLIENT = createClient(null); + private static final ScheduledThreadPoolExecutor DEADLINES = createDeadlines(); + private final boolean allowLocalNetwork; + private final CloseableHttpClient client; + + static CloseableHttpClient createClient(SSLContext sslContext) { + var tls = ClientTlsStrategyBuilder.create().setSslContext(sslContext).buildClassic(); + var operator = new DefaultHttpClientConnectionOperator( + ignored -> new Socket(Proxy.NO_PROXY), + null, + null, + RegistryBuilder.create() + .register("https", tls) + .build()); + var manager = new PoolingHttpClientConnectionManager( + operator, PoolConcurrencyPolicy.STRICT, PoolReusePolicy.LIFO, TimeValue.ofMinutes(1), null); + manager.setDefaultConnectionConfig(ConnectionConfig.custom() + .setConnectTimeout(Timeout.ofSeconds(10)) + .setSocketTimeout(Timeout.ofSeconds(30)) + .setTimeToLive(TimeValue.ofMinutes(1)) + .build()); + return HttpClients.custom() + .setConnectionManager(manager) + .setRoutePlanner(new DefaultRoutePlanner(null)) + .setRequestExecutor(new PeerCheckingExecutor()) + .disableAutomaticRetries() + .disableRedirectHandling() + .disableCookieManagement() + .disableAuthCaching() + .build(); + } + + ApacheTransport(boolean allowLocalNetwork) { + this(allowLocalNetwork, CLIENT); + } + + ApacheTransport(boolean allowLocalNetwork, CloseableHttpClient client) { + this.allowLocalNetwork = allowLocalNetwork; + this.client = client; + } + + private static ScheduledThreadPoolExecutor createDeadlines() { + var executor = new ScheduledThreadPoolExecutor(1, runnable -> { + Thread thread = new Thread(runnable, "odp-http-deadlines"); + thread.setDaemon(true); + return thread; + }); + executor.setRemoveOnCancelPolicy(true); + return executor; + } + + @Override + public HttpResponse send(HttpRequest request) throws IOException, InterruptedException { + return send(request, MAXIMUM_BYTES); + } + + @Override + public HttpResponse send(HttpRequest request, int maximumBytes) throws IOException, InterruptedException { + if (maximumBytes < 0 || maximumBytes > MAXIMUM_BYTES) { + throw new IllegalArgumentException("ODP response byte limit must be between 0 and " + MAXIMUM_BYTES); + } + if (Thread.currentThread().isInterrupted()) { + throw new InterruptedException("ODP request was interrupted"); + } + URI uri = request.uri(); + if (uri.getRawUserInfo() != null || uri.getRawFragment() != null) { + throw new IllegalArgumentException("ODP request URL must not contain credentials or a fragment"); + } + var addresses = SecureDestinations.resolve(uri, allowLocalNetwork); + if (!"https".equalsIgnoreCase(uri.getScheme()) + && !(allowLocalNetwork + && "http".equalsIgnoreCase(uri.getScheme()) + && addresses[0].isLoopbackAddress())) { + throw new IllegalArgumentException("ODP request URL must use HTTPS outside local development"); + } + Duration timeout = request.timeout().orElse(Duration.ofSeconds(30)); + byte[] payload = request.bodyPublisher().isPresent() ? body(request, timeout) : null; + long started = System.nanoTime(); + IOException failure = null; + for (int index = 0; index < addresses.length; index++) { + InetAddress address = addresses[index]; + if (index > 0 + && !Arrays.asList(SecureDestinations.resolve(uri, allowLocalNetwork)) + .contains(address)) { + continue; + } + long remaining = timeout.toNanos() - (System.nanoTime() - started); + if (remaining <= 0) { + throw new java.net.http.HttpTimeoutException("ODP request timed out"); + } + try { + return sendTo(request, maximumBytes, address, payload, Duration.ofNanos(remaining)); + } catch (HttpHostConnectException | ConnectTimeoutException exception) { + // Only connection establishment failures qualify; an HTTP request is never replayed. + failure = exception; + } + } + throw new IOException("ODP connection could not be established", failure); + } + + private HttpResponse sendTo( + HttpRequest request, int maximumBytes, InetAddress address, byte[] payload, Duration timeout) + throws IOException, InterruptedException { + URI uri = request.uri(); + // Supplying the address prevents the connector from resolving the hostname a second time. + HttpHost target = new HttpHost(uri.getScheme(), address, uri.getHost(), uri.getPort()); + HttpUriRequestBase outgoing = new HttpUriRequestBase(request.method(), uri); + outgoing.setConfig(RequestConfig.custom() + .setAuthenticationEnabled(false) + .setConnectionRequestTimeout(Timeout.of(timeout)) + .setResponseTimeout(Timeout.of(timeout)) + .build()); + request.headers().map().forEach((name, values) -> { + if (!"Content-Length".equalsIgnoreCase(name) + && !"Transfer-Encoding".equalsIgnoreCase(name) + && !"Host".equalsIgnoreCase(name)) { + values.forEach(value -> outgoing.addHeader(name, value)); + } + }); + if (payload != null && payload.length > 0) { + outgoing.setEntity(new ByteArrayEntity(payload, null)); + } + Thread caller = Thread.currentThread(); + long started = System.nanoTime(); + long budget = timeout.toNanos(); + var deadline = DEADLINES.scheduleAtFixedRate( + () -> { + if (caller.isInterrupted() || System.nanoTime() - started >= budget) { + outgoing.cancel(); + } + }, + Math.min(budget, TimeUnit.MILLISECONDS.toNanos(25)), + TimeUnit.MILLISECONDS.toNanos(25), + TimeUnit.NANOSECONDS); + try { + HttpResponse result = client.execute(target, outgoing, response -> { + try { + return read(request, response, maximumBytes); + } catch (IOException | RuntimeException exception) { + // Abort before response closure: closing a live entity may otherwise drain it. + outgoing.cancel(); + throw exception; + } + }); + if (caller.isInterrupted()) { + throw new InterruptedException("ODP request was interrupted"); + } + return result; + } catch (IOException exception) { + if (caller.isInterrupted()) { + InterruptedException interrupted = new InterruptedException("ODP request was interrupted"); + interrupted.initCause(exception); + throw interrupted; + } + throw exception; + } finally { + deadline.cancel(false); + } + } + + private static byte[] body(HttpRequest request, Duration timeout) throws IOException, InterruptedException { + var subscriber = HttpResponse.BodySubscribers.ofByteArray(); + var subscription = new AtomicReference(); + request.bodyPublisher().orElseThrow().subscribe(new Flow.Subscriber() { + @Override + public void onSubscribe(Flow.Subscription value) { + subscription.set(value); + subscriber.onSubscribe(value); + } + + @Override + public void onNext(ByteBuffer value) { + subscriber.onNext(List.of(value)); + } + + @Override + public void onError(Throwable failure) { + subscriber.onError(failure); + } + + @Override + public void onComplete() { + subscriber.onComplete(); + } + }); + try { + return subscriber.getBody().toCompletableFuture().get(timeout.toNanos(), TimeUnit.NANOSECONDS); + } catch (ExecutionException | TimeoutException exception) { + throw new IOException("ODP request body could not be read", exception); + } finally { + if (subscription.get() != null) { + subscription.get().cancel(); + } + } + } + + private static HttpResponse read(HttpRequest request, ClassicHttpResponse response, int maximumBytes) + throws IOException { + int status = response.getCode(); + int limit = status >= 200 && status < 300 ? maximumBytes : Math.min(maximumBytes, MAXIMUM_PROBLEM_BYTES); + var entity = response.getEntity(); // NOPMD CloseResource - response handler owns closure after cancellation on + // failure. + byte[] bytes = new byte[0]; + if (entity != null) { + if (entity.getContentLength() > limit) { + throw new OdpResponseLimitException("ODP response exceeds its byte limit"); + } + // The entity stream is decoded by HttpClient; compressed bodies have the same budget. + bytes = entity.getContent().readNBytes(limit + 1); + if (bytes.length > limit) { + throw new OdpResponseLimitException("ODP response exceeds its byte limit"); + } + } + Map> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (var header : response.getHeaders()) { + headers.computeIfAbsent(header.getName(), ignored -> new ArrayList<>()) + .add(header.getValue()); + } + return new Response(request, status, HttpHeaders.of(headers, (name, value) -> true), bytes); + } + + private static final class PeerCheckingExecutor extends HttpRequestExecutor { + @Override + public ClassicHttpResponse execute( + ClassicHttpRequest request, + HttpClientConnection connection, + HttpResponseInformationCallback callback, + HttpContext context) + throws IOException, HttpException { + var expected = HttpClientContext.cast(context) + .getHttpRoute() + .getTargetHost() + .getAddress(); + if (!(connection.getRemoteAddress() instanceof InetSocketAddress peer) + || expected == null + || !expected.equals(peer.getAddress())) { + throw new IOException("ODP connected peer does not match its validated destination"); + } + return super.execute(request, connection, callback, context); + } + } + + private record Response(HttpRequest request, int statusCode, HttpHeaders headers, byte[] content) + implements HttpResponse { + private Response { + content = content.clone(); + } + + @Override + public byte[] content() { + return content.clone(); + } + + @Override + public byte[] body() { + return content.clone(); + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @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-agent/src/main/java/org/offeringprotocol/odp/agent/AttributeSchemaResolver.java b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/AttributeSchemaResolver.java index 54b4d27..6cad257 100644 --- a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/AttributeSchemaResolver.java +++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/AttributeSchemaResolver.java @@ -12,6 +12,7 @@ import org.offeringprotocol.odp.core.OdpJson; import org.offeringprotocol.odp.core.OdpJsonNode; import org.offeringprotocol.odp.core.OdpJsonSchema; +import org.offeringprotocol.odp.core.OdpResponseLimitException; final class AttributeSchemaResolver { private static final String DIALECT = "https://json-schema.org/draft/2020-12/schema"; @@ -45,17 +46,17 @@ private void load(URI target, int depth, SchemaGraph graph) { return; } if (graph.documents.size() >= MAXIMUM_DOCUMENTS) { - throw new IllegalStateException("ODP Attribute Schema graph exceeds 16 documents"); + throw new OdpResponseLimitException("ODP Attribute Schema graph exceeds 16 documents"); } if (depth > MAXIMUM_DEPTH) { - throw new IllegalStateException("ODP Attribute Schema graph exceeds eight reference levels"); + throw new OdpResponseLimitException("ODP Attribute Schema graph exceeds eight reference levels"); } OdpJsonNode document = client.get( target, "application/schema+json", Set.of("application/schema+json"), MAXIMUM_DOCUMENT_BYTES, 16); requireSchema(document); graph.bytes += document.toString().getBytes(StandardCharsets.UTF_8).length; if (graph.bytes > MAXIMUM_GRAPH_BYTES) { - throw new IllegalStateException("ODP Attribute Schema graph exceeds its byte limit"); + throw new OdpResponseLimitException("ODP Attribute Schema graph exceeds its byte limit"); } graph.documents.put(target, document); for (URI external : externalReferences(document, target)) { 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 index 4699952..8713173 100644 --- a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OdpServiceClient.java +++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OdpServiceClient.java @@ -2,7 +2,6 @@ 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; @@ -10,14 +9,16 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Set; import org.offeringprotocol.odp.core.Collection; import org.offeringprotocol.odp.core.Odp; import org.offeringprotocol.odp.core.OdpJson; import org.offeringprotocol.odp.core.OdpJsonNode; +import org.offeringprotocol.odp.core.OdpMediaType; import org.offeringprotocol.odp.core.OdpOperation; +import org.offeringprotocol.odp.core.OdpResponseLimitException; import org.offeringprotocol.odp.core.OdpUris; import org.offeringprotocol.odp.core.Offering; import org.offeringprotocol.odp.core.OfferingPage; @@ -28,43 +29,66 @@ /** Validated ODP Service inspection and catalog client. */ public final class OdpServiceClient { - private static final int MAXIMUM_BYTES = 2_097_152; + private static final String REFINEMENTS_FIELD = "refinements"; + private static final String FILTER_ID_FIELD = "filter_id"; + /** ERR-21 and SVC-83: the byte and nesting-depth budgets each class of document is read under. */ + private static final int MAXIMUM_DOCUMENT_BYTES = 65_536; + + private static final int MAXIMUM_DOCUMENT_DEPTH = 8; + private static final int MAXIMUM_RESOURCE_BYTES = 524_288; + private static final int MAXIMUM_RESOURCE_DEPTH = 16; + private static final int MAXIMUM_PROBLEM_BYTES = 16_384; 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 static final String MEDIA_TYPE = "application/odp+json"; + private static final String TERSE = "terse"; + private static final String FULL = "full"; private final OdpTransport transport; private final ActionResolver actionResolver; private final AttributeSchemaResolver schemaResolver; private final String serviceOrigin; private final ServiceInspection serviceInspection; + private final OdpJsonNode advertisedSearchCapabilities; private OdpServiceClient( OdpTransport transport, ActionResolver actionResolver, AttributeSchemaResolver schemaResolver, String serviceOrigin, - ServiceInspection inspection) { + ServiceInspection inspection, + OdpJsonNode advertisedSearchCapabilities) { this.transport = transport; this.actionResolver = actionResolver; this.schemaResolver = schemaResolver; this.serviceOrigin = serviceOrigin; this.serviceInspection = inspection; + this.advertisedSearchCapabilities = advertisedSearchCapabilities; + } + + /** + * A transport that resolves every destination and refuses any address the IANA special-purpose + * registries mark as non-public (SEC-08). This is what {@link #create(URI)} uses. + */ + public static OdpTransport defaultTransport() { + return SecureDestinations.transport(false); + } + + /** + * A transport that additionally reaches a loopback Service, for running against a Service on the + * same machine. It still refuses every other non-public address, and refuses a loopback name + * that resolves anywhere else. + */ + public static OdpTransport localDevelopmentTransport() { + return SecureDestinations.transport(true); } public static OdpServiceClient create(URI serviceUri) { - return create( - serviceUri, request -> DEFAULT_HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofByteArray())); + return create(serviceUri, defaultTransport()); } public static OdpServiceClient create(URI serviceUri, OdpTransport transport) { - return create( - serviceUri, - transport, - request -> DEFAULT_HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofByteArray())); + return create(serviceUri, transport, defaultTransport()); } public static OdpServiceClient create(URI serviceUri, OdpTransport transport, OdpTransport supportingTransport) { @@ -73,7 +97,7 @@ public static OdpServiceClient create(URI serviceUri, OdpTransport transport, Od Objects.requireNonNull(supportingTransport, "supportingTransport"); 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); + String json = request(transport, documentUri, GET, null, null, MAXIMUM_DOCUMENT_BYTES, MAXIMUM_DOCUMENT_DEPTH); var document = OdpJson.parseAgentServiceDocument(json); Map operations = new LinkedHashMap<>(); for (OperationDescriptor operation : document.operations()) { @@ -86,59 +110,198 @@ public static OdpServiceClient create(URI serviceUri, OdpTransport transport, Od new ActionResolver(supportingClient, schemaResolver), schemaResolver, origin, - new ServiceInspection(origin, documentUri, document, Map.copyOf(operations))); + new ServiceInspection(origin, documentUri, document, Map.copyOf(operations)), + OdpJson.parseTree(json).get("search_capabilities")); } public ServiceInspection inspection() { return serviceInspection; } + public SearchCapabilityResult resolveSearchCapabilities(String collectionId, String language) { + OdpJsonNode collectionCapabilities = null; + List collectionIssues = new ArrayList<>(); + if (collectionId != null && serviceInspection.supports(OdpOperation.SEARCH_OFFERINGS)) { + try { + String json = requestOperation(OdpOperation.GET_COLLECTION, collectionId, FULL, null, language, null); + Collection collection = parseAgentCollection(json); + requireCollection(collection, FULL, false); + if (!collectionId.equals(collection.id())) + throw new IllegalArgumentException("Collection identity does not match request"); + collectionCapabilities = OdpJson.parseTree(json).get("search_capabilities"); + } catch (OdpRequestException exception) { + collectionIssues.add(new SearchCapabilityResult.Issue( + "collection", + "capabilities", + collectionId, + exception.getMessage(), + SearchCapabilityResult.HttpFailure.from(exception))); + } catch (IllegalArgumentException | IllegalStateException exception) { + if (Thread.currentThread().isInterrupted()) + throw new IllegalStateException("Capability resolution interrupted", exception); + collectionIssues.add(new SearchCapabilityResult.Issue( + "collection", "capabilities", collectionId, exception.getMessage())); + } + } + SearchCapabilityResult result = new SearchCapabilityResolver( + serviceOrigin, + uri -> request( + transport, uri, GET, null, language, MAXIMUM_RESOURCE_BYTES, MAXIMUM_RESOURCE_DEPTH)) + .resolve( + serviceInspection.supports(OdpOperation.SEARCH_OFFERINGS), + advertisedSearchCapabilities, + collectionCapabilities); + collectionIssues.addAll(result.issues()); + return new SearchCapabilityResult(result.catalog(), collectionIssues); + } + public Page listCollections(String representation, Integer limit, String language) { + String selected = requireRepresentation(representation); return collectionPage( - requestOperation(OdpOperation.LIST_COLLECTIONS, null, representation, limit, language, null)); + requestOperation(OdpOperation.LIST_COLLECTIONS, null, selected, limit, language, null), selected); } public Page searchCollections( SearchRequests.Collections request, String representation, String language) { - return collectionPage(requestOperation( - OdpOperation.SEARCH_COLLECTIONS, null, representation, null, language, OdpJson.write(request))); + String selected = requireRepresentation(representation); + return collectionPage( + requestOperation( + OdpOperation.SEARCH_COLLECTIONS, null, selected, null, language, OdpJson.write(request)), + selected); } public Collection getCollection(String id, String representation, String language) { - return parseAgentCollection( - requestOperation(OdpOperation.GET_COLLECTION, id, representation, null, language, null)); + String selected = requireRepresentation(representation == null ? FULL : representation); + Collection collection = + parseAgentCollection(requestOperation(OdpOperation.GET_COLLECTION, id, selected, null, language, null)); + requireCollection(collection, selected, false); + return collection; } public Page listCollectionOfferings( String collectionId, String representation, Integer limit, String language) { - return offeringPage(requestOperation( - OdpOperation.LIST_COLLECTION_OFFERINGS, collectionId, representation, limit, language, null)); + String selected = requireRepresentation(representation); + return offeringPage( + requestOperation(OdpOperation.LIST_COLLECTION_OFFERINGS, collectionId, selected, limit, language, null), + selected); } public Page listOfferings(String representation, Integer limit, String language) { - return offeringPage(requestOperation(OdpOperation.LIST_OFFERINGS, null, representation, limit, language, null)); + String selected = requireRepresentation(representation); + return offeringPage( + requestOperation(OdpOperation.LIST_OFFERINGS, null, selected, limit, language, null), selected); } public OfferingPage searchOfferings(SearchRequests.Offerings request, String representation, String language) { - OfferingPage page = parseAgentOfferingSearchResponse(requestOperation( - OdpOperation.SEARCH_OFFERINGS, null, representation, null, language, OdpJson.write(request))); - page.items().forEach(item -> requireSummary(item.id(), item.name(), "Offering")); + String selected = requireRepresentation(representation); + String json = + requestOperation(OdpOperation.SEARCH_OFFERINGS, null, selected, null, language, OdpJson.write(request)); + requireTerseActions(json, selected, true); + OfferingPage page = parseAgentOfferingSearchResponse(json); + page.items().forEach(item -> requireOffering(item, selected, true)); + org.offeringprotocol.odp.core.SearchCatalog.validateRefinementContext(page, request, false); return page; } public Offering getOffering(String id, String representation, String language) { - return parseAgentOffering( - requestOperation(OdpOperation.GET_OFFERING, id, representation, null, language, null)); + String selected = requireRepresentation(representation == null ? FULL : representation); + String json = requestOperation(OdpOperation.GET_OFFERING, id, selected, null, language, null); + requireTerseActions(json, selected, false); + Offering offering = parseAgentOffering(json); + requireOffering(offering, selected, false); + return offering; + } + + public OfferingSearchDetails searchOfferingsDetails( + SearchRequests.Offerings search, String representation, String language) { + OdpJson.parseOfferingSearchRequest(OdpJson.write(search)); + SearchCapabilityResult capabilities = resolveSearchCapabilities(search.collectionId(), language); + capabilities.catalog().validateRequest(search); + String selected = requireRepresentation(representation); + String json = + requestOperation(OdpOperation.SEARCH_OFFERINGS, null, selected, null, language, OdpJson.write(search)); + requireTerseActions(json, selected, true); + OdpJsonNode document = OdpJson.parseTree(json); + OdpJsonNode groups = document.remove(REFINEMENTS_FIELD); + OfferingPage page = parseAgentOfferingSearchResponse(document.toString()); + page.items().forEach(item -> requireOffering(item, selected, true)); + List issues = new ArrayList<>(capabilities.issues()); + List resolved = new ArrayList<>(); + List accepted = new ArrayList<>(); + if (groups != null) { + normalizeRefinements(groups, search, capabilities, accepted, resolved, issues); + } + return new OfferingSearchDetails( + new OfferingPage( + page.authExpands(), + page.odpVersion(), + page.items(), + page.next(), + accepted.isEmpty() ? null : accepted, + page.additional()), + capabilities.catalog(), + resolved, + issues); + } + + private static void normalizeRefinements( + OdpJsonNode groups, + SearchRequests.Offerings search, + SearchCapabilityResult capabilities, + List accepted, + List resolved, + List issues) { + if (!groups.isArray() || groups.isEmpty() || groups.size() > 16) { + issues.add(new SearchCapabilityResult.Issue( + REFINEMENTS_FIELD, "groups", null, "Invalid refinement group array")); + return; + } + Map counts = new LinkedHashMap<>(); + for (OdpJsonNode group : groups) { + if (group.path(FILTER_ID_FIELD).isString()) + counts.merge(group.path(FILTER_ID_FIELD).asString(), 1, Integer::sum); + } + for (OdpJsonNode group : groups) { + String id = group.path(FILTER_ID_FIELD).isString() + ? group.path(FILTER_ID_FIELD).asString() + : null; + try { + if (id != null && counts.get(id) > 1) throw new IllegalArgumentException("Repeated refinement group"); + OfferingPage candidate = OdpJson.parseOfferingSearchResponse( + "{\"odp_version\":\"1.0\",\"items\":[],\"refinements\":[" + group + "]}"); + resolved.addAll(capabilities.catalog().validateRefinements(candidate, search, false)); + accepted.addAll(candidate.refinements()); + } catch (IllegalArgumentException exception) { + issues.add(new SearchCapabilityResult.Issue(REFINEMENTS_FIELD, "group", id, exception.getMessage())); + } + } } public OfferingDetails getOfferingDetails(String id, String language) { - Offering offering = getOffering(id, "full", language); + OdpJsonNode raw = + OdpJson.parseTree(requestOperation(OdpOperation.GET_OFFERING, id, FULL, null, language, null)); + Offering offering = parseAgentOffering(raw.toString()); + requireOffering(offering, FULL, false); String serviceOpenApiUrl = serviceInspection.document().http().openapi() == null ? null : serviceInspection.document().http().openapi().url(); ActionResolver.NormalizedActions normalized = actionResolver.normalize(offering.actions(), serviceOrigin, serviceOpenApiUrl); List issues = new ArrayList<>(normalized.issues()); + Set retainedIds = new java.util.HashSet<>(); + if (offering.actions() != null) offering.actions().forEach(action -> retainedIds.add(action.id())); + Set reportedIds = new java.util.HashSet<>(); + OdpJsonNode rawActions = raw.get("actions"); + if (rawActions != null && rawActions.isArray()) { + for (OdpJsonNode action : rawActions) { + String actionId = + action.path("id").isString() ? action.path("id").asString() : null; + if (!retainedIds.contains(actionId) && reportedIds.add(actionId)) { + issues.add(new OfferingIssue( + OfferingIssue.Scope.ACTION, "Invalid or duplicate Action descriptor", actionId)); + } + } + } Offering safeOffering = offering; OdpJsonNode attributeSchema = null; if (offering.schema() != null) { @@ -155,6 +318,8 @@ public OfferingDetails getOfferingDetails(String id, String language) { null)); } } catch (IllegalArgumentException | IllegalStateException exception) { + if (Thread.currentThread().isInterrupted()) + throw new IllegalStateException("Offering resolution interrupted", exception); safeOffering = withoutAttributes(offering); issues.add(new OfferingIssue(OfferingIssue.Scope.ATTRIBUTE_SCHEMA, exception.getMessage(), null)); } @@ -172,14 +337,21 @@ public ResolvedAction resolveAction(String offeringId, String actionId, String l return actionResolver.resolve(action, serviceOrigin); } - public Page continueCollections(String next, String language) { + public Page continueCollections(String next, String representation, String language) { + String selected = requireRepresentation(Objects.requireNonNull(representation, "representation")); URI target = OdpUris.resolveContinuation(next, serviceOrigin); - return collectionPage(request(transport, target, GET, null, language, MAXIMUM_BYTES)); + return collectionPage( + request(transport, target, GET, null, language, MAXIMUM_RESOURCE_BYTES, MAXIMUM_RESOURCE_DEPTH), + selected); } - public Page continueOfferings(String next, String language) { + public Page continueOfferings(String next, String representation, String language) { + String selected = requireRepresentation(Objects.requireNonNull(representation, "representation")); URI target = OdpUris.resolveContinuation(next, serviceOrigin); - return offeringPage(request(transport, target, GET, null, language, MAXIMUM_BYTES)); + String json = request(transport, target, GET, null, language, MAXIMUM_RESOURCE_BYTES, MAXIMUM_RESOURCE_DEPTH); + if (OdpJson.parseTree(json).has(REFINEMENTS_FIELD)) + throw new IllegalArgumentException("Continuation must omit refinements"); + return offeringPage(json, selected); } private String requestOperation( @@ -192,33 +364,79 @@ private String requestOperation( 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); + target = URI.create( + target + separator + "representation=" + representation + (limit == null ? "" : "&limit=" + limit)); + return request( + transport, target, operation.method(), body, language, MAXIMUM_RESOURCE_BYTES, MAXIMUM_RESOURCE_DEPTH); + } + + /** SVC-72: a request carries exactly one representation, and only a value ODP defines. */ + private static String requireRepresentation(String representation) { + String selected = representation == null ? TERSE : representation; + if (!TERSE.equals(selected) && !FULL.equals(selected)) { + throw new IllegalArgumentException("representation must be terse or full"); + } + return selected; } - private static Page collectionPage(String json) { + private static Page collectionPage(String json, String representation) { Page page = parseAgentCollectionPage(json); - page.items().forEach(item -> requireSummary(item.id(), item.name(), "Collection")); + page.items().forEach(item -> requireCollection(item, representation, true)); return page; } - private static Page offeringPage(String json) { + private static Page offeringPage(String json, String representation) { + requireTerseActions(json, representation, true); Page page = parseAgentOfferingPage(json); - page.items().forEach(item -> requireSummary(item.id(), item.name(), "Offering")); + page.items().forEach(item -> requireOffering(item, representation, true)); return page; } + private static void requireTerseActions(String json, String representation, boolean page) { + if (!TERSE.equals(representation)) return; + OdpJsonNode value = OdpJson.parseTree(json); + Iterable items = page ? value.path("items") : List.of(value); + for (OdpJsonNode item : items) { + if (item.has("actions")) throw new IllegalArgumentException("ODP Terse Offering cannot contain Actions"); + } + } + + private static void requireOffering(Offering offering, String representation, boolean nested) { + requireSummary(offering.id(), offering.name(), "Offering"); + requireNestedVersion(offering.odpVersion(), nested, "Offering"); + if (TERSE.equals(representation) + && offering.actions() != null + && !offering.actions().isEmpty()) { + throw new IllegalArgumentException("ODP Terse Offering cannot contain Actions"); + } + requireDetailFields(offering.detailFields(), representation, "Offering"); + } + + private static void requireCollection(Collection collection, String representation, boolean nested) { + requireSummary(collection.id(), collection.name(), "Collection"); + requireNestedVersion(collection.odpVersion(), nested, "Collection"); + requireDetailFields(collection.detailFields(), representation, "Collection"); + } + + private static void requireDetailFields(List detailFields, String representation, String resourceType) { + if (FULL.equals(representation) && detailFields != null && !detailFields.isEmpty()) { + throw new IllegalArgumentException("ODP Full " + resourceType + " cannot contain detail_fields"); + } + } + + /** VER-03: an item nested in a page inherits its container's version and cannot restate it. */ + private static void requireNestedVersion(String odpVersion, boolean nested, String resourceType) { + if (nested && odpVersion != null) { + throw new IllegalArgumentException("ODP page item " + resourceType + " cannot restate odp_version"); + } + } + 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"); @@ -252,27 +470,34 @@ private static OfferingPage parseAgentOfferingSearchResponse(String json) { } private static String request( - OdpTransport transport, URI target, String method, String body, String language, int maximumBytes) { + OdpTransport transport, + URI target, + String method, + String body, + String language, + int maximumBytes, + int maximumDepth) { URI current = target; String currentMethod = method; String currentBody = body; boolean hasBody = body != null; + ReadRetry retry = new ReadRetry(); 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"); + .header("Accept", MEDIA_TYPE + ", 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") + builder.header("Content-Type", MEDIA_TYPE) .method(currentMethod, HttpRequest.BodyPublishers.ofString(currentBody)); } HttpResponse response; try { - response = transport.send(builder.build()); + response = retry.send(transport, builder.build(), maximumBytes); } catch (IOException exception) { throw new IllegalStateException("ODP request failed", exception); } catch (InterruptedException exception) { @@ -297,32 +522,85 @@ private static String request( } current = next; } else { + boolean failure = status < 200 || status > 299; + int limit = failure ? MAXIMUM_PROBLEM_BYTES : maximumBytes; + // ERR-20: a declared length past the limit is refused before the body is read, and + // what did arrive is measured before it is decoded. + requireDeclaredLength(response, limit); byte[] bytes = response.body(); - if (bytes.length > maximumBytes) { - throw new IllegalStateException("ODP response exceeds its byte limit"); + if (bytes.length > limit) { + throw new OdpResponseLimitException("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(OdpJson.normalizeAgentResponse(text, "problem")); - } 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"); + if (failure) { + throw failureFor(status, text, response); } + requireMediaType(response); + requireDepth(text, maximumDepth); return text; } } throw new IllegalStateException("ODP request produced no response"); } + + private static void requireDeclaredLength(HttpResponse response, int limit) { + response.headers().firstValueAsLong("Content-Length").ifPresent(declared -> { + if (declared > limit) { + throw new OdpResponseLimitException("ODP response exceeds its byte limit"); + } + }); + } + + /** MED-08 and MED-09: the media-type essence is compared whole, and case-insensitively. */ + private static void requireMediaType(HttpResponse response) { + String contentType = response.headers().firstValue("Content-Type").orElse(""); + String essence = OdpMediaType.essence(contentType); + if (!MEDIA_TYPE.equals(essence)) { + throw new IllegalStateException("ODP response must use " + MEDIA_TYPE); + } + } + + /** ERR-18 and ERR-21: nesting is bounded so a document cannot be read deeper than the protocol allows. */ + private static void requireDepth(String json, int maximumDepth) { + if (depth(OdpJson.parseTree(json), maximumDepth) > maximumDepth) { + throw new OdpResponseLimitException("ODP response exceeds its nesting-depth limit"); + } + } + + private static int depth(OdpJsonNode value, int remaining) { + if (remaining <= 0) { + return 1; + } + int maximum = 1; + for (OdpJsonNode child : value) { + maximum = Math.max(maximum, 1 + depth(child, remaining - 1)); + } + return maximum; + } + + /** + * The Problem Details a failed response carried, when it describes the failure that occurred. A + * document whose stated status disagrees with the response is describing a different one, so the + * HTTP status stands alone instead. + */ + private static ProblemDetails problemFor(int status, String text) { + try { + ProblemDetails parsed = OdpJson.parseProblemDetails(OdpJson.normalizeAgentResponse(text, "problem")); + if (parsed.status() == status) { + return parsed; + } + } catch (IllegalArgumentException ignored) { + // The HTTP status remains available when a peer does not return ODP Problem Details. + } + return null; + } + + private static OdpRequestException failureFor(int status, String text, HttpResponse response) { + ProblemDetails problem = problemFor(status, text); + return new OdpRequestException( + status, + problem == null ? "ODP request failed with HTTP " + status : problem.title(), + response.headers(), + problem); + } } 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 index 28eff0a..51a311d 100644 --- a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OdpTransport.java +++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OdpTransport.java @@ -7,4 +7,9 @@ @FunctionalInterface public interface OdpTransport { HttpResponse send(HttpRequest request) throws IOException, InterruptedException; + + /** Custom transports must enforce this limit while reading, before buffering the entire body. */ + default HttpResponse send(HttpRequest request, int maximumBytes) throws IOException, InterruptedException { + return send(request); + } } diff --git a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OfferingSearchDetails.java b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OfferingSearchDetails.java new file mode 100644 index 0000000..5a7b789 --- /dev/null +++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/OfferingSearchDetails.java @@ -0,0 +1,16 @@ +package org.offeringprotocol.odp.agent; + +import java.util.List; +import org.offeringprotocol.odp.core.OfferingPage; +import org.offeringprotocol.odp.core.SearchCatalog; + +public record OfferingSearchDetails( + OfferingPage page, + SearchCatalog catalog, + List refinements, + List issues) { + public OfferingSearchDetails { + refinements = List.copyOf(refinements); + issues = List.copyOf(issues); + } +} diff --git a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/ReadRetry.java b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/ReadRetry.java new file mode 100644 index 0000000..f5b5dc3 --- /dev/null +++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/ReadRetry.java @@ -0,0 +1,118 @@ +package org.offeringprotocol.odp.agent; + +import java.io.IOException; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Locale; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.DoubleSupplier; +import java.util.function.LongSupplier; +import java.util.function.Supplier; + +/** One retry budget for a read-only ODP operation, including its redirects. */ +final class ReadRetry { + private static final long BUDGET_MILLIS = 30_000; + private static final int SINGLE_HEADER = 1; + private static final DateTimeFormatter RFC850 = + DateTimeFormatter.ofPattern("dd-MMM-uu HH:mm:ss zzz", Locale.ENGLISH); + private static final DateTimeFormatter ASCTIME = DateTimeFormatter.ofPattern( + "EEE MMM d HH:mm:ss uuuu", Locale.ENGLISH) + .withZone(ZoneOffset.UTC); + private final LongSupplier nanos; + private final Supplier clock; + private final Sleeper sleeper; + private final DoubleSupplier jitter; + private final long started; + private int retries; + + ReadRetry() { + this( + System::nanoTime, + Instant::now, + Thread::sleep, + () -> ThreadLocalRandom.current().nextDouble()); + } + + ReadRetry(LongSupplier nanos, Supplier clock, Sleeper sleeper, DoubleSupplier jitter) { + this.nanos = nanos; + this.clock = clock; + this.sleeper = sleeper; + this.jitter = jitter; + this.started = nanos.getAsLong(); + } + + HttpResponse send(OdpTransport transport, HttpRequest request, int maximumBytes) + throws IOException, InterruptedException { + while (true) { + if (Thread.currentThread().isInterrupted()) { + throw new InterruptedException("ODP request was interrupted"); + } + HttpResponse response = transport.send(request, maximumBytes); + int status = response.statusCode(); + if ((status != 429 && status != 503) || retries == 3) { + return response; + } + long delay = delay(response); + long elapsed = (nanos.getAsLong() - started) / 1_000_000; + if (delay < 0 || elapsed >= BUDGET_MILLIS || delay > BUDGET_MILLIS - elapsed) { + return response; + } + sleeper.sleep(delay); + // Scheduling delays must not turn a permitted wait into an unbounded retry window. + if ((nanos.getAsLong() - started) / 1_000_000 > BUDGET_MILLIS) { + return response; + } + retries++; + } + } + + private long delay(HttpResponse response) { + var values = response.headers().allValues("Retry-After"); + if (values.isEmpty()) { + return response.statusCode() == 503 ? (long) (1000L * (1L << retries) * jitter.getAsDouble()) : -1; + } + if (values.size() != SINGLE_HEADER) { + return -1; + } + String value = values.get(0).strip(); + try { + if (value.matches("[0-9]+")) { + return Math.multiplyExact(Long.parseLong(value), 1000L); + } + Instant target = httpDate(value); + long milliseconds = + Math.subtractExact(target.toEpochMilli(), clock.get().toEpochMilli()); + return Math.max(0, milliseconds); + } catch (NumberFormatException | ArithmeticException | DateTimeParseException exception) { + return -1; + } + } + + private Instant httpDate(String value) { + try { + return ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME) + .toInstant(); + } catch (DateTimeParseException exception) { + if (value.matches("[A-Za-z]+, [0-9]{2}-[A-Za-z]{3}-[0-9]{2} .*")) { + ZonedDateTime date = ZonedDateTime.parse(value.substring(value.indexOf(',') + 2), RFC850); + if (date.toInstant() + .isAfter( + clock.get().atZone(ZoneOffset.UTC).plusYears(50).toInstant())) { + date = date.minusYears(100); + } + return date.toInstant(); + } + return Instant.from(ASCTIME.parse(value.replaceAll(" +", " "))); + } + } + + @FunctionalInterface + interface Sleeper { + void sleep(long milliseconds) throws InterruptedException; + } +} diff --git a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/SearchCapabilityResolver.java b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/SearchCapabilityResolver.java new file mode 100644 index 0000000..ac9c789 --- /dev/null +++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/SearchCapabilityResolver.java @@ -0,0 +1,177 @@ +package org.offeringprotocol.odp.agent; + +import java.net.URI; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import org.offeringprotocol.odp.core.OdpJson; +import org.offeringprotocol.odp.core.OdpJsonNode; +import org.offeringprotocol.odp.core.OdpUris; +import org.offeringprotocol.odp.core.SearchCapabilities.FilterDefinition; +import org.offeringprotocol.odp.core.SearchCapabilities.SortDefinition; +import org.offeringprotocol.odp.core.SearchCatalog; + +final class SearchCapabilityResolver { + private static final String FILTERS_KIND = "filters"; + private static final String SORTS_KIND = "sorts"; + private static final int MAXIMUM_PAGES = 16; + private static final int MAXIMUM_PAGE_ITEMS = 100; + private static final int MAXIMUM_FILTERS = 1024; + private static final int MAXIMUM_SORTS = 128; + private final String origin; + private final Function loader; + private final List issues = new ArrayList<>(); + private final Map filters = new LinkedHashMap<>(); + private final Map sorts = new LinkedHashMap<>(); + private final Map sortScopes = new LinkedHashMap<>(); + private final Map> publishedIdentifiers = + Map.of(FILTERS_KIND, new HashSet<>(), SORTS_KIND, new HashSet<>()); + + SearchCapabilityResolver(String origin, Function loader) { + this.origin = origin; + this.loader = loader; + } + + SearchCapabilityResult resolve(boolean supported, OdpJsonNode service, OdpJsonNode collection) { + addScope("service", service, supported); + addScope("collection", collection, supported); + List resolvedFilters = filters.values().stream() + .map(value -> OdpJson.parseFilterDefinition(value.toString())) + .toList(); + List resolvedSorts = new ArrayList<>(); + sorts.forEach((id, value) -> { + SortDefinition sort = OdpJson.parseSortDefinition(value.toString()); + if (sort.keys().stream().anyMatch(key -> !filters.containsKey(key.filterId()))) { + issue(sortScopes.get(id), SORTS_KIND, id, "Sort references an unavailable Filter"); + } else { + resolvedSorts.add(sort); + } + }); + return new SearchCapabilityResult(new SearchCatalog(resolvedFilters, resolvedSorts), issues); + } + + private void addScope(String scope, OdpJsonNode capabilities, boolean supported) { + if (capabilities == null) return; + if (!supported || !capabilities.isObject() || capabilities.isEmpty()) { + issue( + scope, + "capabilities", + null, + "Search capabilities require a valid advertisement and search-offerings"); + return; + } + addSource(scope, FILTERS_KIND, capabilities.get(FILTERS_KIND), filters, MAXIMUM_FILTERS); + addSource(scope, SORTS_KIND, capabilities.get(SORTS_KIND), sorts, MAXIMUM_SORTS); + } + + private void addSource( + String scope, String kind, OdpJsonNode source, Map target, int maximum) { + if (source == null) return; + try { + boolean inline = source.has("inline"); + if (!source.isObject() || inline == source.has("linked")) { + throw new IllegalArgumentException("A capability source requires exactly one of inline or linked"); + } + List definitions; + if (inline) { + OdpJsonNode items = source.get("inline"); + int limit = FILTERS_KIND.equals(kind) ? 32 : 16; + if (!items.isArray() || items.isEmpty() || items.size() > limit) { + throw new IllegalArgumentException("Invalid inline capability source size"); + } + definitions = new ArrayList<>(); + items.forEach(definitions::add); + } else { + OdpJsonNode href = source.at("/linked/href"); + if (!href.isString()) throw new IllegalArgumentException("A linked source requires href"); + definitions = load(href.asString(), kind, maximum - target.size()); + } + Map accepted = new LinkedHashMap<>(); + Set identifiers = new HashSet<>(); + List unsupported = new ArrayList<>(); + for (OdpJsonNode definition : definitions) { + OdpJsonNode id = definition.path("id"); + if (!id.isString() || !identifiers.add(id.asString())) { + throw new IllegalArgumentException("Invalid or duplicate capability identifier within source"); + } + if (!supportedDefinition(definition, kind)) { + unsupported.add(id.asString()); + continue; + } + if (FILTERS_KIND.equals(kind)) OdpJson.parseFilterDefinition(definition.toString()); + else OdpJson.parseSortDefinition(definition.toString()); + accepted.put(id.asString(), definition); + } + Set conflicts = new HashSet<>(identifiers); + conflicts.retainAll(publishedIdentifiers.get(kind)); + long additions = accepted.keySet().stream() + .filter(id -> !conflicts.contains(id)) + .count(); + long removals = conflicts.stream().filter(target::containsKey).count(); + if (target.size() - removals + additions > maximum) { + throw new IllegalArgumentException("Effective capability catalog exceeds its limit"); + } + publishedIdentifiers.get(kind).addAll(identifiers); + for (String id : conflicts) { + target.remove(id); + issue(scope, kind, id, "Duplicate capability identifier across sources"); + } + accepted.forEach((id, definition) -> { + if (!conflicts.contains(id)) { + target.put(id, definition); + if (SORTS_KIND.equals(kind)) sortScopes.put(id, scope); + } + }); + unsupported.forEach(id -> issue(scope, kind, id, "Unsupported capability definition")); + } catch (OdpRequestException exception) { + issues.add(new SearchCapabilityResult.Issue( + scope, kind, null, exception.getMessage(), SearchCapabilityResult.HttpFailure.from(exception))); + } catch (IllegalArgumentException | IllegalStateException exception) { + if (Thread.currentThread().isInterrupted()) + throw new IllegalStateException("Capability resolution interrupted", exception); + issue(scope, kind, null, exception.getMessage()); + } + } + + private static boolean supportedDefinition(OdpJsonNode definition, String kind) { + String normalized = OdpJson.normalizeAgentResponse( + "{\"odp_version\":\"1.0\",\"items\":[" + definition + "]}", + FILTERS_KIND.equals(kind) ? "filter-page" : "sort-page"); + return !OdpJson.parseTree(normalized).path("items").isEmpty(); + } + + private List load(String href, String kind, int budget) { + List definitions = new ArrayList<>(); + int supported = 0; + Set visited = new HashSet<>(); + URI next = OdpUris.resolveContinuation(href, origin); + for (int pageNumber = 0; pageNumber < MAXIMUM_PAGES; pageNumber++) { + if (!visited.add(next)) throw new IllegalArgumentException("Capability pagination loop"); + String json = loader.apply(next); + var page = OdpJson.parsePage(json, OdpJsonNode.class); + if (page.items().size() > MAXIMUM_PAGE_ITEMS) { + throw new IllegalArgumentException("Linked capability source exceeds its definition limit"); + } + for (OdpJsonNode definition : page.items()) { + if (definition.has("odp_version")) + throw new IllegalArgumentException("Nested definition contains odp_version"); + if (supportedDefinition(definition, kind)) supported++; + if (supported > budget) { + throw new IllegalArgumentException("Linked capability source exceeds its definition limit"); + } + definitions.add(definition); + } + if (page.next() == null) return definitions; + next = OdpUris.resolveContinuation(page.next(), origin); + } + throw new IllegalArgumentException("Linked capability source exceeds 16 pages"); + } + + private void issue(String scope, String kind, String identifier, String message) { + issues.add(new SearchCapabilityResult.Issue(scope, kind, identifier, message)); + } +} diff --git a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/SearchCapabilityResult.java b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/SearchCapabilityResult.java new file mode 100644 index 0000000..144008c --- /dev/null +++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/SearchCapabilityResult.java @@ -0,0 +1,23 @@ +package org.offeringprotocol.odp.agent; + +import java.util.List; +import org.offeringprotocol.odp.core.SearchCatalog; + +public record SearchCapabilityResult(SearchCatalog catalog, List issues) { + public SearchCapabilityResult { + issues = List.copyOf(issues); + } + + public record Issue(String scope, String kind, String identifier, String message, HttpFailure responseFailure) { + public Issue(String scope, String kind, String identifier, String message) { + this(scope, kind, identifier, message, null); + } + } + + public record HttpFailure( + int status, java.net.http.HttpHeaders headers, org.offeringprotocol.odp.core.ProblemDetails problem) { + static HttpFailure from(OdpRequestException exception) { + return new HttpFailure(exception.status(), exception.headers(), exception.problem()); + } + } +} diff --git a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/SecureDestinations.java b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/SecureDestinations.java new file mode 100644 index 0000000..f9f6af9 --- /dev/null +++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/SecureDestinations.java @@ -0,0 +1,70 @@ +package org.offeringprotocol.odp.agent; + +import java.net.InetAddress; +import java.net.URI; +import java.net.UnknownHostException; +import java.util.Locale; +import org.offeringprotocol.odp.core.OdpAddresses; + +/** + * SEC-08: the destination of every connection is resolved and checked against the IANA + * special-purpose address registries before a request is sent, so a Service name cannot point an + * Agent at an address its own network treats as internal. Which addresses those are is + * {@link OdpAddresses}; what this adds is resolving the name first and judging every address it + * answers with, not only the first. + */ +final class SecureDestinations { + private SecureDestinations() {} + + /** A transport that reaches public destinations only. */ + static OdpTransport transport(boolean allowLocalNetwork) { + return new ApacheTransport(allowLocalNetwork); + } + + static void require(URI target, boolean allowLocalNetwork) { + resolve(target, allowLocalNetwork); + } + + static InetAddress[] resolve(URI target, boolean allowLocalNetwork) { + String host = target.getHost(); + if (host == null || host.isBlank()) { + throw new IllegalArgumentException("ODP request target must name a host"); + } + // URI keeps the brackets around an IPv6 literal; address resolution does not want them. + if (host.startsWith("[") && host.endsWith("]")) { + host = host.substring(1, host.length() - 1); + } + InetAddress[] addresses; + try { + addresses = InetAddress.getAllByName(host); + } catch (UnknownHostException exception) { + throw new IllegalStateException("ODP request host did not resolve", exception); + } + if (addresses.length == 0) { + throw new IllegalStateException("ODP request host did not resolve"); + } + return validate(host, addresses, allowLocalNetwork); + } + + static InetAddress[] validate(String host, InetAddress[] addresses, boolean allowLocalNetwork) { + boolean local = isLocalDevelopmentHost(host); + for (InetAddress address : addresses) { + if (local && allowLocalNetwork) { + if (!address.isLoopbackAddress()) { + throw new IllegalStateException("ODP local-development host resolved outside the loopback network"); + } + } else if (!OdpAddresses.isPublic(address)) { + throw new IllegalStateException("ODP request host resolved to a non-public address"); + } + } + return addresses; + } + + private static boolean isLocalDevelopmentHost(String host) { + String value = host.toLowerCase(Locale.ROOT); + return "localhost".equals(value) + || "127.0.0.1".equals(value) // NOPMD - ODP explicitly permits loopback for local development. + || "::1".equals(value) // NOPMD - ODP explicitly permits loopback for local development. + || "[::1]".equals(value); + } +} diff --git a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/SupportingJsonClient.java b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/SupportingJsonClient.java index dbf4a40..ebd92e3 100644 --- a/odp-agent/src/main/java/org/offeringprotocol/odp/agent/SupportingJsonClient.java +++ b/odp-agent/src/main/java/org/offeringprotocol/odp/agent/SupportingJsonClient.java @@ -6,10 +6,11 @@ import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; -import java.util.Locale; import java.util.Set; import org.offeringprotocol.odp.core.OdpJson; import org.offeringprotocol.odp.core.OdpJsonNode; +import org.offeringprotocol.odp.core.OdpMediaType; +import org.offeringprotocol.odp.core.OdpResponseLimitException; final class SupportingJsonClient { private static final int MAXIMUM_REDIRECTS = 5; @@ -22,13 +23,14 @@ final class SupportingJsonClient { OdpJsonNode get(URI target, String accept, Set mediaTypes, int maximumBytes, int maximumDepth) { requireHttps(target); URI current = target; + ReadRetry retry = new ReadRetry(); for (int redirects = 0; redirects <= MAXIMUM_REDIRECTS; redirects++) { HttpRequest request = HttpRequest.newBuilder(current) .timeout(Duration.ofSeconds(30)) .header("Accept", accept) .GET() .build(); - HttpResponse response = send(request); + HttpResponse response = send(request, maximumBytes, retry); int status = response.statusCode(); if (isRedirect(status)) { if (redirects == MAXIMUM_REDIRECTS) { @@ -49,11 +51,11 @@ OdpJsonNode get(URI target, String accept, Set mediaTypes, int maximumBy } byte[] bytes = response.body(); if (bytes.length > maximumBytes) { - throw new IllegalStateException("ODP supporting resource exceeds its byte limit"); + throw new OdpResponseLimitException("ODP supporting resource exceeds its byte limit"); } String contentType = response.headers().firstValue("Content-Type").orElse(""); - String essence = contentType.split(";", 2)[0].trim().toLowerCase(Locale.ROOT); + String essence = OdpMediaType.essence(contentType); if (!mediaTypes.contains(essence)) { throw new IllegalStateException("ODP supporting resource returned an unsupported Content-Type"); } @@ -61,8 +63,8 @@ OdpJsonNode get(URI target, String accept, Set mediaTypes, int maximumBy if (!document.isObject()) { throw new IllegalStateException("ODP supporting resource must be a JSON object"); } - if (depth(document) > maximumDepth) { - throw new IllegalStateException("ODP supporting resource exceeds its JSON depth limit"); + if (depth(document, maximumDepth) > maximumDepth) { + throw new OdpResponseLimitException("ODP supporting resource exceeds its JSON depth limit"); } return document; } @@ -70,9 +72,9 @@ OdpJsonNode get(URI target, String accept, Set mediaTypes, int maximumBy throw new IllegalStateException("ODP supporting resource produced no response"); } - private HttpResponse send(HttpRequest request) { + private HttpResponse send(HttpRequest request, int maximumBytes, ReadRetry retry) { try { - return transport.send(request); + return retry.send(transport, request, maximumBytes); } catch (IOException exception) { throw new IllegalStateException("ODP supporting resource request failed", exception); } catch (InterruptedException exception) { @@ -89,10 +91,14 @@ private static OdpJsonNode parse(byte[] bytes) { } } - private static int depth(OdpJsonNode value) { + /** Stops descending once the limit is already exceeded, so nesting cannot cost unbounded stack. */ + private static int depth(OdpJsonNode value, int remaining) { + if (remaining <= 0) { + return 1; + } int maximum = 1; for (OdpJsonNode child : value) { - maximum = Math.max(maximum, 1 + depth(child)); + maximum = Math.max(maximum, 1 + depth(child, remaining - 1)); } return maximum; } diff --git a/odp-agent/src/test/java/org/offeringprotocol/odp/agent/ApacheTlsTest.java b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/ApacheTlsTest.java new file mode 100644 index 0000000..90075ed --- /dev/null +++ b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/ApacheTlsTest.java @@ -0,0 +1,96 @@ +package org.offeringprotocol.odp.agent; + +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 com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsServer; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpRequest; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.util.concurrent.TimeUnit; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ApacheTlsTest { + @TempDir + Path directory; + + @Test + void verifiesCertificateHostnameWhileConnectingToPinnedAddress() throws Exception { + Path store = directory.resolve("server.p12"); + Path output = directory.resolve("keytool.log"); + String keytool = + Path.of(System.getProperty("java.home"), "bin", "keytool").toString(); + Process process = new ProcessBuilder( + keytool, + "-genkeypair", + "-alias", + "server", + "-keyalg", + "RSA", + "-keystore", + store.toString(), + "-storetype", + "PKCS12", + "-storepass", + "test-only", + "-dname", + "CN=localhost", + "-ext", + "SAN=dns:localhost", + "-validity", + "1", + "-noprompt") + .redirectErrorStream(true) + .redirectOutput(output.toFile()) + .start(); + try { + assertTrue(process.waitFor(20, TimeUnit.SECONDS)); + assertEquals(0, process.exitValue(), Files.readString(output)); + } finally { + process.destroyForcibly(); + } + KeyStore keys = KeyStore.getInstance("PKCS12"); + try (var input = Files.newInputStream(store)) { + keys.load(input, "test-only".toCharArray()); + } + var keyManagers = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagers.init(keys, "test-only".toCharArray()); + var trustManagers = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagers.init(keys); + SSLContext context = SSLContext.getInstance("TLS"); + context.init(keyManagers.getKeyManagers(), trustManagers.getTrustManagers(), null); + var server = HttpsServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.setHttpsConfigurator(new HttpsConfigurator(context)); + server.createContext("/", exchange -> { + exchange.sendResponseHeaders(204, -1); + exchange.close(); + }); + server.start(); + try (var client = ApacheTransport.createClient(context)) { + var transport = new ApacheTransport(true, client); + int port = server.getAddress().getPort(); + var valid = HttpRequest.newBuilder(URI.create("https://localhost:" + port + "/")) + .build(); + assertEquals(204, transport.send(valid).statusCode()); + var mismatch = HttpRequest.newBuilder(URI.create("https://127.0.0.1:" + port + "/")) + .build(); + assertThrows(IOException.class, () -> transport.send(mismatch)); + assertThrows( + IOException.class, + () -> OdpServiceClient.localDevelopmentTransport().send(valid)); + } finally { + server.stop(0); + } + } +} diff --git a/odp-agent/src/test/java/org/offeringprotocol/odp/agent/ApacheTransportTest.java b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/ApacheTransportTest.java new file mode 100644 index 0000000..bcc382b --- /dev/null +++ b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/ApacheTransportTest.java @@ -0,0 +1,328 @@ +package org.offeringprotocol.odp.agent; + +import static org.junit.jupiter.api.Assertions.*; + +import com.sun.net.httpserver.HttpServer; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.ProxySelector; +import java.net.SocketAddress; +import java.net.URI; +import java.net.http.HttpRequest; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.zip.GZIPOutputStream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.offeringprotocol.odp.core.OdpResponseLimitException; + +class ApacheTransportTest { + private HttpServer server; + private URI origin; + private final OdpTransport transport = OdpServiceClient.localDevelopmentTransport(); + + @BeforeEach + void start() throws IOException { + server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + origin = URI.create("http://localhost:" + server.getAddress().getPort()); + server.start(); + } + + @AfterEach + void stop() { + server.stop(0); + } + + @Test + void clientPreservesStreamingLimitError() { + server.createContext("/.well-known/odp", exchange -> { + exchange.sendResponseHeaders(200, 100_000); + exchange.getResponseBody().flush(); + exchange.close(); + }); + var failure = assertThrows(OdpResponseLimitException.class, () -> OdpServiceClient.create(origin, transport)); + assertEquals("RESPONSE_LIMIT_EXCEEDED", failure.code()); + assertFalse(failure.retryable()); + } + + @Test + void clientRetriesSearchOverHttpWithoutChangingItsBody() { + AtomicInteger attempts = new AtomicInteger(); + java.util.ArrayList bodies = new java.util.ArrayList<>(); + server.createContext("/.well-known/odp", exchange -> { + byte[] body = Responses.SERVICE_DOCUMENT.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", Responses.ODP); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.createContext("/odp/offerings/search", exchange -> { + bodies.add(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + byte[] body = "{\"odp_version\":\"1.0\",\"items\":[]}".getBytes(StandardCharsets.UTF_8); + int status = attempts.getAndIncrement() == 0 ? 429 : 200; + exchange.getResponseHeaders().add("Content-Type", Responses.ODP); + exchange.getResponseHeaders().add("Retry-After", "0"); + exchange.sendResponseHeaders(status, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + OdpServiceClient client = OdpServiceClient.create(origin, transport); + var page = client.searchOfferings( + new org.offeringprotocol.odp.core.SearchRequests.Offerings( + "1.0", "plants", null, null, null, null, null, null), + "terse", + null); + assertTrue(page.items().isEmpty()); + assertEquals(2, attempts.get()); + assertEquals(bodies.get(0), bodies.get(1)); + assertTrue(bodies.get(0).contains("plants")); + } + + @Test + void sendsPostAndPreservesResponse() throws Exception { + server.createContext("/echo", exchange -> { + byte[] body = exchange.getRequestBody().readAllBytes(); + exchange.getResponseHeaders().add("Content-Type", "application/odp+json"); + exchange.getResponseHeaders().add("X-Method", exchange.getRequestMethod()); + exchange.sendResponseHeaders(201, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + var request = HttpRequest.newBuilder(origin.resolve("/echo")) + .POST(HttpRequest.BodyPublishers.ofString("{\"query\":\"plants\"}")) + .header("Content-Type", "application/odp+json") + .build(); + var response = transport.send(request, 100); + assertEquals(201, response.statusCode()); + assertEquals("POST", response.headers().firstValue("X-Method").orElseThrow()); + assertEquals("{\"query\":\"plants\"}", new String(response.body(), StandardCharsets.UTF_8)); + assertEquals(request, response.request()); + assertEquals(request.uri(), response.uri()); + byte[] copy = response.body(); + copy[0] = 0; + assertEquals('{', response.body()[0]); + } + + @Test + void rejectsDeclaredLengthBeforeWaitingForBody() { + server.createContext("/large", exchange -> { + exchange.sendResponseHeaders(200, 100_000); + exchange.getResponseBody().flush(); + exchange.close(); + }); + assertTimeoutPreemptively(Duration.ofSeconds(2), () -> { + OdpResponseLimitException error = + assertThrows(OdpResponseLimitException.class, () -> transport.send(request("/large"), 64)); + assertTrue(error.getMessage().contains("byte limit")); + assertEquals("RESPONSE_LIMIT_EXCEEDED", error.code()); + assertFalse(error.retryable()); + }); + } + + @Test + void boundsChunkedAndCompressedBodiesAndErrorResponses() throws Exception { + byte[] oversized = new byte[20_000]; + var compressed = new ByteArrayOutputStream(); + try (var gzip = new GZIPOutputStream(compressed)) { + gzip.write(oversized); + } + for (String path : new String[] {"/chunked", "/compressed", "/error", "/redirect"}) { + server.createContext(path, exchange -> { + boolean gzip = path.equals("/compressed"); + if (gzip) { + exchange.getResponseHeaders().add("Content-Encoding", "gzip"); + } + int status = path.equals("/error") ? 400 : path.equals("/redirect") ? 302 : 200; + exchange.sendResponseHeaders(status, 0); + try { + exchange.getResponseBody().write(gzip ? compressed.toByteArray() : oversized); + } finally { + exchange.close(); + } + }); + int limit = path.equals("/error") || path.equals("/redirect") ? 30_000 : 64; + OdpResponseLimitException error = + assertThrows(OdpResponseLimitException.class, () -> transport.send(request(path), limit)); + assertTrue(error.getMessage().contains("byte limit")); + assertEquals("RESPONSE_LIMIT_EXCEEDED", error.code()); + assertFalse(error.retryable()); + } + } + + @Test + void doesNotFollowRedirectOrRetainCookies() throws Exception { + var hits = new AtomicInteger(); + server.createContext("/redirect", exchange -> { + exchange.getResponseHeaders() + .add("Location", origin.resolve("/target").toString()); + exchange.getResponseHeaders().add("Set-Cookie", "secret=private"); + exchange.sendResponseHeaders(302, -1); + exchange.close(); + }); + server.createContext("/target", exchange -> { + hits.incrementAndGet(); + exchange.getResponseHeaders() + .add( + "X-Content-Type", + String.valueOf(exchange.getRequestHeaders().getFirst("Content-Type"))); + exchange.getResponseHeaders() + .add("X-Cookie", String.valueOf(exchange.getRequestHeaders().getFirst("Cookie"))); + exchange.sendResponseHeaders(204, -1); + exchange.close(); + }); + assertEquals(302, transport.send(request("/redirect")).statusCode()); + assertEquals(0, hits.get()); + assertEquals( + "null", + transport + .send(request("/target")) + .headers() + .firstValue("X-Cookie") + .orElseThrow()); + assertEquals( + "null", + transport + .send(HttpRequest.newBuilder(origin.resolve("/target")) + .method("GET", HttpRequest.BodyPublishers.noBody()) + .build()) + .headers() + .firstValue("X-Content-Type") + .orElseThrow()); + } + + @Test + void cancelsStalledResponse() { + server.createContext("/stall", exchange -> { + exchange.sendResponseHeaders(200, 0); + }); + assertTimeoutPreemptively( + Duration.ofSeconds(3), + () -> assertThrows( + IOException.class, + () -> transport.send( + HttpRequest.newBuilder(origin.resolve("/stall")) + .timeout(Duration.ofMillis(100)) + .build(), + 64))); + } + + @Test + void rejectsProductionLoopbackAndInvalidUrls() { + assertThrows( + IllegalStateException.class, + () -> OdpServiceClient.defaultTransport().send(request("/"))); + assertThrows( + IllegalArgumentException.class, + () -> transport.send(HttpRequest.newBuilder(URI.create("http://user@localhost/")) + .build())); + assertThrows(IllegalArgumentException.class, () -> transport.send(request("/"), -1)); + } + + @Test + void rejectsMixedDnsAnswersAndLocalAlias() throws Exception { + InetAddress[] mixed = {InetAddress.getByAddress(new byte[] {8, 8, 8, 8}), InetAddress.getLoopbackAddress()}; + assertThrows(IllegalStateException.class, () -> SecureDestinations.validate("example.com", mixed, false)); + assertThrows(IllegalStateException.class, () -> SecureDestinations.validate("example.com", mixed, true)); + assertThrows(IllegalStateException.class, () -> SecureDestinations.validate("localhost", mixed, true)); + } + + @Test + void interruptionCancelsBlockedRead() throws Exception { + var received = new CountDownLatch(1); + server.createContext("/interrupt", exchange -> { + exchange.sendResponseHeaders(200, 0); + received.countDown(); + }); + var failure = new AtomicReference(); + Thread caller = new Thread(() -> { + try { + transport.send(request("/interrupt")); + } catch (Exception exception) { + failure.set(exception); + } + }); + caller.start(); + try { + assertTrue(received.await(2, TimeUnit.SECONDS)); + caller.interrupt(); + caller.join(2_000); + assertFalse(caller.isAlive()); + assertInstanceOf(InterruptedException.class, failure.get()); + } finally { + caller.interrupt(); + caller.join(2_000); + } + } + + private HttpRequest request(String path) { + return HttpRequest.newBuilder(origin.resolve(path)).build(); + } + + @Test + void ignoresSystemProxyAndDoesNotRetryErrors() throws Exception { + var requests = new AtomicInteger(); + var proxyLookups = new AtomicInteger(); + server.createContext("/failure", exchange -> { + requests.incrementAndGet(); + exchange.sendResponseHeaders(503, -1); + exchange.close(); + }); + ProxySelector previous = ProxySelector.getDefault(); + ProxySelector.setDefault(new ProxySelector() { + @Override + public List select(URI uri) { + proxyLookups.incrementAndGet(); + throw new AssertionError("System proxy must not handle ODP requests"); + } + + @Override + public void connectFailed(URI uri, SocketAddress address, IOException failure) { + throw new AssertionError("System proxy must not handle ODP requests"); + } + }); + try { + assertEquals(503, transport.send(request("/failure")).statusCode()); + assertEquals(1, requests.get()); + assertEquals(0, proxyLookups.get()); + } finally { + ProxySelector.setDefault(previous); + } + } + + @Test + void triesAnotherAddressOnlyWhenConnectionWasNotEstablished() throws Exception { + InetAddress[] addresses = InetAddress.getAllByName("localhost"); + org.junit.jupiter.api.Assumptions.assumeTrue(addresses.length > 1, "Requires dual-stack localhost"); + var alternate = HttpServer.create(new InetSocketAddress(addresses[addresses.length - 1], 0), 0); + var requests = new AtomicInteger(); + alternate.createContext("/", exchange -> { + requests.incrementAndGet(); + byte[] body = exchange.getRequestBody().readAllBytes(); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + alternate.start(); + try { + var request = HttpRequest.newBuilder(URI.create( + "http://localhost:" + alternate.getAddress().getPort() + "/")) + .timeout(Duration.ofSeconds(2)) + .POST(HttpRequest.BodyPublishers.ofString("search")) + .build(); + var response = transport.send(request); + assertEquals("search", new String(response.body(), StandardCharsets.UTF_8)); + assertEquals(1, requests.get()); + } finally { + alternate.stop(0); + } + } +} diff --git a/odp-agent/src/test/java/org/offeringprotocol/odp/agent/ModelsTest.java b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/ModelsTest.java new file mode 100644 index 0000000..4fe7100 --- /dev/null +++ b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/ModelsTest.java @@ -0,0 +1,89 @@ +package org.offeringprotocol.odp.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.offeringprotocol.odp.core.AuthenticationRequirement; +import org.offeringprotocol.odp.core.OdpJson; + +class ModelsTest { + private static final String DIALECT = "https://json-schema.org/draft/2020-12/schema"; + + /** An omitted list reads as an empty one, so a caller never has to check for null. */ + @Test + void readsAnOmittedListAsAnEmptyOne() { + DiscoveredAction.HttpTarget target = new DiscoveredAction.HttpTarget("https://x.example/a", "POST", null, null); + assertTrue(target.responseContentTypes().isEmpty()); + + OfferingDetails details = new OfferingDetails(null, null, null, null); + assertTrue(details.actions().isEmpty()); + assertTrue(details.issues().isEmpty()); + assertNull(details.attributeSchema()); + + ResolvedAction resolved = new ResolvedAction( + new DiscoveredAction(AuthenticationRequirement.NOT_REQUIRED, "rent", "purchase", null, target, null), + null, + null, + null); + assertNull(resolved.requestSchema()); + assertNull(resolved.openApiDocument()); + assertNull(resolved.operation()); + } + + /** A caller cannot reach into a document the Agent validated, so every copy handed out is its own. */ + @Test + void handsOutACopyOfEveryDocument() { + var schema = OdpJson.parseTree("{\"type\":\"object\"}"); + OfferingDetails details = new OfferingDetails(null, schema, List.of(), List.of()); + assertNotSame(schema, details.attributeSchema()); + assertNotSame(details.attributeSchema(), details.attributeSchema()); + + ResolvedAction resolved = new ResolvedAction(null, schema, schema, schema); + assertNotSame(schema, resolved.requestSchema()); + assertNotSame(resolved.openApiDocument(), resolved.openApiDocument()); + assertNotSame(resolved.operation(), resolved.operation()); + } + + /** A document two branches both reference is retrieved once, and its fragment is not part of it. */ + @Test + void retrievesASharedSchemaDocumentOnce() { + Map documents = Map.of( + "https://schemas.example/root.json", + "{\"$schema\":\"" + DIALECT + "\",\"properties\":{" + + "\"left\":{\"$ref\":\"left.json\"},\"right\":{\"$ref\":\"right.json\"}}}", + "https://schemas.example/left.json", + "{\"$schema\":\"" + DIALECT + "\",\"$ref\":\"shared.json#/$defs/value\"}", + "https://schemas.example/right.json", + "{\"$schema\":\"" + DIALECT + "\",\"$ref\":\"shared.json#/$defs/value\"}", + "https://schemas.example/shared.json", + "{\"$schema\":\"" + DIALECT + "\",\"$defs\":{\"value\":{\"type\":\"integer\"}}}"); + Map requests = new HashMap<>(); + OdpServiceClient client = OdpServiceClient.create( + URI.create("https://plants.example"), + request -> Responses.ok( + request, + request.uri().getPath().equals("/.well-known/odp") + ? Responses.SERVICE_DOCUMENT + : "{\"odp_version\":\"1.0\",\"id\":\"gpu\",\"name\":\"GPU\"," + + "\"schema\":{\"url\":\"https://schemas.example/root.json\"}}"), + request -> { + requests.merge(request.uri().toString(), 1, Integer::sum); + return Responses.of( + request, + 200, + documents.get(request.uri().toString()), + Map.of("Content-Type", List.of("application/schema+json"))); + }); + OfferingDetails details = client.getOfferingDetails("gpu", null); + assertTrue(details.issues().isEmpty(), details.issues().toString()); + assertEquals(1, requests.get("https://schemas.example/shared.json")); + assertEquals(4, requests.size()); + } +} diff --git a/odp-agent/src/test/java/org/offeringprotocol/odp/agent/OdpAgentTest.java b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/OdpAgentTest.java new file mode 100644 index 0000000..0c2eb8c --- /dev/null +++ b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/OdpAgentTest.java @@ -0,0 +1,190 @@ +package org.offeringprotocol.odp.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.offeringprotocol.odp.directory.DirectoryClient; +import org.offeringprotocol.odp.directory.DirectoryEnvironment; + +class OdpAgentTest { + private static final String DIRECTORY_RESULTS = """ + {"items":[ + {"service_origin":"https://fast.example","name":"Fast","description":"Fast catalog", + "language":"en","localizations":["en"],"indexed_at":"2026-08-02T00:00:00Z", + "operations":[{"authentication":"not-required","name":"get-offering"}, + {"authentication":"not-required","name":"list-offerings"}]}, + {"service_origin":"https://slow.example","name":"Slow","description":"Slow catalog", + "language":"en","localizations":["en"],"indexed_at":"2026-08-02T00:00:00Z", + "operations":[{"authentication":"not-required","name":"get-offering"}, + {"authentication":"not-required","name":"list-offerings"}]}]} + """; + + private static final String SEARCHING_DOCUMENT = """ + {"odp_version":"1.0","name":"Catalog","description":"A catalog.", + "language":"en","localizations":["en"],"operations":[ + {"authentication":"not-required","name":"get-offering"}, + {"authentication":"not-required","name":"list-offerings"}, + {"authentication":"not-required","name":"search-offerings"}], + "http":{"endpoint_base":"/odp"}} + """; + + private static final String LISTING_DOCUMENT = """ + {"odp_version":"1.0","name":"Catalog","description":"A catalog.", + "language":"en","localizations":["en"],"operations":[ + {"authentication":"not-required","name":"get-offering"}, + {"authentication":"not-required","name":"list-offerings"}], + "http":{"endpoint_base":"/odp"}} + """; + + @Test + void reportsAnOfferingFromEveryServiceThatCanSearch() { + OdpAgent agent = new OdpAgent(directory(), origin -> service(origin, SEARCHING_DOCUMENT)); + List events = agent.searchOfferings("gpu", 10, 5); + assertEquals(2, events.size()); + OdpAgent.OfferingEvent first = assertInstanceOf(OdpAgent.OfferingEvent.class, events.get(0)); + assertEquals("https://fast.example", first.service().serviceOrigin()); + assertEquals("GPU", first.offering().name()); + } + + /** A Service that does not advertise search is passed over rather than reported as a failure. */ + @Test + void skipsAServiceThatCannotSearch() { + OdpAgent agent = new OdpAgent(directory(), origin -> service(origin, LISTING_DOCUMENT)); + assertTrue(agent.searchOfferings("gpu", 10, 5).isEmpty()); + } + + /** One Service failing is an issue against that Service, not the end of the search. */ + @Test + void reportsAFailingServiceAsAnIssue() { + OdpAgent agent = new OdpAgent(directory(), origin -> { + if (origin.contains("slow")) { + throw new IllegalStateException("Service stopped answering"); + } + return service(origin, SEARCHING_DOCUMENT); + }); + List events = agent.searchOfferings("gpu", 10, 5); + assertEquals(2, events.size()); + OdpAgent.IssueEvent issue = assertInstanceOf(OdpAgent.IssueEvent.class, events.get(1)); + assertEquals("https://slow.example", issue.service().serviceOrigin()); + assertEquals("Service stopped answering", issue.message()); + } + + @Test + void refusesBoundsOutsideWhatItWillTraverse() { + OdpAgent agent = new OdpAgent(directory(), origin -> service(origin, SEARCHING_DOCUMENT)); + assertEquals( + "maximumServices must be from 1 through 100", + assertThrows(IllegalArgumentException.class, () -> agent.searchOfferings("gpu", 0, 5)) + .getMessage()); + assertEquals( + "maximumServices must be from 1 through 100", + assertThrows(IllegalArgumentException.class, () -> agent.searchOfferings("gpu", 101, 5)) + .getMessage()); + assertEquals( + "offeringsPerService must be from 1 through 100", + assertThrows(IllegalArgumentException.class, () -> agent.searchOfferings("gpu", 10, 0)) + .getMessage()); + assertEquals( + "offeringsPerService must be from 1 through 100", + assertThrows(IllegalArgumentException.class, () -> agent.searchOfferings("gpu", 10, 101)) + .getMessage()); + } + + @Test + void requiresADirectoryAndAFactory() { + assertThrows(NullPointerException.class, () -> new OdpAgent(null)); + assertThrows(NullPointerException.class, () -> new OdpAgent(directory(), null)); + } + + private static DirectoryClient directory() { + return DirectoryClient.create(DirectoryEnvironment.SANDBOX, new StubHttpClient()); + } + + private static OdpServiceClient service(String origin, String document) { + return OdpServiceClient.create( + URI.create(origin), + request -> Responses.ok( + request, + request.uri().getPath().equals("/.well-known/odp") + ? document + : "{\"odp_version\":\"1.0\",\"items\":[{\"id\":\"gpu\",\"name\":\"GPU\"}]}")); + } + + /** The directory client speaks to an {@link HttpClient}, so its answers are stubbed at that seam. */ + private static final class StubHttpClient extends HttpClient { + @Override + public java.util.Optional cookieHandler() { + return java.util.Optional.empty(); + } + + @Override + public java.util.Optional connectTimeout() { + return java.util.Optional.empty(); + } + + @Override + public Redirect followRedirects() { + return Redirect.NEVER; + } + + @Override + public java.util.Optional proxy() { + return java.util.Optional.empty(); + } + + @Override + public javax.net.ssl.SSLContext sslContext() { + return null; + } + + @Override + public javax.net.ssl.SSLParameters sslParameters() { + return new javax.net.ssl.SSLParameters(); + } + + @Override + public java.util.Optional authenticator() { + return java.util.Optional.empty(); + } + + @Override + public Version version() { + return Version.HTTP_1_1; + } + + @Override + public java.util.Optional executor() { + return java.util.Optional.empty(); + } + + @SuppressWarnings("unchecked") + @Override + public HttpResponse send(HttpRequest request, HttpResponse.BodyHandler handler) { + return (HttpResponse) + Responses.of(request, 200, DIRECTORY_RESULTS, Map.of("Content-Type", List.of("application/json"))); + } + + @Override + public java.util.concurrent.CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler handler) { + return java.util.concurrent.CompletableFuture.completedFuture(send(request, handler)); + } + + @Override + public java.util.concurrent.CompletableFuture> sendAsync( + HttpRequest request, + HttpResponse.BodyHandler handler, + HttpResponse.PushPromiseHandler pushPromiseHandler) { + return sendAsync(request, handler); + } + } +} diff --git a/odp-agent/src/test/java/org/offeringprotocol/odp/agent/OfferingDetailsTest.java b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/OfferingDetailsTest.java index 3e38dfa..2a8bb07 100644 --- a/odp-agent/src/test/java/org/offeringprotocol/odp/agent/OfferingDetailsTest.java +++ b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/OfferingDetailsTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.net.URI; @@ -76,6 +77,24 @@ void resolvesOfferingSchemaAndNormalizesActions() { request -> request.headers().firstValue("Authorization").isEmpty())); } + @Test + void propagatesInterruptedAttributeSchemaFetch() { + OdpTransport service = request -> response( + request, + request.uri().getPath().equals("/.well-known/odp") ? SERVICE_DOCUMENT : OFFERING, + "application/odp+json"); + OdpTransport supporting = request -> { + throw new InterruptedException("cancelled"); + }; + OdpServiceClient client = OdpServiceClient.create(URI.create("https://plants.example"), service, supporting); + try { + assertThrows(IllegalStateException.class, () -> client.getOfferingDetails("gpu", null)); + assertTrue(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); + } + } + @Test void omitsAttributesThatDoNotMatchTheirSchema() { Fixture fixture = fixture(OFFERING.replace("\"memory\":80", "\"memory\":\"large\""), ROOT_SCHEMA); diff --git a/odp-agent/src/test/java/org/offeringprotocol/odp/agent/ReadRetryTest.java b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/ReadRetryTest.java new file mode 100644 index 0000000..3d443fa --- /dev/null +++ b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/ReadRetryTest.java @@ -0,0 +1,237 @@ +package org.offeringprotocol.odp.agent; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.Test; + +class ReadRetryTest { + private static final HttpRequest REQUEST = HttpRequest.newBuilder(URI.create("https://example.com/odp/offerings")) + .GET() + .build(); + private static final Instant NOW = Instant.parse("2026-09-21T12:00:00Z"); + + @Test + void honorsDelayAndStopsAfterThreeRetries() throws Exception { + AtomicLong time = new AtomicLong(); + List waits = new ArrayList<>(); + ReadRetry retry = retry(time, waits); + AtomicInteger calls = new AtomicInteger(); + HttpResponse result = retry.send( + request -> { + calls.incrementAndGet(); + return response(429, "2"); + }, + REQUEST, + 100); + assertEquals(429, result.statusCode()); + assertEquals(4, calls.get()); + assertEquals(List.of(2000L, 2000L, 2000L), waits); + } + + @Test + void respectsOperationBudgetAndDoesNotRetryOtherStatuses() throws Exception { + for (int status : List.of(200, 304, 400, 401, 402, 403, 404, 410, 422, 500)) { + AtomicInteger calls = new AtomicInteger(); + retry(new AtomicLong(), new ArrayList<>()) + .send( + request -> { + calls.incrementAndGet(); + return response(status, "0"); + }, + REQUEST, + 100); + assertEquals(1, calls.get()); + } + AtomicLong time = new AtomicLong(); + List waits = new ArrayList<>(); + AtomicInteger calls = new AtomicInteger(); + retry(time, waits) + .send( + request -> { + calls.incrementAndGet(); + time.addAndGet(5_000_000_000L); + return response(503, "20"); + }, + REQUEST, + 100); + assertEquals(2, calls.get()); + assertEquals(List.of(20_000L), waits); + } + + @Test + void parsesHttpDateAndRejectsInvalidOrExcessiveDelays() throws Exception { + for (String value : List.of("31", "999999999999999999999999", "9223372036854775807", "-1", "1.5", "bad")) { + List waits = new ArrayList<>(); + AtomicInteger calls = new AtomicInteger(); + retry(new AtomicLong(), waits) + .send( + request -> { + calls.incrementAndGet(); + return response(429, value); + }, + REQUEST, + 100); + assertEquals(1, calls.get(), value); + assertTrue(waits.isEmpty()); + } + List waits = new ArrayList<>(); + AtomicInteger calls = new AtomicInteger(); + retry(new AtomicLong(), waits) + .send( + request -> calls.getAndIncrement() == 0 + ? response(503, "Mon, 21 Sep 2026 12:00:02 GMT") + : response(200, null), + REQUEST, + 100); + assertEquals(List.of(2000L), waits); + } + + @Test + void acceptsAllHttpDateFormsAndTreatsPastDatesAsImmediate() throws Exception { + for (String value : List.of("Monday, 21-Sep-26 12:00:02 GMT", "Mon Sep 21 12:00:02 2026")) { + List waits = new ArrayList<>(); + AtomicInteger calls = new AtomicInteger(); + retry(new AtomicLong(), waits) + .send( + request -> calls.getAndIncrement() == 0 ? response(503, value) : response(200, null), + REQUEST, + 100); + assertEquals(List.of(2000L), waits, value); + } + for (String value : List.of("Sunday, 06-Nov-94 08:49:37 GMT", "Sun Nov 6 08:49:37 1994")) { + List waits = new ArrayList<>(); + AtomicInteger calls = new AtomicInteger(); + retry(new AtomicLong(), waits) + .send( + request -> calls.getAndIncrement() == 0 ? response(503, value) : response(200, null), + REQUEST, + 100); + assertEquals(List.of(0L), waits, value); + } + } + + @Test + void usesJitterOnlyForMissing503Delay() throws Exception { + List waits = new ArrayList<>(); + retry(new AtomicLong(), waits).send(request -> response(503, null), REQUEST, 100); + assertEquals(List.of(500L, 1000L, 2000L), waits); + waits.clear(); + retry(new AtomicLong(), waits).send(request -> response(429, null), REQUEST, 100); + assertTrue(waits.isEmpty()); + retry(new AtomicLong(), waits) + .send( + request -> Responses.of(request, 503, "", Map.of("Retry-After", List.of("0", "1"))), + REQUEST, + 100); + assertTrue(waits.isEmpty()); + } + + @Test + void sharesBudgetAcrossRedirectRequests() throws Exception { + ReadRetry retry = retry(new AtomicLong(), new ArrayList<>()); + AtomicInteger calls = new AtomicInteger(); + retry.send(request -> calls.getAndIncrement() < 2 ? response(503, "0") : response(302, null), REQUEST, 100); + AtomicInteger redirectedCalls = new AtomicInteger(); + retry.send( + request -> { + redirectedCalls.incrementAndGet(); + return response(503, "0"); + }, + REQUEST, + 100); + assertEquals(2, redirectedCalls.get()); + } + + @Test + void doesNotRetryTransportFailureOrInterruptions() { + ReadRetry retry = retry(new AtomicLong(), new ArrayList<>()); + AtomicInteger calls = new AtomicInteger(); + assertThrows( + IOException.class, + () -> retry.send( + request -> { + calls.incrementAndGet(); + throw new IOException("connection closed"); + }, + REQUEST, + 100)); + assertEquals(1, calls.get()); + ReadRetry interrupted = new ReadRetry( + System::nanoTime, + () -> NOW, + milliseconds -> { + throw new InterruptedException(); + }, + () -> 0.5); + assertThrows(InterruptedException.class, () -> interrupted.send(request -> response(503, "0"), REQUEST, 100)); + Thread.currentThread().interrupt(); + try { + assertThrows(InterruptedException.class, () -> retry.send(request -> fail("must not send"), REQUEST, 100)); + } finally { + Thread.interrupted(); + } + } + + @Test + void doesNotSendAfterSleepOvershootsDeadline() throws Exception { + AtomicLong time = new AtomicLong(); + ReadRetry retry = new ReadRetry(time::get, () -> NOW, milliseconds -> time.set(31_000_000_000L), () -> 0.5); + AtomicInteger calls = new AtomicInteger(); + retry.send( + request -> { + calls.incrementAndGet(); + return response(503, "1"); + }, + REQUEST, + 100); + assertEquals(1, calls.get()); + } + + @Test + void actualClientRetriesReadOnlyPostAndPreservesRequest() { + AtomicInteger calls = new AtomicInteger(); + List attempts = new ArrayList<>(); + OdpServiceClient client = OdpServiceClient.create(URI.create("https://example.com"), request -> { + if (request.uri().getPath().equals("/.well-known/odp")) { + return Responses.ok(request, Responses.SERVICE_DOCUMENT); + } + attempts.add(request); + return calls.getAndIncrement() == 0 + ? response(429, "0") + : Responses.ok(request, "{\"odp_version\":\"1.0\",\"items\":[]}"); + }); + client.searchOfferings( + new org.offeringprotocol.odp.core.SearchRequests.Offerings( + "1.0", "plants", null, null, null, null, null, null), + null, + null); + assertEquals(2, attempts.size()); + assertEquals("POST", attempts.get(0).method()); + assertSame(attempts.get(0), attempts.get(1)); + } + + private static ReadRetry retry(AtomicLong time, List waits) { + return new ReadRetry( + time::get, + () -> NOW.plusNanos(time.get()), + milliseconds -> { + waits.add(milliseconds); + time.addAndGet(milliseconds * 1_000_000); + }, + () -> 0.5); + } + + private static HttpResponse response(int status, String delay) { + return Responses.of(REQUEST, status, "", delay == null ? Map.of() : Map.of("Retry-After", List.of(delay))); + } +} diff --git a/odp-agent/src/test/java/org/offeringprotocol/odp/agent/RepresentationConformanceTest.java b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/RepresentationConformanceTest.java new file mode 100644 index 0000000..efdc5bb --- /dev/null +++ b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/RepresentationConformanceTest.java @@ -0,0 +1,223 @@ +package org.offeringprotocol.odp.agent; + +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 java.util.List; +import org.junit.jupiter.api.Test; +import org.offeringprotocol.odp.core.OdpOperation; +import org.offeringprotocol.odp.core.SearchRequests; + +class RepresentationConformanceTest { + @Test + void acceptsCompatibleOfferingAndPageVersions() { + for (String version : List.of("1.1", "1.7")) { + OdpServiceClient single = Responses.serving( + request -> "{\"odp_version\":\"" + version + "\",\"id\":\"gpu\",\"name\":\"GPU\"}"); + assertEquals(version, single.getOffering("gpu", null, null).odpVersion()); + OdpServiceClient page = Responses.serving( + request -> "{\"odp_version\":\"" + version + "\",\"items\":[{\"id\":\"gpu\",\"name\":\"GPU\"}]}"); + assertEquals(version, page.listOfferings(null, null, null).odpVersion()); + } + } + + private static final String ACTION = """ + {"authentication":"not-required","id":"rent","rel":"purchase", + "http":{"href":"/rent","method":"POST"}} + """; + + /** A Terse Representation carries no Actions, so a Service sending them answered a different request. */ + @Test + void refusesActionsInATerseOffering() { + OdpServiceClient malformed = Responses.serving( + request -> + "{\"odp_version\":\"1.0\",\"items\":[{\"id\":\"gpu\",\"name\":\"GPU\",\"actions\":[{\"rel\":\"download\"}]}]}"); + assertThrows(IllegalArgumentException.class, () -> malformed.listOfferings("terse", null, null)); + OdpServiceClient page = Responses.serving(request -> + "{\"odp_version\":\"1.0\",\"items\":[{\"id\":\"gpu\",\"name\":\"GPU\",\"actions\":[" + ACTION + "]}]}"); + assertEquals( + "ODP Terse Offering cannot contain Actions", + assertThrows(IllegalArgumentException.class, () -> page.listOfferings(null, null, null)) + .getMessage()); + + OdpServiceClient single = Responses.serving( + request -> "{\"odp_version\":\"1.0\",\"id\":\"gpu\",\"name\":\"GPU\",\"actions\":[" + ACTION + "]}"); + assertEquals( + "ODP Terse Offering cannot contain Actions", + assertThrows(IllegalArgumentException.class, () -> single.getOffering("gpu", "terse", null)) + .getMessage()); + // The same document is the expected answer to a request for the Full Representation. + assertEquals("GPU", single.getOffering("gpu", "full", null).name()); + } + + /** A Full Representation withholds nothing, so it has no detail_fields to list. */ + @Test + void refusesDetailFieldsInAFullRepresentation() { + OdpServiceClient offering = Responses.serving(request -> + "{\"odp_version\":\"1.0\",\"id\":\"gpu\",\"name\":\"GPU\",\"detail_fields\":[\"/description\"]}"); + assertEquals( + "ODP Full Offering cannot contain detail_fields", + assertThrows(IllegalArgumentException.class, () -> offering.getOffering("gpu", "full", null)) + .getMessage()); + assertEquals("GPU", offering.getOffering("gpu", "terse", null).name()); + + OdpServiceClient collection = Responses.serving(request -> + "{\"odp_version\":\"1.0\",\"id\":\"pots\",\"name\":\"Pots\",\"detail_fields\":[\"/description\"]}"); + assertEquals( + "ODP Full Collection cannot contain detail_fields", + assertThrows(IllegalArgumentException.class, () -> collection.getCollection("pots", "full", null)) + .getMessage()); + assertEquals("Pots", collection.getCollection("pots", "terse", null).name()); + } + + /** VER-03: a page item inherits its container's version and cannot restate it. */ + @Test + void refusesAPageItemThatRestatesTheVersion() { + OdpServiceClient offerings = Responses.serving(request -> + "{\"odp_version\":\"1.0\",\"items\":[{\"id\":\"gpu\",\"name\":\"GPU\",\"odp_version\":\"1.0\"}]}"); + assertEquals( + "ODP page item Offering cannot restate odp_version", + assertThrows(IllegalArgumentException.class, () -> offerings.listOfferings(null, null, null)) + .getMessage()); + + OdpServiceClient collections = Responses.serving(request -> + "{\"odp_version\":\"1.0\",\"items\":[{\"id\":\"pots\",\"name\":\"Pots\",\"odp_version\":\"1.0\"}]}"); + assertEquals( + "ODP page item Collection cannot restate odp_version", + assertThrows(IllegalArgumentException.class, () -> collections.listCollections(null, null, null)) + .getMessage()); + + // A single resource is a Top-Level Document and carries the version its page items must not. + OdpServiceClient single = + Responses.serving(request -> "{\"odp_version\":\"1.0\",\"id\":\"gpu\",\"name\":\"GPU\"}"); + assertEquals("GPU", single.getOffering("gpu", null, null).name()); + } + + @Test + void refusesAnItemWithoutAUsableSummary() { + OdpServiceClient nameless = + Responses.serving(request -> "{\"odp_version\":\"1.0\",\"items\":[{\"id\":\"gpu\",\"name\":\" \"}]}"); + assertEquals( + "Offering summary is invalid", + assertThrows(IllegalArgumentException.class, () -> nameless.listOfferings(null, null, null)) + .getMessage()); + } + + @Test + void servesEveryOperationTheServiceAdvertises() { + OdpServiceClient client = + Responses.serving(request -> switch (request.uri().getPath()) { + case "/odp/collections/pots" -> "{\"odp_version\":\"1.0\",\"id\":\"pots\",\"name\":\"Pots\"}"; + case "/odp/offerings/gpu" -> "{\"odp_version\":\"1.0\",\"id\":\"gpu\",\"name\":\"GPU\"}"; + default -> "{\"odp_version\":\"1.0\",\"items\":[{\"id\":\"gpu\",\"name\":\"GPU\"}]}"; + }); + assertEquals(1, client.listCollections(null, null, null).items().size()); + assertEquals( + 1, + client.listCollectionOfferings("pots", null, 10, null).items().size()); + assertEquals(1, client.listOfferings(null, null, null).items().size()); + assertEquals("Pots", client.getCollection("pots", null, null).name()); + assertEquals("GPU", client.getOffering("gpu", null, null).name()); + assertEquals( + 1, + client.searchCollections(new SearchRequests.Collections("1.0", "pots", null, null), null, null) + .items() + .size()); + assertEquals( + 1, + client.searchOfferings( + new SearchRequests.Offerings("1.0", "gpu", null, null, null, null, null, null), + null, + null) + .items() + .size()); + assertEquals( + 1, + client.continueCollections("/odp/collections?cursor=c", "terse", null) + .items() + .size()); + assertEquals( + 1, + client.continueOfferings("/odp/offerings?cursor=c", "terse", null) + .items() + .size()); + } + + @Test + void refusesAnOperationTheServiceDoesNotAdvertise() { + String minimal = """ + {"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"}} + """; + OdpServiceClient client = OdpServiceClient.create( + URI.create("https://plants.example"), request -> Responses.ok(request, minimal)); + assertTrue(client.inspection().supports(OdpOperation.LIST_OFFERINGS)); + for (String operation : List.of("list-collections", "get-collection", "list-collection-offerings")) { + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> { + switch (operation) { + case "list-collections" -> client.listCollections(null, null, null); + case "get-collection" -> client.getCollection("pots", null, null); + default -> client.listCollectionOfferings("pots", null, null, null); + } + }); + assertEquals("Service does not advertise " + operation, failure.getMessage()); + } + } + + @Test + void refusesRequestArgumentsOutsideTheirBounds() { + OdpServiceClient client = Responses.serving(request -> "{\"odp_version\":\"1.0\",\"items\":[]}"); + assertEquals( + "representation must be terse or full", + assertThrows(IllegalArgumentException.class, () -> client.listOfferings("brief", null, null)) + .getMessage()); + assertEquals( + "limit must be from 1 through 100", + assertThrows(IllegalArgumentException.class, () -> client.listOfferings(null, 0, null)) + .getMessage()); + assertEquals( + "limit must be from 1 through 100", + assertThrows(IllegalArgumentException.class, () -> client.listOfferings(null, 101, null)) + .getMessage()); + } + + /** PAG-07: a continuation is followed only while it stays on the Service origin. */ + @Test + void refusesAContinuationThatLeavesTheServiceOrigin() { + OdpServiceClient client = Responses.serving(request -> "{\"odp_version\":\"1.0\",\"items\":[]}"); + assertThrows( + IllegalArgumentException.class, + () -> client.continueOfferings("https://elsewhere.example/odp/offerings", "terse", null)); + assertThrows( + IllegalArgumentException.class, + () -> client.continueCollections("https://elsewhere.example/odp/collections", "terse", null)); + } + + @Test + void validatesContinuationRepresentationWithoutRewritingOpaqueLinks() { + String next = "/odp/offerings?cursor=opaque%2Bvalue&representation=full"; + OdpServiceClient client = Responses.serving(request -> { + assertEquals(next, request.uri().getRawPath() + "?" + request.uri().getRawQuery()); + return "{\"odp_version\":\"1.0\",\"items\":[{\"id\":\"gpu\",\"name\":\"GPU\",\"actions\":[" + ACTION + + "]}]}"; + }); + assertThrows(IllegalArgumentException.class, () -> client.continueOfferings(next, "terse", null)); + assertEquals(1, client.continueOfferings(next, "full", null).items().size()); + assertThrows(NullPointerException.class, () -> client.continueOfferings(next, null, null)); + assertThrows(IllegalArgumentException.class, () -> client.continueOfferings(next, "invalid", null)); + + OdpServiceClient detail = Responses.serving( + request -> + "{\"odp_version\":\"1.0\",\"items\":[{\"id\":\"gpu\",\"name\":\"GPU\",\"detail_fields\":[\"/description\"]}]}"); + assertThrows(IllegalArgumentException.class, () -> detail.continueOfferings(next, "full", null)); + assertThrows(IllegalArgumentException.class, () -> detail.continueCollections(next, "full", null)); + assertEquals(1, detail.continueCollections(next, "terse", null).items().size()); + assertThrows(NullPointerException.class, () -> detail.continueCollections(next, null, null)); + assertThrows(IllegalArgumentException.class, () -> detail.continueCollections(next, "invalid", null)); + } +} diff --git a/odp-agent/src/test/java/org/offeringprotocol/odp/agent/Responses.java b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/Responses.java new file mode 100644 index 0000000..5515a23 --- /dev/null +++ b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/Responses.java @@ -0,0 +1,111 @@ +package org.offeringprotocol.odp.agent; + +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.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import javax.net.ssl.SSLSession; + +/** Builds the responses a transport hands back, so a test states exactly what a Service said. */ +final class Responses { + static final String ODP = "application/odp+json"; + static final String PROBLEM = "application/problem+json"; + + static final String SERVICE_DOCUMENT = """ + {"odp_version":"1.0","name":"Plant Store","description":"Plants for agents.", + "language":"en","localizations":["en"],"operations":[ + {"authentication":"not-required","name":"get-collection"}, + {"authentication":"not-required","name":"get-offering"}, + {"authentication":"not-required","name":"list-collection-offerings"}, + {"authentication":"not-required","name":"list-collections"}, + {"authentication":"not-required","name":"list-offerings"}, + {"authentication":"not-required","name":"search-collections"}, + {"authentication":"not-required","name":"search-offerings"}], + "http":{"endpoint_base":"/odp"}} + """; + + private Responses() {} + + static HttpResponse ok(HttpRequest request, String body) { + return of(request, 200, body, Map.of("Content-Type", List.of(ODP))); + } + + static HttpResponse of(HttpRequest request, int status, String body, Map> headers) { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + Map> values = new LinkedHashMap<>(headers); + return new HttpResponse<>() { + @Override + public int statusCode() { + return status; + } + + @Override + public HttpRequest request() { + return request; + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return HttpHeaders.of(values, (left, right) -> true); + } + + @Override + public byte[] body() { + return bytes; + } + + @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; + } + }; + } + + /** A client whose Service Document is the shared one and whose catalog answers from {@code catalog}. */ + static OdpServiceClient serving(java.util.function.Function catalog) { + return OdpServiceClient.create( + URI.create("https://plants.example"), + request -> request.uri().getPath().equals("/.well-known/odp") + ? ok(request, SERVICE_DOCUMENT) + : ok(request, catalog.apply(request))); + } + + /** Records every request a client made, so a test can assert on what went out. */ + static final class Recorder { + private final List requests = new ArrayList<>(); + + List requests() { + return List.copyOf(requests); + } + + HttpRequest last() { + return requests.get(requests.size() - 1); + } + + void record(HttpRequest request) { + requests.add(request); + } + } +} diff --git a/odp-agent/src/test/java/org/offeringprotocol/odp/agent/SearchCapabilityTest.java b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/SearchCapabilityTest.java new file mode 100644 index 0000000..9221aa6 --- /dev/null +++ b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/SearchCapabilityTest.java @@ -0,0 +1,300 @@ +package org.offeringprotocol.odp.agent; + +import static org.junit.jupiter.api.Assertions.*; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.offeringprotocol.odp.core.OdpJson; + +class SearchCapabilityTest { + @Test + void unsupportedDefinitionsCannotBeOverriddenByAnotherSource() { + var service = client( + inline("same").replace("\"string\"", "\"future\""), + request -> Responses.ok( + request, + "{\"odp_version\":\"1.0\",\"id\":\"selected\",\"name\":\"Selected\",\"search_capabilities\":" + + inline("same", "other") + "}")); + var result = service.resolveSearchCapabilities("selected", null); + assertEquals(java.util.Set.of("other"), result.catalog().filters().keySet()); + assertEquals(2, result.issues().size()); + } + + @Test + void unsupportedDefinitionsDoNotConsumeUsableCatalogBudget() { + AtomicInteger calls = new AtomicInteger(); + var service = client("{\"filters\":{\"linked\":{\"href\":\"/filters\"}}}", request -> { + int page = calls.incrementAndGet(); + List items = new ArrayList<>(); + for (int index = 0; index < 100; index++) { + String definition = filter("f" + page + "-" + index); + items.add(page == 11 ? definition : definition.replace("\"string\"", "\"future\"")); + } + return Responses.ok( + request, + "{\"odp_version\":\"1.0\",\"items\":[" + String.join(",", items) + "]" + + (page < 11 ? ",\"next\":\"/page/" + page + "\"}" : "}")); + }); + var result = service.resolveSearchCapabilities(null, null); + assertEquals(100, result.catalog().filters().size()); + assertEquals(11, calls.get()); + } + + @Test + void sourceFailuresRemainScoped() { + for (String capabilities : List.of( + "null", + "{}", + "{\"filters\":{}}", + "{\"filters\":{\"inline\":[]}}", + "{\"filters\":{\"linked\":{\"href\":1}}}", + "{\"filters\":{\"inline\":[{\"id\":42}]}}")) { + var result = client(capabilities, request -> { + fail("Unexpected request"); + return null; + }) + .resolveSearchCapabilities(null, null); + assertTrue(result.catalog().filters().isEmpty()); + assertEquals(1, result.issues().size(), capabilities); + } + var looping = client( + "{\"filters\":{\"linked\":{\"href\":\"/filters\"}}}", + request -> Responses.ok(request, "{\"odp_version\":\"1.0\",\"items\":[],\"next\":\"/filters\"}")); + assertEquals(1, looping.resolveSearchCapabilities(null, null).issues().size()); + var nested = client( + "{\"filters\":{\"linked\":{\"href\":\"/filters\"}}}", + request -> Responses.ok(request, "{\"odp_version\":\"1.0\",\"items\":[{\"odp_version\":\"1.0\"}]}")); + assertEquals(1, nested.resolveSearchCapabilities(null, null).issues().size()); + } + + @Test + void validSortsResolveTheirFilterReferences() { + String capabilities = "{\"filters\":{\"inline\":[" + filter("material") + + "]},\"sorts\":{\"inline\":[{\"id\":\"order\",\"title\":\"Order\",\"description\":\"Order\",\"keys\":[{\"filter_id\":\"material\",\"direction\":\"ascending\",\"missing\":\"last\"}]}]}}"; + var result = client(capabilities, request -> { + fail("Unexpected request"); + return null; + }) + .resolveSearchCapabilities(null, null); + assertTrue(result.issues().isEmpty()); + assertEquals( + "material", + result.catalog().sorts().get("order").filters().get(0).id()); + } + + @Test + void acceptsSixteenCompletePagesAndStopsOnBudgetOverflow() { + AtomicInteger calls = new AtomicInteger(); + var complete = client("{\"filters\":{\"linked\":{\"href\":\"/filters\"}}}", request -> { + int page = calls.incrementAndGet(); + return Responses.ok( + request, + "{\"odp_version\":\"1.0\",\"items\":[" + filter("f" + page) + "]" + + (page < 16 ? ",\"next\":\"/page/" + page + "\"}" : "}")); + }); + assertEquals( + 16, + complete.resolveSearchCapabilities(null, null) + .catalog() + .filters() + .size()); + assertEquals(16, calls.get()); + calls.set(0); + var oversized = client("{\"filters\":{\"linked\":{\"href\":\"/filters\"}}}", request -> { + int page = calls.incrementAndGet(); + List items = new ArrayList<>(); + for (int index = 0; index < 100; index++) items.add(filter("f" + page + "-" + index)); + return Responses.ok( + request, + "{\"odp_version\":\"1.0\",\"items\":[" + String.join(",", items) + "],\"next\":\"/page/" + page + + "\"}"); + }); + var result = oversized.resolveSearchCapabilities(null, null); + assertTrue(result.catalog().filters().isEmpty()); + assertEquals(1, result.issues().size()); + assertEquals(11, calls.get()); + } + + @Test + void dropsDependentSortButPreservesOtherDefinitions() { + String sort = + "\"sorts\":{\"inline\":[{\"id\":\"order\",\"title\":\"Order\",\"description\":\"Order\",\"keys\":[{\"filter_id\":\"price\",\"direction\":\"ascending\",\"missing\":\"last\"}]}]}"; + String capabilities = inline("price", "other"); + capabilities = capabilities.substring(0, capabilities.length() - 1) + "," + sort + "}"; + var service = client( + capabilities, + request -> Responses.ok( + request, + "{\"odp_version\":\"1.0\",\"id\":\"selected\",\"name\":\"Selected\",\"search_capabilities\":" + + inline("price") + "}")); + var result = service.resolveSearchCapabilities("selected", null); + assertEquals(java.util.Set.of("other"), result.catalog().filters().keySet()); + assertTrue(result.catalog().sorts().isEmpty()); + assertEquals(2, result.issues().size()); + } + + @Test + void interruptionIsNotConvertedIntoAnUnavailableSource() { + var document = OdpJson.parseTree(Responses.SERVICE_DOCUMENT); + document.set("search_capabilities", OdpJson.parseTree("{\"filters\":{\"linked\":{\"href\":\"/filters\"}}}")); + var service = OdpServiceClient.create(URI.create(ORIGIN), request -> { + if (request.uri().getPath().equals("/.well-known/odp")) return Responses.ok(request, document.toString()); + throw new InterruptedException("Cancelled"); + }); + try { + assertThrows(IllegalStateException.class, () -> service.resolveSearchCapabilities(null, null)); + assertTrue(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); + } + } + + @Test + void searchDetailsKeepsOfferingsWhenARefinementGroupIsInvalid() { + String capabilities = + inline("one", "two").replace("\"operators\":[\"eq\"]", "\"operators\":[\"eq\"],\"refinable\":true"); + var service = client(capabilities, request -> Responses.ok(request, """ + {"odp_version":"1.0","items":[{"id":"item","name":"Item"}],"refinements":[ + {"filter_id":"one","values":[{"value":"yes","count":2}]}, + {"filter_id":"two","values":[{"value":true,"count":2}]}]} + """)); + var request = OdpJson.parseOfferingSearchRequest( + "{\"odp_version\":\"1.0\",\"query\":\"items\",\"refinements\":[\"one\",\"two\"]}"); + var result = service.searchOfferingsDetails(request, "terse", null); + assertEquals(1, result.page().items().size()); + assertEquals(1, result.refinements().size()); + assertEquals("one", result.refinements().get(0).filter().id()); + assertEquals("two", result.issues().get(0).identifier()); + assertThrows(IllegalArgumentException.class, () -> service.continueOfferings("/next", "terse", null)); + } + + private static final String ORIGIN = "https://plants.example"; + + private static String filter(String id) { + return "{\"id\":\"" + id + + "\",\"title\":\"Title\",\"description\":\"Description\",\"type\":\"string\",\"operators\":[\"eq\"]}"; + } + + private static String inline(String... ids) { + return "{\"filters\":{\"inline\":[" + + String.join( + ",", + java.util.Arrays.stream(ids) + .map(SearchCapabilityTest::filter) + .toList()) + "]}}"; + } + + private static OdpServiceClient client( + String capabilities, + java.util.function.Function> response) { + var document = OdpJson.parseTree(Responses.SERVICE_DOCUMENT); + document.set("search_capabilities", OdpJson.parseTree(capabilities)); + return OdpServiceClient.create( + URI.create(ORIGIN), + request -> request.uri().getPath().equals("/.well-known/odp") + ? Responses.ok(request, document.toString()) + : response.apply(request)); + } + + @Test + void mergesOnlyTheSelectedCollectionAndQuarantinesConflicts() { + List paths = new ArrayList<>(); + var service = client(inline("price", "service"), request -> { + paths.add(request.uri().getPath()); + return Responses.ok( + request, + "{\"odp_version\":\"1.0\",\"id\":\"selected\",\"name\":\"Selected\",\"parent_ids\":[\"parent\"],\"search_capabilities\":" + + inline("price", "collection") + "}"); + }); + assertEquals( + 2, + service.resolveSearchCapabilities(null, null) + .catalog() + .filters() + .size()); + assertTrue(paths.isEmpty()); + var result = service.resolveSearchCapabilities("selected", null); + assertEquals( + java.util.Set.of("service", "collection"), + result.catalog().filters().keySet()); + assertEquals(List.of("/odp/collections/selected"), paths); + assertEquals("price", result.issues().get(0).identifier()); + } + + @Test + void validatesWholeLinkedSourceBeforeExposingAnything() { + var service = client( + "{\"filters\":{\"linked\":{\"href\":\"/filters\"}}}", + request -> Responses.ok( + request, + "{\"odp_version\":\"1.0\",\"items\":[" + filter("repeated") + "]" + + (request.uri().getPath().equals("/filters") ? ",\"next\":\"/second\"}" : "}"))); + var result = service.resolveSearchCapabilities(null, null); + assertTrue(result.catalog().filters().isEmpty()); + assertEquals(1, result.issues().size()); + } + + @Test + void stopsBeforeSeventeenthPageAndDoesNotCrossOrigins() { + AtomicInteger calls = new AtomicInteger(); + var service = client("{\"filters\":{\"linked\":{\"href\":\"/filters\"}}}", request -> { + int page = calls.incrementAndGet(); + return Responses.ok( + request, + "{\"odp_version\":\"1.0\",\"items\":[" + filter("f" + page) + "],\"next\":\"/page/" + page + "\"}"); + }); + assertTrue(service.resolveSearchCapabilities(null, null) + .catalog() + .filters() + .isEmpty()); + assertEquals(16, calls.get()); + var foreign = client("{\"filters\":{\"linked\":{\"href\":\"https://other.example/filters\"}}}", request -> { + fail("Cross-origin request"); + return null; + }); + assertEquals(1, foreign.resolveSearchCapabilities(null, null).issues().size()); + } + + @Test + void retainsLiveChallengeInScopedFailure() { + var service = client( + "{\"filters\":{\"linked\":{\"href\":\"/filters\"}}}", + request -> Responses.of(request, 401, "{}", Map.of("WWW-Authenticate", List.of("Bearer")))); + var result = service.resolveSearchCapabilities(null, null); + assertTrue(result.catalog().filters().isEmpty()); + assertEquals(401, result.issues().get(0).responseFailure().status()); + assertEquals( + "Bearer", + result.issues() + .get(0) + .responseFailure() + .headers() + .firstValue("WWW-Authenticate") + .orElseThrow()); + } + + @Test + void isolatesMalformedAndUnknownDefinitionsDifferently() { + var malformed = client(inline("one", "two").replace("\"type\":\"string\"", "\"type\":true"), request -> { + fail("Unexpected request"); + return null; + }); + assertTrue(malformed + .resolveSearchCapabilities(null, null) + .catalog() + .filters() + .isEmpty()); + var unknown = + client(inline("one", "two").replaceFirst("\"type\":\"string\"", "\"type\":\"future\""), request -> { + fail("Unexpected request"); + return null; + }); + var result = unknown.resolveSearchCapabilities(null, null); + assertEquals(java.util.Set.of("two"), result.catalog().filters().keySet()); + assertEquals("one", result.issues().get(0).identifier()); + } +} diff --git a/odp-agent/src/test/java/org/offeringprotocol/odp/agent/SecureDestinationsTest.java b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/SecureDestinationsTest.java new file mode 100644 index 0000000..70b76a3 --- /dev/null +++ b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/SecureDestinationsTest.java @@ -0,0 +1,112 @@ +package org.offeringprotocol.odp.agent; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +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; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class SecureDestinationsTest { + /** + * SEC-08: every destination the IANA special-purpose registries mark as non-public is refused. + * The IPv6 transition ranges each embed an IPv4 address, so 64:ff9b::a9fe:a9fe is a route to + * link-local 169.254.169.254 and has to be refused as one. + */ + @ParameterizedTest + @ValueSource( + strings = { + "https://0.0.0.1/", + "https://10.1.2.3/", + "https://100.64.0.1/", + "https://127.0.0.1/", + "https://169.254.169.254/", + "https://172.16.0.1/", + "https://192.0.0.1/", + "https://192.0.2.1/", + "https://192.31.196.1/", + "https://192.88.99.1/", + "https://192.168.1.1/", + "https://192.175.48.1/", + "https://198.18.0.1/", + "https://198.51.100.1/", + "https://203.0.113.1/", + "https://224.0.0.1/", + "https://240.0.0.1/", + "https://[::1]/", + "https://[::ffff:169.254.169.254]/", + "https://[64:ff9b::a9fe:a9fe]/", + "https://[64:ff9b:1::1]/", + "https://[100::1]/", + "https://[2001::1]/", + "https://[2001:2::1]/", + "https://[2001:3::1]/", + "https://[2001:4:112::1]/", + "https://[2001:10::1]/", + "https://[2001:20::1]/", + "https://[2001:30::1]/", + "https://[2001:db8::1]/", + "https://[2002::1]/", + "https://[2620:4f:8000::1]/", + "https://[5f00::1]/", + "https://[fc00::1]/", + "https://[fd00::1]/", + "https://[fe80::1]/", + "https://[fec0::1]/", + "https://[ff00::1]/" + }) + void refusesEveryNonPublicDestination(String target) { + IllegalStateException failure = + assertThrows(IllegalStateException.class, () -> SecureDestinations.require(URI.create(target), false)); + assertEquals("ODP request host resolved to a non-public address", failure.getMessage()); + } + + @ParameterizedTest + @ValueSource(strings = {"https://93.184.216.34/", "https://[2606:2800:220:1:248:1893:25c8:1946]/"}) + void acceptsAPublicDestination(String target) { + assertDoesNotThrow(() -> SecureDestinations.require(URI.create(target), false)); + } + + @Test + void reachesLoopbackOnlyForALocalDevelopmentHost() { + assertDoesNotThrow(() -> SecureDestinations.require(URI.create("https://127.0.0.1:8080/"), true)); + assertDoesNotThrow(() -> SecureDestinations.require(URI.create("https://[::1]:8080/"), true)); + // The allowance is for a host that names the local machine, not for any address that happens + // to be internal. + assertThrows( + IllegalStateException.class, () -> SecureDestinations.require(URI.create("https://10.1.2.3/"), true)); + } + + @Test + void refusesATargetThatNamesNoHost() { + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> SecureDestinations.require(URI.create("file:///etc/hosts"), false)); + assertEquals("ODP request target must name a host", failure.getMessage()); + } + + @Test + void reportsAHostThatDoesNotResolve() { + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> SecureDestinations.require(URI.create("https://absent.invalid/"), false)); + assertTrue(failure.getMessage().contains("did not resolve"), failure.getMessage()); + } + + @Test + void buildsTransportsForBothPolicies() { + assertThrows( + IllegalStateException.class, + () -> SecureDestinations.transport(false) + .send(java.net.http.HttpRequest.newBuilder(URI.create("https://127.0.0.1/")) + .build())); + assertThrows( + IllegalStateException.class, + () -> SecureDestinations.transport(true) + .send(java.net.http.HttpRequest.newBuilder(URI.create("https://10.0.0.1/")) + .build())); + } +} diff --git a/odp-agent/src/test/java/org/offeringprotocol/odp/agent/SupportingGraphTest.java b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/SupportingGraphTest.java new file mode 100644 index 0000000..3b7e02e --- /dev/null +++ b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/SupportingGraphTest.java @@ -0,0 +1,324 @@ +package org.offeringprotocol.odp.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class SupportingGraphTest { + private static final URI SERVICE = URI.create("https://plants.example"); + private static final String SCHEMA_TYPE = "application/schema+json"; + private static final String OPENAPI_TYPE = "application/vnd.oai.openapi+json;version=3.1"; + private static final String DIALECT = "https://json-schema.org/draft/2020-12/schema"; + + @Test + void isolatesInvalidActionsAndReportsTheirIdentifiers() { + String valid = + "{\"authentication\":\"not-required\",\"id\":\"good\",\"rel\":\"invoke\",\"http\":{\"href\":\"/run\",\"method\":\"POST\"}}"; + for (String invalid : List.of( + "{\"id\":\"bad\",\"rel\":\"invoke\",\"http\":{\"href\":\"/run\",\"method\":\"POST\"}}", + "{\"authentication\":\"not-required\",\"id\":\"bad\",\"rel\":\"invoke\",\"http\":{\"href\":\"/run\",\"method\":\"DELETE\"}}", + "{\"authentication\":\"not-required\",\"id\":\"bad\",\"rel\":\"invoke\",\"http\":{\"href\":\"/run\",\"method\":\"POST\"},\"openapi\":{\"operation_id\":\"run\"}}")) { + OfferingDetails details = client( + offering(",\"actions\":[" + invalid + "," + valid + "]"), Map.of(), Map.of()) + .getOfferingDetails("gpu", null); + assertEquals("GPU", details.offering().name()); + assertEquals( + List.of("good"), + details.actions().stream().map(DiscoveredAction::id).toList()); + assertEquals(1, details.issues().size()); + assertEquals("bad", details.issues().get(0).actionId()); + assertEquals(OfferingIssue.Scope.ACTION, details.issues().get(0).scope()); + } + OfferingDetails duplicates = client(offering(",\"actions\":[" + valid + "," + valid + "]"), Map.of(), Map.of()) + .getOfferingDetails("gpu", null); + assertTrue(duplicates.actions().isEmpty()); + assertEquals(1, duplicates.issues().size()); + OfferingDetails missingId = + client(offering(",\"actions\":[null]"), Map.of(), Map.of()).getOfferingDetails("gpu", null); + assertTrue(missingId.actions().isEmpty()); + assertEquals(1, missingId.issues().size()); + OfferingDetails numericId = client(offering(",\"actions\":[{\"id\":42}," + valid + "]"), Map.of(), Map.of()) + .getOfferingDetails("gpu", null); + assertEquals( + List.of("good"), + numericId.actions().stream().map(DiscoveredAction::id).toList()); + assertEquals(1, numericId.issues().size()); + } + + private static String offering(String members) { + return "{\"odp_version\":\"1.0\",\"id\":\"gpu\",\"name\":\"GPU\"" + members + "}"; + } + + private static String schemaOffering() { + return offering(",\"schema\":{\"url\":\"https://schemas.example/root.json\"}"); + } + + /** A schema graph is bounded by document count, reference depth and total bytes. */ + @Test + void refusesASchemaGraphPastItsBounds() { + Map chain = new HashMap<>(); + for (int index = 0; index < 20; index++) { + chain.put( + "https://schemas.example/n" + index + ".json", + "{\"$schema\":\"" + DIALECT + "\",\"$ref\":\"n" + (index + 1) + ".json\"}"); + } + chain.put("https://schemas.example/root.json", "{\"$schema\":\"" + DIALECT + "\",\"$ref\":\"n0.json\"}"); + assertEquals( + "ODP Attribute Schema graph exceeds eight reference levels", + issue(schemaOffering(), chain).message()); + + Map wide = new HashMap<>(); + StringBuilder references = new StringBuilder(); + for (int index = 0; index < 20; index++) { + references + .append(index == 0 ? "" : ",") + .append("\"p") + .append(index) + .append("\":{\"$ref\":\"w") + .append(index) + .append(".json\"}"); + wide.put("https://schemas.example/w" + index + ".json", "{\"$schema\":\"" + DIALECT + "\"}"); + } + wide.put( + "https://schemas.example/root.json", + "{\"$schema\":\"" + DIALECT + "\",\"properties\":{" + references + "}}"); + assertEquals( + "ODP Attribute Schema graph exceeds 16 documents", + issue(schemaOffering(), wide).message()); + + Map heavy = new HashMap<>(); + heavy.put( + "https://schemas.example/root.json", + "{\"$schema\":\"" + DIALECT + "\",\"properties\":{\"a\":{\"$ref\":\"big0.json\"}," + + "\"b\":{\"$ref\":\"big1.json\"},\"c\":{\"$ref\":\"big2.json\"}," + + "\"d\":{\"$ref\":\"big3.json\"},\"e\":{\"$ref\":\"big4.json\"}}}"); + for (int index = 0; index < 5; index++) { + heavy.put( + "https://schemas.example/big" + index + ".json", + "{\"$schema\":\"" + DIALECT + "\",\"description\":\"" + "d".repeat(250_000) + "\"}"); + } + assertEquals( + "ODP Attribute Schema graph exceeds its byte limit", + issue(schemaOffering(), heavy).message()); + } + + @Test + void refusesASchemaItCannotTrust() { + assertEquals( + "ODP Attribute Schema must declare JSON Schema Draft 2020-12", + issue(schemaOffering(), Map.of("https://schemas.example/root.json", "{\"type\":\"object\"}")) + .message()); + assertEquals( + "ODP Attribute Schema requires unsupported vocabulary https://example.com/vocab/custom", + issue( + schemaOffering(), + Map.of( + "https://schemas.example/root.json", + "{\"$schema\":\"" + DIALECT + + "\",\"$defs\":{\"inner\":{\"$vocabulary\":{\"https://example.com/vocab/custom\":true}}}}")) + .message()); + assertEquals( + "ODP Attribute Schema $dynamicRef must be a fragment-only reference", + issue( + schemaOffering(), + Map.of( + "https://schemas.example/root.json", + "{\"$schema\":\"" + DIALECT + "\",\"$dynamicRef\":\"other.json#node\"}")) + .message()); + assertEquals( + "ODP Attribute Schema $ref must be a string", + issue( + schemaOffering(), + Map.of( + "https://schemas.example/root.json", + "{\"$schema\":\"" + DIALECT + "\",\"$defs\":{\"inner\":{\"$ref\":7}}}")) + .message()); + assertEquals( + "ODP Attribute Schema $id must be a string", + issue( + schemaOffering(), + Map.of( + "https://schemas.example/root.json", + "{\"$schema\":\"" + DIALECT + "\",\"$defs\":{\"inner\":{\"$id\":7}}}")) + .message()); + assertEquals( + "ODP Attribute Schema references must use HTTPS", + issue( + schemaOffering(), + Map.of( + "https://schemas.example/root.json", + "{\"$schema\":\"" + DIALECT + "\",\"$ref\":\"http://schemas.example/x.json\"}")) + .message()); + } + + /** A graph is retrieved once per document, and the bundle keeps definitions the root already had. */ + @Test + void bundlesAGraphWithoutLosingTheRootsOwnDefinitions() { + Map requests = new HashMap<>(); + Map documents = Map.of( + "https://schemas.example/root.json", + "{\"$schema\":\"" + DIALECT + "\",\"$defs\":{\"odp_external_0\":{\"type\":\"string\"}}," + + "\"properties\":{\"left\":{\"$ref\":\"shared.json\"},\"right\":{\"$ref\":\"shared.json\"}}}", + "https://schemas.example/shared.json", + "{\"$schema\":\"" + DIALECT + "\",\"type\":\"integer\"}"); + OdpServiceClient client = OdpServiceClient.create( + SERVICE, + request -> Responses.ok( + request, + request.uri().getPath().equals("/.well-known/odp") + ? Responses.SERVICE_DOCUMENT + : schemaOffering()), + request -> { + requests.merge(request.uri().toString(), 1, Integer::sum); + return Responses.of( + request, + 200, + documents.get(request.uri().toString()), + Map.of("Content-Type", List.of(SCHEMA_TYPE))); + }); + OfferingDetails details = client.getOfferingDetails("gpu", null); + assertTrue(details.issues().isEmpty(), details.issues().toString()); + assertEquals(1, requests.get("https://schemas.example/shared.json")); + String bundled = details.attributeSchema().toString(); + assertTrue(bundled.contains("odp_external_0_"), bundled); + assertTrue(bundled.contains("\"type\":\"string\""), bundled); + } + + @Test + void reportsAnOfferingWithNoSchemaAndNoServiceOpenApi() { + OdpServiceClient client = Responses.serving(request -> offering("")); + OfferingDetails details = client.getOfferingDetails("gpu", null); + assertNull(details.attributeSchema()); + assertTrue(details.actions().isEmpty()); + assertTrue(details.issues().isEmpty()); + } + + /** An Action the Agent cannot use is reported rather than offered. */ + @Test + void reportsAnActionItCannotUse() { + assertEquals( + "OpenAPI Action has no OpenAPI document URL", + actionIssue("{\"authentication\":\"not-required\",\"id\":\"rent\",\"rel\":\"purchase\"," + + "\"openapi\":{\"operation_id\":\"rent\"}}")); + assertEquals( + "ODP supporting document URL must use HTTPS", + actionIssue("{\"authentication\":\"not-required\",\"id\":\"rent\",\"rel\":\"purchase\"," + + "\"openapi\":{\"operation_id\":\"rent\",\"url\":\"http://localhost:8080/openapi.json\"}}")); + } + + @Test + void resolvesOnlyAnActionTheOfferingExposes() { + OdpServiceClient client = Responses.serving(request -> + offering(",\"actions\":[{\"authentication\":\"not-required\",\"id\":\"rent\",\"rel\":\"purchase\"," + + "\"http\":{\"href\":\"/rent\",\"method\":\"POST\"}}]")); + ResolvedAction resolved = client.resolveAction("gpu", "rent", null); + assertEquals("https://plants.example/rent", resolved.action().http().url()); + // An HTTP Action with no request schema resolves to the target alone. + assertNull(resolved.requestSchema()); + assertNull(resolved.openApiDocument()); + assertEquals( + "ODP Offering does not expose usable Action absent", + assertThrows(IllegalArgumentException.class, () -> client.resolveAction("gpu", "absent", null)) + .getMessage()); + } + + @Test + void refusesAnOpenApiDocumentItCannotUse() { + String action = "{\"authentication\":\"not-required\",\"id\":\"rent\",\"rel\":\"purchase\"," + + "\"openapi\":{\"operation_id\":\"rent\",\"url\":\"https://api.example/openapi.json\"}}"; + assertEquals( + "ODP Action requires an OpenAPI 3.1 document", + resolveFailure(action, "{\"openapi\":\"3.0.0\",\"paths\":{}}")); + assertEquals("ODP OpenAPI document must contain paths", resolveFailure(action, "{\"openapi\":\"3.1.0\"}")); + assertEquals( + "ODP Action operation_id rent must resolve exactly once", + resolveFailure( + action, "{\"openapi\":\"3.1.0\",\"paths\":{\"/a\":{\"post\":{\"operationId\":\"other\"}}}}")); + assertEquals( + "ODP Action operation_id rent must resolve exactly once", + resolveFailure( + action, + "{\"openapi\":\"3.1.0\",\"paths\":{\"/a\":{\"post\":{\"operationId\":\"rent\"}}," + + "\"/b\":{\"get\":{\"operationId\":\"rent\"}}}}")); + // A path member that is not an object, and a method ODP does not read, are both passed over. + assertEquals( + "rent", + resolved( + action, + "{\"openapi\":\"3.1.0\",\"paths\":{\"/a\":7,\"/b\":{\"summary\":\"x\"," + + "\"post\":{\"operationId\":\"rent\"}}}}") + .operation() + .path("operationId") + .asString()); + } + + @Test + void buildsATransportForEachDestinationPolicy() { + assertNotNull(OdpServiceClient.defaultTransport()); + assertNotNull(OdpServiceClient.localDevelopmentTransport()); + // create(URI) goes through the public-destination transport, which refuses loopback. + assertThrows(IllegalStateException.class, () -> OdpServiceClient.create(URI.create("https://127.0.0.1"))); + } + + private static OfferingIssue issue(String offering, Map documents) { + OdpServiceClient client = client(offering, documents, Map.of()); + List issues = client.getOfferingDetails("gpu", null).issues(); + assertEquals(1, issues.size(), issues.toString()); + return issues.get(0); + } + + private static String actionIssue(String action) { + OdpServiceClient client = Responses.serving(request -> offering(",\"actions\":[" + action + "]")); + List issues = client.getOfferingDetails("gpu", null).issues(); + assertEquals(1, issues.size(), issues.toString()); + return issues.get(0).message(); + } + + private static String resolveFailure(String action, String openapi) { + OdpServiceClient client = + client(offering(",\"actions\":[" + action + "]"), Map.of(), openapiDocuments(openapi)); + return assertThrows(IllegalStateException.class, () -> client.resolveAction("gpu", "rent", null)) + .getMessage(); + } + + private static ResolvedAction resolved(String action, String openapi) { + return client(offering(",\"actions\":[" + action + "]"), Map.of(), openapiDocuments(openapi)) + .resolveAction("gpu", "rent", null); + } + + private static Map openapiDocuments(String openapi) { + return Map.of("https://api.example/openapi.json", openapi); + } + + private static OdpServiceClient client( + String offering, Map schemas, Map openapiDocuments) { + return OdpServiceClient.create( + SERVICE, + request -> Responses.ok( + request, + request.uri().getPath().equals("/.well-known/odp") ? Responses.SERVICE_DOCUMENT : offering), + request -> { + String target = request.uri().toString(); + if (openapiDocuments.containsKey(target)) { + return Responses.of( + request, + 200, + openapiDocuments.get(target), + Map.of("Content-Type", List.of(OPENAPI_TYPE))); + } + String document = schemas.get(target); + if (document == null) { + return Responses.of(request, 404, "", Map.of("Content-Type", List.of(SCHEMA_TYPE))); + } + return Responses.of(request, 200, document, Map.of("Content-Type", List.of(SCHEMA_TYPE))); + }); + } +} diff --git a/odp-agent/src/test/java/org/offeringprotocol/odp/agent/SupportingResourceTest.java b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/SupportingResourceTest.java new file mode 100644 index 0000000..d7f8522 --- /dev/null +++ b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/SupportingResourceTest.java @@ -0,0 +1,183 @@ +package org.offeringprotocol.odp.agent; + +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 java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class SupportingResourceTest { + private static final URI ROOT = URI.create("https://schemas.example/root.json"); + private static final String SCHEMA_TYPE = "application/schema+json"; + private static final String SCHEMA = + "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"type\":\"object\"}"; + + @Test + void refusesASupportingUrlThatIsNotHttps() { + for (String target : List.of("http://schemas.example/root.json", "file:///schema.json")) { + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> client(request -> + Responses.of(request, 200, SCHEMA, Map.of("Content-Type", List.of(SCHEMA_TYPE)))) + .get(URI.create(target), SCHEMA_TYPE, Set.of(SCHEMA_TYPE), 1024, 16)); + assertEquals("ODP supporting document URL must use HTTPS", failure.getMessage()); + } + } + + @Test + void refusesWhatASupportingResponseCannotBe() { + assertThrows( + IllegalStateException.class, + () -> get(request -> + Responses.of(request, 200, SCHEMA, Map.of("Content-Type", List.of(SCHEMA_TYPE + "; broken"))))); + Map cases = new HashMap<>(); + cases.put("ODP supporting resource returned an unsupported Content-Type", "application/json"); + for (Map.Entry entry : cases.entrySet()) { + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> get(request -> + Responses.of(request, 200, SCHEMA, Map.of("Content-Type", List.of(entry.getValue()))))); + assertEquals(entry.getKey(), failure.getMessage()); + } + assertEquals( + "ODP supporting resource request failed with HTTP 404", + assertThrows( + IllegalStateException.class, + () -> get(request -> + Responses.of(request, 404, "", Map.of("Content-Type", List.of(SCHEMA_TYPE))))) + .getMessage()); + assertEquals( + "ODP supporting resource exceeds its byte limit", + assertThrows( + IllegalStateException.class, + () -> get(request -> Responses.of( + request, + 200, + "{\"$schema\":\"x\",\"pad\":\"" + "p".repeat(2_000) + "\"}", + Map.of("Content-Type", List.of(SCHEMA_TYPE))))) + .getMessage()); + assertEquals( + "ODP supporting resource must contain valid JSON", + assertThrows( + IllegalStateException.class, + () -> get(request -> Responses.of( + request, 200, "{not json", Map.of("Content-Type", List.of(SCHEMA_TYPE))))) + .getMessage()); + assertEquals( + "ODP supporting resource must be a JSON object", + assertThrows( + IllegalStateException.class, + () -> get(request -> Responses.of( + request, 200, "[1,2,3]", Map.of("Content-Type", List.of(SCHEMA_TYPE))))) + .getMessage()); + String tower = "[".repeat(20) + "0" + "]".repeat(20); + assertEquals( + "ODP supporting resource exceeds its JSON depth limit", + assertThrows( + IllegalStateException.class, + () -> get(request -> Responses.of( + request, + 200, + "{\"tower\":" + tower + "}", + Map.of("Content-Type", List.of(SCHEMA_TYPE))))) + .getMessage()); + } + + @Test + void followsSameOriginRedirectsAndRefusesTheRest() { + int[] hops = {0}; + assertEquals( + SCHEMA, + get(request -> { + hops[0]++; + if (hops[0] == 1) { + return Responses.of( + request, + 302, + "", + Map.of("Location", List.of("https://schemas.example/moved.json"))); + } + return Responses.of(request, 200, SCHEMA, Map.of("Content-Type", List.of(SCHEMA_TYPE))); + }) + .toString()); + assertEquals(2, hops[0]); + + assertEquals( + "ODP supporting resource redirect changed origin", + assertThrows( + IllegalStateException.class, + () -> get(request -> Responses.of( + request, + 302, + "", + Map.of("Location", List.of("https://elsewhere.example/root.json"))))) + .getMessage()); + assertEquals( + "ODP supporting resource redirect omitted Location", + assertThrows( + IllegalStateException.class, + () -> get(request -> Responses.of(request, 302, "", Map.of()))) + .getMessage()); + assertEquals( + "ODP supporting resource exceeded its redirect limit", + assertThrows( + IllegalStateException.class, + () -> get(request -> Responses.of( + request, + 302, + "", + Map.of("Location", List.of("https://schemas.example/again"))))) + .getMessage()); + assertEquals( + "ODP supporting document URL must use HTTPS", + assertThrows( + IllegalArgumentException.class, + () -> get(request -> Responses.of( + request, + 302, + "", + Map.of("Location", List.of("http://schemas.example/root.json"))))) + .getMessage()); + } + + @Test + void reportsATransportThatCouldNotAnswer() { + assertEquals( + "ODP supporting resource request failed", + assertThrows( + IllegalStateException.class, + () -> get(request -> { + throw new java.io.IOException("connection reset by peer"); + })) + .getMessage()); + assertEquals( + "ODP supporting resource request was interrupted", + assertThrows( + IllegalStateException.class, + () -> get(request -> { + throw new InterruptedException("stopped"); + })) + .getMessage()); + assertTrue(Thread.interrupted()); + } + + private static SupportingJsonClient client(Transport transport) { + return new SupportingJsonClient(transport::send); + } + + private static Object get(Transport transport) { + return client(transport).get(ROOT, SCHEMA_TYPE, Set.of(SCHEMA_TYPE), 1024, 16); + } + + @FunctionalInterface + private interface Transport { + HttpResponse send(HttpRequest request) throws java.io.IOException, InterruptedException; + } +} diff --git a/odp-agent/src/test/java/org/offeringprotocol/odp/agent/TransportConformanceTest.java b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/TransportConformanceTest.java new file mode 100644 index 0000000..199fbdf --- /dev/null +++ b/odp-agent/src/test/java/org/offeringprotocol/odp/agent/TransportConformanceTest.java @@ -0,0 +1,286 @@ +package org.offeringprotocol.odp.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.net.http.HttpRequest; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.offeringprotocol.odp.core.OdpResponseLimitException; +import org.offeringprotocol.odp.core.SearchRequests; + +class TransportConformanceTest { + private static final URI SERVICE = URI.create("https://plants.example"); + private static final String EMPTY_PAGE = "{\"odp_version\":\"1.0\",\"items\":[]}"; + + /** ERR-21: the Service Document is read under 65,536 bytes, a catalog response under 524,288. */ + @Test + void refusesADocumentPastItsByteLimit() { + String padded = padded(Responses.SERVICE_DOCUMENT, 70_000); + OdpResponseLimitException document = assertThrows( + OdpResponseLimitException.class, + () -> OdpServiceClient.create(SERVICE, request -> Responses.ok(request, padded))); + assertEquals("ODP response exceeds its byte limit", document.getMessage()); + + String page = padded(EMPTY_PAGE, 600_000); + OdpServiceClient client = Responses.serving(request -> page); + OdpResponseLimitException listing = + assertThrows(OdpResponseLimitException.class, () -> client.listOfferings(null, null, null)); + assertEquals("ODP response exceeds its byte limit", listing.getMessage()); + } + + /** A Service Document that would be a legal catalog page is still too large to be a document. */ + @Test + void holdsTheServiceDocumentToItsOwnBudget() { + String padded = padded(Responses.SERVICE_DOCUMENT, 70_000); + assertThrows( + OdpResponseLimitException.class, + () -> OdpServiceClient.create(SERVICE, request -> Responses.ok(request, padded))); + OdpServiceClient client = Responses.serving(request -> padded(EMPTY_PAGE, 70_000)); + assertEquals(0, client.listOfferings(null, null, null).items().size()); + } + + /** ERR-20: a declared length past the limit is refused before the body is read. */ + @Test + void refusesADeclaredLengthPastTheLimit() { + OdpResponseLimitException failure = assertThrows( + OdpResponseLimitException.class, + () -> OdpServiceClient.create( + SERVICE, + request -> Responses.of( + request, + 200, + Responses.SERVICE_DOCUMENT, + Map.of( + "Content-Type", + List.of(Responses.ODP), + "Content-Length", + List.of("10000000"))))); + assertEquals("ODP response exceeds its byte limit", failure.getMessage()); + } + + /** ERR-18: nesting is bounded at 16 for a catalog response and at 8 for the Service Document. */ + @Test + void refusesADocumentNestedPastItsLimit() { + String tower = "[".repeat(20) + "0" + "]".repeat(20); + OdpServiceClient client = + Responses.serving(request -> "{\"odp_version\":\"1.0\",\"items\":[],\"x_tower\":" + tower + "}"); + OdpResponseLimitException listing = + assertThrows(OdpResponseLimitException.class, () -> client.listOfferings(null, null, null)); + assertEquals("ODP response exceeds its nesting-depth limit", listing.getMessage()); + + String deepDocument = Responses.SERVICE_DOCUMENT.strip(); + deepDocument = deepDocument.substring(0, deepDocument.length() - 1) + ",\"x_tower\":" + "[".repeat(10) + "0" + + "]".repeat(10) + "}"; + String document = deepDocument; + assertThrows( + OdpResponseLimitException.class, + () -> OdpServiceClient.create(SERVICE, request -> Responses.ok(request, document))); + } + + /** MED-08 and MED-09: the media-type essence is compared whole, and case-insensitively. */ + @Test + void refusesAMediaTypeThatIsNotTheOdpOne() { + for (String contentType : List.of( + "application/json", + "application/odp+jsonx", + "", + "application/odp", + "application/odp+json; broken", + "application/odp+json; p=\"unterminated")) { + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> OdpServiceClient.create( + SERVICE, + request -> Responses.of( + request, + 200, + Responses.SERVICE_DOCUMENT, + Map.of("Content-Type", List.of(contentType)))), + contentType); + assertEquals("ODP response must use application/odp+json", failure.getMessage()); + } + OdpServiceClient client = OdpServiceClient.create( + SERVICE, + request -> Responses.of( + request, + 200, + Responses.SERVICE_DOCUMENT, + Map.of("Content-Type", List.of("APPLICATION/ODP+JSON; charset=utf-8")))); + assertEquals("Plant Store", client.inspection().document().name()); + } + + @Test + void carriesTheHeadersEachRequestNeeds() { + Responses.Recorder recorder = new Responses.Recorder(); + OdpServiceClient client = OdpServiceClient.create(SERVICE, request -> { + recorder.record(request); + return Responses.ok( + request, + request.uri().getPath().equals("/.well-known/odp") ? Responses.SERVICE_DOCUMENT : EMPTY_PAGE); + }); + client.listOfferings("full", 25, "fr"); + HttpRequest listing = recorder.last(); + assertEquals( + "application/odp+json, application/problem+json", + listing.headers().firstValue("Accept").orElseThrow()); + assertEquals("fr", listing.headers().firstValue("Accept-Language").orElseThrow()); + assertTrue( + listing.uri().getQuery().contains("representation=full"), + listing.uri().toString()); + assertTrue(listing.uri().getQuery().contains("limit=25"), listing.uri().toString()); + + client.searchOfferings( + new SearchRequests.Offerings("1.0", "gpu", null, null, null, null, null, null), null, null); + HttpRequest search = recorder.last(); + assertEquals("POST", search.method()); + assertEquals(Responses.ODP, search.headers().firstValue("Content-Type").orElseThrow()); + assertTrue(search.headers().firstValue("Accept-Language").isEmpty()); + } + + /** ERR-05: a Problem Details document whose status disagrees describes a different failure. */ + @Test + void readsAProblemOnlyWhenItDescribesTheFailureThatOccurred() { + String problem = """ + {"code":"NOT_FOUND","status":404,"title":"Absent", + "type":"https://offeringprotocol.org/problems/not-found"} + """; + OdpRequestException matching = assertThrows( + OdpRequestException.class, + () -> failing(404, problem, Responses.PROBLEM).listOfferings(null, null, null)); + assertEquals(404, matching.status()); + assertEquals("Absent", matching.getMessage()); + assertNotNull(matching.problem()); + assertNotNull(matching.headers()); + + OdpRequestException mismatched = assertThrows( + OdpRequestException.class, + () -> failing(503, problem, Responses.PROBLEM).listOfferings(null, null, null)); + assertEquals(503, mismatched.status()); + assertEquals("ODP request failed with HTTP 503", mismatched.getMessage()); + assertNull(mismatched.problem()); + + OdpRequestException plain = assertThrows( + OdpRequestException.class, + () -> failing(500, "not a problem document", "text/plain").listOfferings(null, null, null)); + assertEquals("ODP request failed with HTTP 500", plain.getMessage()); + assertNull(plain.problem()); + } + + /** ERR-21: a Problem Details response is read under 16,384 bytes, not the catalog budget. */ + @Test + void refusesAProblemPastItsOwnByteLimit() { + String problem = "{\"code\":\"NOT_FOUND\",\"status\":404,\"title\":\"Absent\",\"x_pad\":\"" + "t".repeat(20_000) + + "\",\"type\":\"https://offeringprotocol.org/problems/not-found\"}"; + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> failing(404, problem, Responses.PROBLEM).listOfferings(null, null, null)); + assertEquals("ODP response exceeds its byte limit", failure.getMessage()); + } + + @Test + void followsSameOriginRedirectsAndRefusesTheRest() { + Responses.Recorder recorder = new Responses.Recorder(); + OdpServiceClient client = OdpServiceClient.create(SERVICE, request -> { + recorder.record(request); + if (request.uri().getPath().equals("/.well-known/odp")) { + return Responses.of(request, 308, "", Map.of("Location", List.of("https://plants.example/moved"))); + } + return Responses.ok(request, Responses.SERVICE_DOCUMENT); + }); + assertEquals("Plant Store", client.inspection().document().name()); + assertEquals(2, recorder.requests().size()); + + assertEquals( + "ODP redirect changed Service origin", + assertThrows(IllegalStateException.class, () -> redirectingTo("https://elsewhere.example/odp")) + .getMessage()); + assertEquals( + "ODP redirect omitted Location", + assertThrows(IllegalStateException.class, () -> redirectingTo(null)) + .getMessage()); + assertEquals( + "ODP response exceeded its redirect limit", + assertThrows(IllegalStateException.class, () -> redirectingTo("https://plants.example/again")) + .getMessage()); + } + + /** A 303, and a 301 or 302 answering a POST, continue as a GET without the original body. */ + @Test + void rewritesTheMethodEachRedirectStatusRequires() { + for (int status : List.of(301, 302, 303)) { + Responses.Recorder recorder = new Responses.Recorder(); + OdpServiceClient client = OdpServiceClient.create(SERVICE, request -> { + recorder.record(request); + if (request.uri().getPath().equals("/.well-known/odp")) { + return Responses.ok(request, Responses.SERVICE_DOCUMENT); + } + if (request.uri().getPath().equals("/odp/offerings/search")) { + return Responses.of( + request, status, "", Map.of("Location", List.of("https://plants.example/odp/moved"))); + } + return Responses.ok(request, EMPTY_PAGE); + }); + client.searchOfferings( + new SearchRequests.Offerings("1.0", "gpu", null, null, null, null, null, null), null, null); + assertEquals("GET", recorder.last().method(), "status " + status); + } + Responses.Recorder preserved = new Responses.Recorder(); + OdpServiceClient client = OdpServiceClient.create(SERVICE, request -> { + preserved.record(request); + if (request.uri().getPath().equals("/.well-known/odp")) { + return Responses.ok(request, Responses.SERVICE_DOCUMENT); + } + if (request.uri().getPath().equals("/odp/offerings/search")) { + return Responses.of(request, 307, "", Map.of("Location", List.of("https://plants.example/odp/moved"))); + } + return Responses.ok(request, EMPTY_PAGE); + }); + client.searchOfferings( + new SearchRequests.Offerings("1.0", "gpu", null, null, null, null, null, null), null, null); + assertEquals("POST", preserved.last().method()); + } + + @Test + void reportsATransportThatCouldNotAnswer() { + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> OdpServiceClient.create(SERVICE, request -> { + throw new java.io.IOException("connection reset by peer"); + })); + assertEquals("ODP request failed", failure.getMessage()); + + IllegalStateException interrupted = assertThrows( + IllegalStateException.class, + () -> OdpServiceClient.create(SERVICE, request -> { + throw new InterruptedException("stopped"); + })); + assertEquals("ODP request was interrupted", interrupted.getMessage()); + assertTrue(Thread.interrupted()); + } + + private static OdpServiceClient failing(int status, String body, String contentType) { + return OdpServiceClient.create( + SERVICE, + request -> request.uri().getPath().equals("/.well-known/odp") + ? Responses.ok(request, Responses.SERVICE_DOCUMENT) + : Responses.of(request, status, body, Map.of("Content-Type", List.of(contentType)))); + } + + private static void redirectingTo(String location) { + OdpServiceClient.create( + SERVICE, + request -> Responses.of( + request, 302, "", location == null ? Map.of() : Map.of("Location", List.of(location)))); + } + + private static String padded(String json, int bytes) { + String trimmed = json.strip(); + return trimmed.substring(0, trimmed.length() - 1) + ",\"x_pad\":\"" + "p".repeat(bytes) + "\"}"; + } +} diff --git a/odp-core/README.md b/odp-core/README.md index d6a091f..d6162f3 100644 --- a/odp-core/README.md +++ b/odp-core/README.md @@ -22,6 +22,10 @@ and does not select a JSON library or application framework. `OdpJson` validates incoming JSON against the exact ODP schemas bundled in the published JAR before decoding it into immutable Java models. +SDK-generated requests and Service Document builders use protocol version `1.0`. Incoming `1.x` +documents are validated using the `1.0` schemas. Parsed models retain the received version. +Unsupported major versions and malformed version strings are rejected. + ```java try { ServiceDocument document = OdpJson.parseServiceDocument(responseBody); @@ -90,12 +94,43 @@ loader: Page first = client.listOfferings("terse", 25, "en"); List offerings = OdpPagination.items( first, - next -> client.continueOfferings(next, "en")); + next -> client.continueOfferings(next, "terse", "en")); +``` + +For incremental consumption, use a synchronous, single-use iterator: + +```java +Iterator offerings = OdpPagination.iterate( + () -> client.listOfferings("terse", 25, "en"), + next -> client.continueOfferings(next, "terse", "en"), + 100); +while (offerings.hasNext()) { + consume(offerings.next()); +} ``` -`OdpPagination` detects continuation loops and limits one traversal to 16 pages. Applications that -need independent cancellation, streaming, or a lower result ceiling can follow pages directly and -stop before invoking the next loader. +The first page is fetched on the first `hasNext()` or `next()` call. Further pages are fetched only +when needed. The final argument limits total items, independently of the Service page size; zero +performs no requests. Stop calling the iterator to stop fetching. A failed page terminates the +traversal without losing items already delivered; subsequent calls rethrow that failure without +another request. Response limits throw `OdpResponseLimitException` directly, with code +`RESPONSE_LIMIT_EXCEEDED` and `retryable()` set to `false`. Other loader failures are retained as the cause of an +`IllegalStateException`. Iterators are not thread-safe. Applications own any asynchronous wrapping. + +Both helpers detect continuation loops and enforce a local maximum of 16 pages per traversal. +Applications following explicit pages can choose their own traversal bounds. + +## Search validation + +`SearchCatalog` accepts the effective Filter and Sort definitions for one search scope and exposes +indexed definitions, with each Sort's Filter references resolved. `validateRequest` checks Filter +operators and typed values, Sort availability, and refinable identifiers. `validateRefinements` +checks the returned groups against the request and returns groups paired with their Filter +Definitions. Decimal equality is numeric; date-time equality compares instants. + +This class performs no network or database work. The Agent module resolves inline and linked +sources. A Service supplies definitions from its own catalog and remains responsible for executing +queries and computing accurate refinement counts. ## Payment option vocabulary diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpAddresses.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpAddresses.java new file mode 100644 index 0000000..952ba5a --- /dev/null +++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpAddresses.java @@ -0,0 +1,120 @@ +package org.offeringprotocol.odp.core; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.List; + +/** + * The IANA special-purpose address registries (RFC 6890 and its successors), as a single question: + * is this address one the public internet routes? SEC-08 turns on the answer, so both the Agent + * checking where it is about to connect and the client reading an address out of a third party's + * document ask it here rather than each keeping its own idea of what is internal. + * + *

The IPv6 transition ranges matter as much as the obvious ones: each embeds an IPv4 address, so + * without them an address of {@code 64:ff9b::a9fe:a9fe} reaches link-local 169.254.169.254. + */ +@SuppressWarnings("PMD.AvoidUsingHardCodedIP") // The registries this enforces are literal addresses. +public final class OdpAddresses { + private static final int OCTET = 0xFF; + private static final int IPV6_BYTES = 16; + private static final int IPV4_MAPPED_PREFIX_BYTES = 10; + private static final List NON_PUBLIC = List.of( + prefix("0.0.0.0", 8), + prefix("10.0.0.0", 8), + prefix("100.64.0.0", 10), + prefix("127.0.0.0", 8), + prefix("169.254.0.0", 16), + prefix("172.16.0.0", 12), + prefix("192.0.0.0", 24), + prefix("192.0.2.0", 24), + prefix("192.31.196.0", 24), + prefix("192.88.99.0", 24), + prefix("192.168.0.0", 16), + prefix("192.175.48.0", 24), + prefix("198.18.0.0", 15), + prefix("198.51.100.0", 24), + prefix("203.0.113.0", 24), + prefix("224.0.0.0", 4), + prefix("240.0.0.0", 4), + prefix("::", 96), + prefix("64:ff9b::", 96), + prefix("64:ff9b:1::", 48), + prefix("100::", 64), + prefix("2001::", 32), + prefix("2001:2::", 48), + prefix("2001:3::", 32), + prefix("2001:4:112::", 48), + prefix("2001:10::", 28), + prefix("2001:20::", 28), + prefix("2001:30::", 28), + prefix("2001:db8::", 32), + prefix("2002::", 16), + prefix("2620:4f:8000::", 48), + prefix("5f00::", 16), + prefix("fc00::", 7), + prefix("fe80::", 10), + prefix("fec0::", 10), + prefix("ff00::", 8)); + + private OdpAddresses() {} + + /** True when the address falls in no special-purpose range, so the public internet routes it. */ + public static boolean isPublic(InetAddress address) { + byte[] bytes = unmap(address.getAddress()); + for (Prefix candidate : NON_PUBLIC) { + if (candidate.contains(bytes)) { + return false; + } + } + return true; + } + + /** An IPv4-mapped IPv6 address is the IPv4 address it carries, and is judged as one. */ + private static byte[] unmap(byte[] bytes) { + if (!isIpv4Mapped(bytes)) { + return bytes; + } + return new byte[] {bytes[12], bytes[13], bytes[14], bytes[15]}; + } + + private static boolean isIpv4Mapped(byte[] bytes) { + if (bytes.length != IPV6_BYTES) { + return false; + } + for (int index = 0; index < IPV4_MAPPED_PREFIX_BYTES; index++) { + if (bytes[index] != 0) { + return false; + } + } + return (bytes[IPV4_MAPPED_PREFIX_BYTES] & OCTET) == OCTET + && (bytes[IPV4_MAPPED_PREFIX_BYTES + 1] & OCTET) == OCTET; + } + + private static Prefix prefix(String network, int bits) { + try { + return new Prefix(InetAddress.getByName(network).getAddress(), bits); + } catch (UnknownHostException exception) { + throw new IllegalStateException("Unable to read the special-purpose address registry", exception); + } + } + + private record Prefix(byte[] network, int bits) { + boolean contains(byte[] address) { + if (address.length != network.length) { + return false; + } + int whole = bits / Byte.SIZE; + for (int index = 0; index < whole; index++) { + if (address[index] != network[index]) { + return false; + } + } + int remainder = bits % Byte.SIZE; + if (remainder == 0) { + return true; + } + int mask = (OCTET << (Byte.SIZE - remainder)) & OCTET; + return (address[whole] & mask) == (network[whole] & mask); + } + } +} diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpJson.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpJson.java index f576367..7ffd153 100644 --- a/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpJson.java +++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpJson.java @@ -13,11 +13,18 @@ /** Validated JSON encoding and decoding for ODP documents. */ public final class OdpJson { + private static final String OPERATIONS_FIELD = "operations"; + private static final String VERSION_FIELD = "odp_version"; + private static final String JSON_ERROR = "json"; + private static final String SEARCH_CAPABILITIES_FIELD = "search_capabilities"; + private static final String SEARCH_OFFERINGS = "search-offerings"; + private static final String FILTER_DEFINITION = "Filter Definition"; private static final String FIELD_NAME = "name"; private static final String FIELD_TYPE = "type"; private static final String FIELD_URL = "url"; private static final String OFFERING = "offering"; private static final int REQUIRED_PROVIDER_COUNT = 1; + private static final int UNIQUE_ACTION_COUNT = 1; private static final String SCHEMA_ORIGIN = "https://offeringprotocol.org/schemas/"; private static final String SCHEMA_PATH = "/org/offeringprotocol/odp/core/schemas/"; private static final String SERVICE_DOCUMENT = "Service Document"; @@ -30,6 +37,13 @@ private OdpJson() {} public static ServiceDocument parseServiceDocument(String json) { ServiceDocument document = parse(json, "service-document.schema.json", SERVICE_DOCUMENT, ServiceDocument.class); validateLocalizations(document.language(), document.localizations(), SERVICE_DOCUMENT); + validateCapabilities(document.searchCapabilities()); + if (document.searchCapabilities() != null + && document.operations().stream() + .noneMatch(operation -> operation.name() == OdpOperation.SEARCH_OFFERINGS)) { + throw semanticError( + SERVICE_DOCUMENT, "search_capabilities requires search-offerings", "/search_capabilities"); + } if (document.additional().containsKey("web_url")) { throw semanticError(SERVICE_DOCUMENT, "web_url is not permitted", "/web_url"); } @@ -51,7 +65,7 @@ public static String normalizeAgentResponse(String json, String kind) { } catch (IllegalArgumentException exception) { throw new OdpValidationException( "Agent response", - List.of(new ValidationIssue("json", exception.getMessage(), Map.of(), "")), + List.of(new ValidationIssue(JSON_ERROR, exception.getMessage(), Map.of(), "")), exception); } } @@ -66,7 +80,7 @@ private static void normalizeAgentDocument(OdpJsonNode document, String kind) { } filterNamedList( document, - "operations", + OPERATIONS_FIELD, Set.of( "get-collection", "get-offering", @@ -74,10 +88,10 @@ private static void normalizeAgentDocument(OdpJsonNode document, String kind) { "list-collections", "list-offerings", "search-collections", - "search-offerings")); - filterUnknownAuthentication(document, "operations"); + SEARCH_OFFERINGS)); + filterUnknownAuthentication(document, OPERATIONS_FIELD); filterTypedList(document, "mcp", Set.of("streamable-http")); - filterClosedObjectList(document, "operations", Set.of("authentication", FIELD_NAME)); + filterClosedObjectList(document, OPERATIONS_FIELD, Set.of("authentication", FIELD_NAME)); filterClosedObjectList(document, "mcp", Set.of("description", FIELD_NAME, FIELD_TYPE, FIELD_URL)); filterPaymentOptions(document); normalizeBranding(document); @@ -239,14 +253,36 @@ private static void normalizeBranding(OdpJsonNode document) { } private static void normalizeSearchCapabilities(OdpJsonNode document) { - OdpJsonNode value = document.get("search_capabilities"); - if (value == null || !value.isObject()) { + OdpJsonNode value = document.get(SEARCH_CAPABILITIES_FIELD); + if (value == null) return; + if (!value.isObject()) { + document.remove(SEARCH_CAPABILITIES_FIELD); return; } + OdpJsonNode operations = document.get(OPERATIONS_FIELD); + if (operations != null && operations.isArray()) { + boolean search = false; + for (OdpJsonNode operation : operations) { + if (SEARCH_OFFERINGS.equals(operation.path("name").asString())) search = true; + } + if (!search) { + document.remove(SEARCH_CAPABILITIES_FIELD); + return; + } + } filterInlineDefinitions(value, "filters", true); filterInlineDefinitions(value, "sorts", false); + for (String member : List.of("filters", "sorts")) { + if (value.has(member)) { + try { + parseSearchCapabilities("{\"" + member + "\":" + value.get(member) + "}"); + } catch (OdpValidationException exception) { + value.remove(member); + } + } + } if (value.isEmpty()) { - document.remove("search_capabilities"); + document.remove(SEARCH_CAPABILITIES_FIELD); } } @@ -257,6 +293,20 @@ private static void filterInlineDefinitions(OdpJsonNode capabilities, String mem return; } inline.removeIf(item -> item.isObject() && !(filters ? knownFilter(item) : knownSort(item))); + if (filters) { + try { + for (OdpJsonNode item : inline) { + validateFilter(parse( + item.toString(), + "filter-definition.schema.json", + FILTER_DEFINITION, + SearchCapabilities.FilterDefinition.class)); + } + } catch (OdpValidationException exception) { + capabilities.remove(member); + return; + } + } if (inline.isEmpty()) { capabilities.remove(member); } @@ -281,44 +331,23 @@ private static void normalizeOffering(OdpJsonNode document) { if (actions == null || !actions.isArray()) { return; } - actions.removeIf(action -> { - if (action.isObject() && hasUnknownAuthentication(action)) { - return true; + Map counts = new HashMap<>(); + actions.forEach(action -> { + if (action.path("id").isString()) { + counts.merge(action.path("id").asString(), 1, Integer::sum); } - if (action.isObject() - && action.fieldNames().stream() - .anyMatch(name -> !Set.of("authentication", "description", "http", "id", "openapi", "rel") - .contains(name))) { - return true; - } - OdpJsonNode http = action.get("http"); - if (http != null - && http.isObject() - && http.fieldNames().stream() - .anyMatch(name -> !Set.of("href", "method", "request", "response_content_types") - .contains(name))) { - return true; - } - OdpJsonNode request = action.at("/http/request"); - if (request.isObject() - && request.fieldNames().stream() - .anyMatch(name -> !Set.of("content_type", "schema").contains(name))) { - return true; - } - OdpJsonNode actionSchema = action.at("/http/request/schema"); - if (actionSchema.isObject() - && actionSchema.fieldNames().stream().anyMatch(name -> !FIELD_URL.equals(name))) { + }); + actions.removeIf(action -> { + if (action.path("id").isString() + && counts.getOrDefault(action.path("id").asString(), 0) > UNIQUE_ACTION_COUNT) { return true; } - OdpJsonNode openapi = action.get("openapi"); - if (openapi != null - && openapi.isObject() - && openapi.fieldNames().stream() - .anyMatch(name -> !Set.of("operation_id", FIELD_URL).contains(name))) { + try { + validate(action.toString(), "action.schema.json", "Action"); + return false; + } catch (OdpValidationException exception) { return true; } - OdpJsonNode method = action.at("/http/method"); - return method.isString() && !Set.of("GET", "POST").contains(method.asString()); }); if (actions.isEmpty()) { document.remove("actions"); @@ -392,6 +421,7 @@ public static Collection parseCollection(String json) { Collection collection = parse(json, "collection.schema.json", "Collection", Collection.class); validateLocalizations(collection.language(), collection.localizations(), "Collection"); validateImages(collection.images(), "Collection"); + validateCapabilities(collection.searchCapabilities()); return collection; } @@ -425,31 +455,56 @@ public static SearchRequests.Offerings parseOfferingSearchRequest(String json) { json, "offering-search-request.schema.json", "Offering search request", SearchRequests.Offerings.class); } + public static SearchCapabilities.FilterDefinition parseFilterDefinition(String json) { + SearchCapabilities.FilterDefinition definition = parse( + json, "filter-definition.schema.json", FILTER_DEFINITION, SearchCapabilities.FilterDefinition.class); + validateFilter(definition); + return definition; + } + + public static SearchCapabilities.SortDefinition parseSortDefinition(String json) { + SearchCapabilities.SortDefinition definition = + parse(json, "sort-definition.schema.json", "Sort Definition", SearchCapabilities.SortDefinition.class); + Set keys = new java.util.HashSet<>(); + if (definition.keys().stream().anyMatch(key -> !keys.add(key.filterId()))) { + throw semanticError("Sort Definition", "Filter identifiers must be distinct", "/keys"); + } + return definition; + } + + public static SearchCapabilities parseSearchCapabilities(String json) { + SearchCapabilities capabilities = + parse(json, "search-capabilities.schema.json", "Search capabilities", SearchCapabilities.class); + validateCapabilities(capabilities); + return capabilities; + } + public static OfferingPage parseOfferingSearchResponse(String json) { OfferingPage page = parse(json, "offering-search-response.schema.json", "Offering search response", OfferingPage.class); - page.items() - .forEach(item -> - parseOffering(withInheritedVersion(write(item), page.odpVersion(), item.odpVersion() != null))); + validatePageItems(json, Offering.class); return page; } public static Page parsePage(String json, Class itemType) { validate(json, "page-envelope.schema.json", "page envelope"); - Page page = decodePage(json, itemType, "page envelope"); - if (itemType == Collection.class) { - page.items().forEach(item -> { - Collection collection = (Collection) item; - parseCollection( - withInheritedVersion(write(collection), page.odpVersion(), collection.odpVersion() != null)); - }); - } else if (itemType == Offering.class) { - page.items().forEach(item -> { - Offering offering = (Offering) item; - parseOffering(withInheritedVersion(write(offering), page.odpVersion(), offering.odpVersion() != null)); + validatePageItems(json, itemType); + return decodePage(json, itemType, "page envelope"); + } + + private static void validatePageItems(String json, Class itemType) { + if (itemType == Collection.class || itemType == Offering.class) { + OdpJsonNode document = parseTree(json); + String version = document.path(VERSION_FIELD).asString(); + document.path("items").forEach(item -> { + String inherited = withInheritedVersion(item.toString(), version, item.has(VERSION_FIELD)); + if (itemType == Collection.class) { + parseCollection(inherited); + } else { + parseOffering(inherited); + } }); } - return page; } public static String write(Object value) { @@ -530,7 +585,7 @@ private static T decode(String json, Class type, String documentType) { } catch (IllegalArgumentException exception) { throw new OdpValidationException( documentType, - List.of(new ValidationIssue("json", exception.getMessage(), Map.of(), "")), + List.of(new ValidationIssue(JSON_ERROR, exception.getMessage(), Map.of(), "")), exception); } } @@ -541,7 +596,7 @@ private static Page decodePage(String json, Class itemType, String doc } catch (IllegalArgumentException exception) { throw new OdpValidationException( documentType, - List.of(new ValidationIssue("json", exception.getMessage(), Map.of(), "")), + List.of(new ValidationIssue(JSON_ERROR, exception.getMessage(), Map.of(), "")), exception); } } @@ -632,6 +687,40 @@ private static void validateImages(List images, String documentTy } } + private static void validateCapabilities(SearchCapabilities capabilities) { + if (capabilities == null) return; + if (capabilities.filters() != null && capabilities.filters().inline() != null) { + Set identifiers = new java.util.HashSet<>(); + for (SearchCapabilities.FilterDefinition filter : + capabilities.filters().inline()) { + validateFilter(filter); + if (!identifiers.add(filter.id())) + throw semanticError("Search capabilities", "Duplicate Filter identifier", "/filters/inline"); + } + } + if (capabilities.sorts() != null && capabilities.sorts().inline() != null) { + Set identifiers = new java.util.HashSet<>(); + for (SearchCapabilities.SortDefinition sort : capabilities.sorts().inline()) { + parseSortDefinition(write(sort)); + if (!identifiers.add(sort.id())) + throw semanticError("Search capabilities", "Duplicate Sort identifier", "/sorts/inline"); + } + } + } + + private static void validateFilter(SearchCapabilities.FilterDefinition filter) { + boolean numeric = Set.of("integer", "number", "decimal").contains(filter.type()); + boolean ordered = numeric || Set.of("date", "date-time").contains(filter.type()); + if (filter.unit() != null && !numeric) { + throw semanticError(FILTER_DEFINITION, "unit requires a numeric type", "/unit"); + } + if (!ordered + && filter.operators().stream() + .anyMatch(operator -> Set.of("lt", "lte", "gt", "gte").contains(operator))) { + throw semanticError(FILTER_DEFINITION, "comparison operator requires an ordered type", "/operators"); + } + } + private static OdpValidationException semanticError(String documentType, String message, String path) { return semanticError(documentType, message, path, null); } @@ -643,7 +732,26 @@ private static OdpValidationException semanticError( } private static void validate(String json, String schemaName, String documentType) { - List issues = SCHEMAS.get(schemaName).validate(json); + String validationJson = json; + OdpJsonNode document; + try { + document = parseTree(json); + } catch (IllegalArgumentException exception) { + throw new OdpValidationException( + documentType, + List.of(new ValidationIssue(JSON_ERROR, exception.getMessage(), Map.of(), "")), + exception); + } + OdpJsonNode version = document == null ? null : document.get(VERSION_FIELD); + if (version != null && version.isString() && !Odp.VERSION.equals(version.asString())) { + String major = Odp.VERSION.substring(0, Odp.VERSION.indexOf('.')); + if (version.asString().matches(major + "\\.(0|[1-9][0-9]*)")) { + // Validate compatible minor versions against this implementation's schema. + document.put(VERSION_FIELD, Odp.VERSION); + validationJson = document.toString(); + } + } + List issues = SCHEMAS.get(schemaName).validate(validationJson); if (!issues.isEmpty()) { throw new OdpValidationException(documentType, issues); } diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpMediaType.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpMediaType.java new file mode 100644 index 0000000..f115e44 --- /dev/null +++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpMediaType.java @@ -0,0 +1,81 @@ +package org.offeringprotocol.odp.core; + +import java.util.Locale; + +/** HTTP media-type syntax validation without interpreting parameters. */ +public final class OdpMediaType { + private static final char SEMICOLON = ';'; + private static final char QUOTE = '"'; + private static final char BACKSLASH = '\\'; + + private OdpMediaType() {} + + /** Returns the lowercase type/subtype, or an empty string for invalid syntax. */ + public static String essence(String value) { + if (value == null) return ""; + int start = whitespace(value, 0); + int slash = tokenEnd(value, start); + if (slash == start || slash == value.length() || value.charAt(slash) != '/') return ""; + int end = tokenEnd(value, slash + 1); + if (end == slash + 1) return ""; + int position = whitespace(value, end); + while (position < value.length()) { + if (value.charAt(position) != SEMICOLON) return ""; + position++; + position = whitespace(value, position); + if (position == value.length() || value.charAt(position) == ';') continue; + int nameEnd = tokenEnd(value, position); + if (nameEnd == position || nameEnd == value.length() || value.charAt(nameEnd) != '=') return ""; + position = nameEnd + 1; + if (position < value.length() && value.charAt(position) == '"') { + position = quotedEnd(value, position + 1); + if (position < 0) return ""; + } else { + int valueEnd = tokenEnd(value, position); + if (valueEnd == position) return ""; + position = valueEnd; + } + position = whitespace(value, position); + } + return value.substring(start, end).toLowerCase(Locale.ROOT); + } + + private static int quotedEnd(String value, int start) { + int position = start; + while (position < value.length()) { + char current = value.charAt(position); + position++; + if (current == QUOTE) return position; + if (current == BACKSLASH) { + if (position == value.length()) return -1; + current = value.charAt(position); + position++; + } + if (current != '\t' && (current < 32 || current == 127 || current > 255)) return -1; + } + return -1; + } + + private static int whitespace(String value, int start) { + int position = start; + while (position < value.length() && (value.charAt(position) == ' ' || value.charAt(position) == '\t')) { + position++; + } + return position; + } + + private static int tokenEnd(String value, int start) { + int position = start; + while (position < value.length()) { + char current = value.charAt(position); + if (!(current >= 'a' && current <= 'z') + && !(current >= 'A' && current <= 'Z') + && !(current >= '0' && current <= '9') + && "!#$%&'*+-.^_`|~".indexOf(current) < 0) { + break; + } + position++; + } + return position; + } +} 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 index 7f9e43c..f81a5b1 100644 --- a/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpPagination.java +++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpPagination.java @@ -1,9 +1,14 @@ package org.offeringprotocol.odp.core; import java.util.ArrayList; +import java.util.Collections; import java.util.HashSet; +import java.util.Iterator; import java.util.List; +import java.util.NoSuchElementException; +import java.util.Objects; import java.util.Set; +import java.util.function.Supplier; /** Bounded helpers for following opaque ODP continuation references. */ public final class OdpPagination { @@ -37,6 +42,80 @@ public static List items(Page first, PageLoader loader) { .toList(); } + /** + * A single-use, synchronous iterator. Requests occur in hasNext or next, never in advance. + * The caller's item limit is independent of the Service page size. Not thread-safe. + */ + public static Iterator iterate(Supplier> first, PageLoader loader, long maximumItems) { + Objects.requireNonNull(first, "first"); + Objects.requireNonNull(loader, "loader"); + if (maximumItems < 0) { + throw new IllegalArgumentException("The item limit must not be negative"); + } + return new ItemIterator<>(first, loader, maximumItems); + } + + private static final class ItemIterator implements Iterator { + private final Supplier> first; + private final PageLoader loader; + private final long maximumItems; + private final Set continuations = new HashSet<>(); + private Page page; + private Iterator current = Collections.emptyIterator(); + private int pageCount; + private long emitted; + private IllegalStateException failure; + + private ItemIterator(Supplier> first, PageLoader loader, long maximumItems) { + this.first = first; + this.loader = loader; + this.maximumItems = maximumItems; + } + + @Override + public boolean hasNext() { + if (failure != null) { + throw failure; + } + if (emitted == maximumItems) { + return false; + } + try { + while (!current.hasNext()) { + if (page != null && page.next() == null) { + return false; + } + if (pageCount == MAXIMUM_PAGES) { + throw new IllegalStateException("ODP pagination exceeded the 16-page traversal limit"); + } + if (page != null && !continuations.add(page.next())) { + throw new IllegalStateException("ODP pagination loop detected"); + } + page = Objects.requireNonNull(page == null ? first.get() : loader.load(page.next()), "page"); + pageCount++; + current = page.items().iterator(); + } + return true; + } catch (OdpResponseLimitException exception) { + failure = exception; + throw exception; + } catch (RuntimeException exception) { + IllegalStateException terminal = new IllegalStateException("ODP pagination failed", exception); + failure = terminal; + throw terminal; + } + } + + @Override + public T next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + emitted++; + return current.next(); + } + } + @FunctionalInterface public interface PageLoader { Page load(String continuation); diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpResponseLimitException.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpResponseLimitException.java new file mode 100644 index 0000000..ba2cda1 --- /dev/null +++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/OdpResponseLimitException.java @@ -0,0 +1,17 @@ +package org.offeringprotocol.odp.core; + +public final class OdpResponseLimitException extends IllegalStateException { + private static final long serialVersionUID = 1L; + + public OdpResponseLimitException(String message) { + super(message); + } + + public String code() { + return "RESPONSE_LIMIT_EXCEEDED"; + } + + public boolean retryable() { + return false; + } +} 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 index bfabca2..cf59544 100644 --- a/odp-core/src/main/java/org/offeringprotocol/odp/core/OfferingPage.java +++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/OfferingPage.java @@ -19,6 +19,11 @@ public record OfferingPage( additional = Copies.nodes(additional); } + /** Item pagination without the initial search response's refinement groups. */ + public Page asPage() { + return new Page<>(authExpands, odpVersion, items, next, additional); + } + public record RefinementGroup(@JsonProperty("filter_id") String filterId, List values) { public RefinementGroup { values = List.copyOf(values); diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/SearchCatalog.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/SearchCatalog.java new file mode 100644 index 0000000..4a0f7c7 --- /dev/null +++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/SearchCatalog.java @@ -0,0 +1,237 @@ +package org.offeringprotocol.odp.core; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import org.offeringprotocol.odp.core.SearchCapabilities.FilterDefinition; +import org.offeringprotocol.odp.core.SearchCapabilities.SortDefinition; + +/** Validated definitions for one Service and optional selected Collection search scope. */ +public final class SearchCatalog { + private static final String EXISTS_OPERATOR = "exists"; + private static final String IN_OPERATOR = "in"; + private static final String UTC_OFFSET = "Z"; + private static final int MAXIMUM_FILTERS = 1024; + private static final int MAXIMUM_SORTS = 128; + private static final Pattern DECIMAL = Pattern.compile("-?(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?"); + private static final Pattern NUMBER = Pattern.compile("-?(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?(?:[eE][+-]?[0-9]+)?"); + private static final Pattern DATE = Pattern.compile("[0-9]{4}-[0-9]{2}-[0-9]{2}"); + private static final Pattern INSTANT = Pattern.compile( + "([0-9]{4}-[0-9]{2}-[0-9]{2}[Tt][0-9]{2}:[0-9]{2}:)([0-9]{2})(\\.[0-9]+)?([Zz]|[+-][0-9]{2}:[0-9]{2})"); + private final Map catalogFilters; + private final Map catalogSorts; + + public SearchCatalog(List filterDefinitions, List sortDefinitions) { + if (filterDefinitions.size() > MAXIMUM_FILTERS || sortDefinitions.size() > MAXIMUM_SORTS) { + throw new IllegalArgumentException("Search capability catalog exceeds its limit"); + } + Map filterMap = new LinkedHashMap<>(); + for (FilterDefinition candidate : filterDefinitions) { + FilterDefinition definition = OdpJson.parseFilterDefinition(OdpJson.write(candidate)); + if (filterMap.putIfAbsent(definition.id(), definition) != null) { + throw new IllegalArgumentException("Duplicate Filter identifier " + definition.id()); + } + } + Map sortMap = new LinkedHashMap<>(); + for (SortDefinition candidate : sortDefinitions) { + SortDefinition definition = OdpJson.parseSortDefinition(OdpJson.write(candidate)); + List resolved = definition.keys().stream() + .map(key -> { + FilterDefinition filter = filterMap.get(key.filterId()); + if (filter == null) throw new IllegalArgumentException("Sort references an unavailable Filter"); + return filter; + }) + .toList(); + if (sortMap.putIfAbsent(definition.id(), new ResolvedSort(definition, resolved)) != null) { + throw new IllegalArgumentException("Duplicate Sort identifier " + definition.id()); + } + } + catalogFilters = Collections.unmodifiableMap(filterMap); + catalogSorts = Collections.unmodifiableMap(sortMap); + } + + public Map filters() { + return catalogFilters; + } + + public Map sorts() { + return catalogSorts; + } + + public static void validateAdvertisement(SearchCapabilities capabilities, boolean searchSupported, String origin) { + if (capabilities == null) return; + if (!searchSupported) throw new IllegalArgumentException("Search capabilities require search-offerings"); + OdpJson.parseSearchCapabilities(OdpJson.write(capabilities)); + List links = new java.util.ArrayList<>(); + if (capabilities.filters() != null && capabilities.filters().linked() != null) + links.add(capabilities.filters().linked()); + if (capabilities.sorts() != null && capabilities.sorts().linked() != null) + links.add(capabilities.sorts().linked()); + for (SearchCapabilities.Link link : links) { + if (origin != null) OdpUris.resolveContinuation(link.href(), origin); + else if (!link.href().startsWith("/") || link.href().startsWith("//")) { + throw new IllegalArgumentException("Absolute capability links require a trusted Service origin"); + } + } + } + + public void validateRequest(SearchRequests.Offerings request) { + OdpJson.parseOfferingSearchRequest(OdpJson.write(request)); + if (request.filters() != null) { + for (SearchCapabilities.FilterExpression expression : request.filters()) { + FilterDefinition filter = requireFilter(expression.id()); + if (!filter.operators().contains(expression.operator())) { + throw new IllegalArgumentException("Filter operator is not advertised"); + } + if (EXISTS_OPERATOR.equals(expression.operator())) { + scalarKey("boolean", expression.value()); + } else if (IN_OPERATOR.equals(expression.operator())) { + if (!expression.value().isArray()) + throw new IllegalArgumentException("The in operator requires an array"); + Set values = new HashSet<>(); + for (OdpJsonNode value : expression.value()) { + if (!values.add(scalarKey(filter.type(), value))) { + throw new IllegalArgumentException("Filter values must be unique"); + } + } + } else { + scalarKey(filter.type(), expression.value()); + } + } + } + if (request.sort() != null && !catalogSorts.containsKey(request.sort())) { + throw new IllegalArgumentException("Sort is unavailable"); + } + if (request.refinements() != null) { + for (String id : request.refinements()) { + if (!Boolean.TRUE.equals(requireFilter(id).refinable())) { + throw new IllegalArgumentException("Filter is not refinable"); + } + } + } + } + + public List validateRefinements( + OfferingPage page, SearchRequests.Offerings request, boolean continuation) { + validateRefinementContext(page, request, continuation); + if (page.refinements() == null) return List.of(); + OdpJson.parseOfferingSearchResponse( + OdpJson.write(new OfferingPage(null, Odp.VERSION, List.of(), null, page.refinements(), Map.of()))); + return page.refinements().stream() + .map(group -> { + FilterDefinition filter = requireFilter(group.filterId()); + if (!Boolean.TRUE.equals(filter.refinable())) { + throw new IllegalArgumentException("Filter is not refinable"); + } + Set values = new HashSet<>(); + for (OfferingPage.RefinementBucket bucket : group.values()) { + if (!values.add(scalarKey(filter.type(), bucket.value()))) { + throw new IllegalArgumentException("Refinement values must be unique"); + } + } + return new ResolvedRefinement(filter, group.values()); + }) + .toList(); + } + + public static void validateRefinementContext( + OfferingPage page, SearchRequests.Offerings request, boolean continuation) { + if (page.refinements() == null) return; + if (continuation || request == null || request.refinements() == null) { + throw new IllegalArgumentException("Refinements require an initial request that asks for them"); + } + Set groups = new HashSet<>(); + for (OfferingPage.RefinementGroup group : page.refinements()) { + if (!request.refinements().contains(group.filterId()) || !groups.add(group.filterId())) { + throw new IllegalArgumentException("Refinement group is unrequested or repeated"); + } + } + } + + private FilterDefinition requireFilter(String id) { + FilterDefinition filter = catalogFilters.get(id); + if (filter == null) throw new IllegalArgumentException("Filter is unavailable: " + id); + return filter; + } + + private static Object scalarKey(String type, OdpJsonNode value) { + try { + return parseScalarKey(type, value); + } catch (java.time.DateTimeException | ArithmeticException exception) { + throw new IllegalArgumentException("Invalid Filter value for " + type, exception); + } + } + + private static Object parseScalarKey(String type, OdpJsonNode value) { + String raw = value.toString(); + String text = value.isString() ? value.asString() : null; + return switch (type) { + case "string" -> { + if (!value.isString()) throw invalidValue(type); + yield text; + } + case "boolean" -> { + if (!"true".equals(raw) && !"false".equals(raw)) throw invalidValue(type); + yield Boolean.valueOf(raw); + } + case "integer", "number", "decimal" -> { + boolean decimal = "decimal".equals(type); + if (decimal + ? !value.isString() || !DECIMAL.matcher(text).matches() + : !NUMBER.matcher(raw).matches()) throw invalidValue(type); + BigDecimal number = new BigDecimal(decimal ? text : raw).stripTrailingZeros(); + if ("integer".equals(type) && number.scale() > 0) throw invalidValue(type); + yield number; + } + case "date" -> { + if (!value.isString() || !DATE.matcher(text).matches()) throw invalidValue(type); + yield LocalDate.parse(text); + } + case "date-time" -> { + if (!value.isString()) throw invalidValue(type); + var matcher = INSTANT.matcher(text); + if (!matcher.matches()) throw invalidValue(type); + boolean leap = "60".equals(matcher.group(2)); + String whole = matcher.group(1) + (leap ? "59" : matcher.group(2)); + long seconds = + LocalDateTime.parse(whole.toUpperCase(Locale.ROOT)).toEpochSecond(ZoneOffset.UTC); + String offset = matcher.group(4); + if (!UTC_OFFSET.equalsIgnoreCase(offset)) { + int hours = Integer.parseInt(offset.substring(1, 3)); + int minutes = Integer.parseInt(offset.substring(4, 6)); + if (hours > 23 || minutes > 59) throw invalidValue(type); + seconds -= (offset.charAt(0) == '-' ? -1 : 1) * (hours * 3600L + minutes * 60L); + } + BigDecimal fraction = + matcher.group(3) == null ? BigDecimal.ZERO : new BigDecimal("0" + matcher.group(3)); + yield List.of(BigDecimal.valueOf(seconds).add(fraction).stripTrailingZeros(), leap); + } + default -> throw invalidValue(type); + }; + } + + private static IllegalArgumentException invalidValue(String type) { + return new IllegalArgumentException("Invalid Filter value for " + type); + } + + public record ResolvedSort(SortDefinition definition, List filters) { + public ResolvedSort { + filters = List.copyOf(filters); + } + } + + public record ResolvedRefinement(FilterDefinition filter, List values) { + public ResolvedRefinement { + values = List.copyOf(values); + } + } +} diff --git a/odp-directory/README.md b/odp-directory/README.md index 6c1d32e..b363a60 100644 --- a/odp-directory/README.md +++ b/odp-directory/README.md @@ -101,6 +101,33 @@ downloading a global vocabulary. Compatible results may advertise protocol names unknown to this library. The client filters those descriptors and preserves recognized enrollment, payment, and trust descriptors, including TAP. +## What the client checks in a result + +A directory result is a third party's description of somebody else's Service, and an Agent connects +to whatever `service_origin` names, so each result is checked before it is handed over: + +- `service_origin` must be the ASCII serialization of a secure origin — `https`, a lowercase host, + no port, path, query, fragment, or user information — naming a destination the public internet + routes. An address in the IANA special-purpose registries is refused, including the IPv6 + transition ranges that embed an IPv4 address. +- The rest of the result is held to the shape of the Service Document it summarizes. +- `branding`, `http`, `odp_version`, `payment_origins`, and `search_capabilities` are + removed. A directory summarizes a Service; it does not serve the Service's own document, so these + are not passed on as though the Agent had retrieved them. Retrieve them from the Service with + [`odp-agent`](../odp-agent/README.md). +- `mcp` metadata is preserved in `additional()` for display and inspection. Confirm connection + details against the live Service Document before using an endpoint. + +One unusable result does not discard the page it arrived on. It is dropped from `page.items()` and +reported in `page.issues()`, whose `index` is the result's position in the page as the directory +sent it: + +```java +for (DirectoryModels.Issue issue : page.issues()) { + System.out.printf("result %d was dropped: %s%n", issue.index(), issue.message()); +} +``` + ## Continue a search One call returns one page. When `page.next()` is non-null, submit that opaque value unchanged: @@ -112,8 +139,10 @@ while (page.next() != null) { } ``` -The client retrieves continuations with GET, keeps them on the selected canonical origin, limits -redirects to five, and bounds response bodies. Applications should impose their own total page and +The client retrieves continuations with GET, keeps them on the selected canonical origin (a +written-out default port still matches), limits redirects to five, and bounds response bodies — +524,288 bytes for a success and 16,384 for an error or redirect, enforced while reading rather +than after buffering an unlimited response. Applications should impose their own total page and item limit when following multiple pages. ## Suggestions @@ -168,9 +197,11 @@ The client does not persist or cache directory responses. ## Errors Non-success HTTP responses throw `DirectoryRequestException`, which preserves the status and -response headers. Invalid arguments and malformed successful responses use -`IllegalArgumentException`; transport, interruption, redirect, and response-boundary failures use -`IllegalStateException`. +response headers. Its message describes the request that failed; it quotes the response only when +that response is a JSON error document, and then only its `detail`, `title`, or `message` member, +flattened and truncated so an error body cannot forge a log line. Invalid arguments and malformed +successful responses use `IllegalArgumentException`; transport, interruption, redirect, and +response-boundary failures use `IllegalStateException`. ## Related documentation diff --git a/odp-directory/src/main/java/org/offeringprotocol/odp/directory/BoundedBodySubscriber.java b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/BoundedBodySubscriber.java new file mode 100644 index 0000000..8a71904 --- /dev/null +++ b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/BoundedBodySubscriber.java @@ -0,0 +1,60 @@ +package org.offeringprotocol.odp.directory; + +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Flow; + +final class BoundedBodySubscriber implements HttpResponse.BodySubscriber { + private final HttpResponse.BodySubscriber delegate = HttpResponse.BodySubscribers.ofByteArray(); + private final int maximumBytes; + private Flow.Subscription subscription; + private int received; + private boolean finished; + + BoundedBodySubscriber(int maximumBytes) { + this.maximumBytes = maximumBytes; + } + + @Override + public CompletionStage getBody() { + return delegate.getBody(); + } + + @Override + public void onSubscribe(Flow.Subscription value) { + subscription = value; + delegate.onSubscribe(value); + } + + @Override + public void onNext(List buffers) { + if (finished) return; + for (ByteBuffer buffer : buffers) { + if (buffer.remaining() > maximumBytes - received) { + subscription.cancel(); + onError(new IllegalStateException("Directory response exceeds its byte limit")); + return; + } + received += buffer.remaining(); + } + delegate.onNext(buffers); + } + + @Override + public void onError(Throwable failure) { + if (!finished) { + finished = true; + delegate.onError(failure); + } + } + + @Override + public void onComplete() { + if (!finished) { + finished = true; + delegate.onComplete(); + } + } +} 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 index cce1e65..29e7d73 100644 --- a/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryClient.java +++ b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryClient.java @@ -4,17 +4,21 @@ import java.net.URI; import java.net.URLEncoder; 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.time.Duration; +import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Set; import org.offeringprotocol.odp.core.OdpJson; import org.offeringprotocol.odp.core.OdpJsonNode; +import org.offeringprotocol.odp.core.OdpMediaType; import org.offeringprotocol.odp.core.ServiceDocument; /** Client for the canonical ODP directory. */ @@ -22,7 +26,26 @@ public final class DirectoryClient { private static final String METHOD_GET = "GET"; private static final String METHOD_POST = "POST"; private static final int MAXIMUM_BYTES = 524_288; + /** A failure message travels into logs, so an error body is read and quoted far more tightly. */ + private static final int MAXIMUM_ERROR_BYTES = 16_384; + + private static final int MAXIMUM_ERROR_CHARACTERS = 2_048; private static final int MAXIMUM_REDIRECTS = 5; + private static final int MAXIMUM_SUGGESTIONS = 25; + private static final int MAXIMUM_SUGGESTION_CHARACTERS = 128; + private static final int MAXIMUM_CONTINUATION_CHARACTERS = 2_048; + private static final String JSON = "application/json"; + private static final String BAD_PAGE = "Directory response is invalid"; + private static final String BAD_SUGGESTIONS = "Directory suggestions response is invalid"; + + /** + * Members a Service Document carries but a directory cannot vouch for. A directory summarizes a + * Service; it does not serve the Service's own document, so these are dropped rather than + * passed on as though the Agent had retrieved them. + */ + private static final List UNVERIFIED = + List.of("branding", "http", "odp_version", "payment_origins", "search_capabilities"); + private final DirectoryEnvironment selectedEnvironment; private final HttpClient httpClient; @@ -86,10 +109,10 @@ public List suggest(String prefix, Integer limit, DirectoryModels.Servic private List suggestions( String path, String prefix, Integer limit, DirectoryModels.ServiceFilters filters, boolean mixed) { - if (prefix == null || prefix.isBlank() || prefix.length() > 128) { + if (prefix == null || prefix.isBlank() || prefix.length() > MAXIMUM_SUGGESTION_CHARACTERS) { throw new IllegalArgumentException("prefix must contain from 1 through 128 characters"); } - if (limit != null && (limit < 1 || limit > 25)) { + if (limit != null && (limit < 1 || limit > MAXIMUM_SUGGESTIONS)) { throw new IllegalArgumentException("limit must be from 1 through 25"); } String json; @@ -104,11 +127,7 @@ private List suggestions( + (limit == null ? "" : "&limit=" + limit); json = send(selectedEnvironment.origin().resolve(path + query), METHOD_GET, null); } - try { - return OdpJson.read(json, DirectoryModels.Suggestions.class).items(); - } catch (IllegalArgumentException exception) { - throw new IllegalArgumentException("Directory suggestions response is invalid", exception); - } + return decodeSuggestions(json, limit == null ? MAXIMUM_SUGGESTIONS : limit); } private String send(URI uri, String method, String body) { @@ -116,19 +135,19 @@ private String send(URI uri, String method, String body) { String currentMethod = method; String currentBody = body; boolean hasBody = body != null; - for (int redirects = 0; redirects <= MAXIMUM_REDIRECTS; redirects++) { + for (int redirects = 0; ; redirects++) { HttpRequest.Builder builder = HttpRequest.newBuilder(current) .timeout(Duration.ofSeconds(30)) - .header("Accept", "application/json"); + .header("Accept", JSON); if (!hasBody) { builder.method(currentMethod, HttpRequest.BodyPublishers.noBody()); } else { - builder.header("Content-Type", "application/json") + builder.header("Content-Type", JSON) .method(currentMethod, HttpRequest.BodyPublishers.ofString(currentBody)); } HttpResponse response; try { - response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray()); + response = httpClient.send(builder.build(), boundedBodyHandler()); } catch (IOException exception) { throw new IllegalStateException("Directory request failed", exception); } catch (InterruptedException exception) { @@ -151,40 +170,104 @@ private String send(URI uri, String method, String body) { hasBody = false; } } - throw new IllegalStateException("Directory request produced no response"); } private String consume(HttpResponse response) { + boolean failure = response.statusCode() < 200 || response.statusCode() > 299; + int limit = failure ? MAXIMUM_ERROR_BYTES : MAXIMUM_BYTES; + // Retain checks for injected HttpClient implementations that do not apply the body handler. + response.headers().firstValueAsLong("Content-Length").ifPresent(declared -> { + if (declared > limit) { + throw new IllegalStateException("Directory response exceeds its byte limit"); + } + }); byte[] body = response.body(); - if (body.length > MAXIMUM_BYTES) { + if (body.length > limit) { throw new IllegalStateException("Directory response exceeds its byte limit"); } String text = new String(body, StandardCharsets.UTF_8); - if (response.statusCode() < 200 || response.statusCode() > 299) { + if (failure) { throw new DirectoryRequestException( response.statusCode(), - text.isEmpty() ? "Directory request failed with HTTP " + response.statusCode() : text, + failureMessage(response.headers(), text, response.statusCode()), response.headers()); } - String contentType = response.headers().firstValue("Content-Type").orElse(""); - if (!contentType.toLowerCase(Locale.ROOT).startsWith("application/json")) { + String essence = OdpMediaType.essence( + response.headers().firstValue("Content-Type").orElse("")); + if (!JSON.equals(essence)) { throw new IllegalStateException("Directory response must use application/json"); } return text; } + static HttpResponse.BodyHandler boundedBodyHandler() { + return info -> new BoundedBodySubscriber( + info.statusCode() >= 200 && info.statusCode() <= 299 ? MAXIMUM_BYTES : MAXIMUM_ERROR_BYTES); + } + + /** + * Describes a failed request without repeating whatever the response happened to contain. Only a + * structured field of a JSON error document is quoted, and only after the control characters + * that would let it forge a log line are removed. + */ + private static String failureMessage(HttpHeaders headers, String body, int status) { + String summary = "Directory request failed with HTTP " + status; + String essence = OdpMediaType.essence(headers.firstValue("Content-Type").orElse("")); + if (!JSON.equals(essence) && !"application/problem+json".equals(essence)) { + return summary; + } + OdpJsonNode document; + try { + document = OdpJson.parseTree(body); + } catch (IllegalArgumentException exception) { + return summary; + } + if (document == null || !document.isObject()) { + return summary; + } + String detail = firstDetail(document); + if (detail.isEmpty()) { + return summary; + } + return summary + ": " + + (detail.length() > MAXIMUM_ERROR_CHARACTERS + ? detail.substring(0, MAXIMUM_ERROR_CHARACTERS) + "…" + : detail); + } + + /** The first structured field of an error document that carries readable text, if any does. */ + private static String firstDetail(OdpJsonNode document) { + String found = ""; + for (String field : List.of("detail", "title", "message")) { + OdpJsonNode value = document.get(field); + if (value != null && value.isString() && found.isEmpty()) { + found = printableText(value.asString()); + } + } + return found; + } + + private static String printableText(String value) { + StringBuilder cleaned = new StringBuilder(value.length()); + value.codePoints().forEach(point -> cleaned.appendCodePoint(Character.isISOControl(point) ? ' ' : point)); + return String.join(" ", cleaned.toString().trim().split("\\s+")); + } + private URI resolveContinuation(String next) { - if (next == null || next.isBlank() || next.length() > 2048) { + if (next == null || next.isBlank() || next.length() > MAXIMUM_CONTINUATION_CHARACTERS) { throw new IllegalArgumentException("next must contain from 1 through 2048 characters"); } + for (int index = 0; index < next.length(); index++) { + char character = next.charAt(index); + if (character < 0x20 || character > 0x7e) { + throw new IllegalArgumentException("next must contain printable ASCII only"); + } + } 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) { + if (!DirectoryOrigins.sameOrigin(uri, selectedEnvironment.origin())) { throw new IllegalArgumentException("Directory continuation must remain on the canonical origin"); } return uri; @@ -194,57 +277,141 @@ private static boolean isRedirect(int status) { return status == 301 || status == 302 || status == 303 || status == 307 || status == 308; } - private static String encode(Object value) { + static List decodeSuggestions(String json, int limit) { + OdpJsonNode value; try { - return OdpJson.write(value); + value = OdpJson.parseTree(json); } catch (IllegalArgumentException exception) { - throw new IllegalArgumentException("Directory request is not encodable", exception); + throw new IllegalArgumentException(BAD_SUGGESTIONS, exception); + } + OdpJsonNode items = value == null || !value.isObject() ? null : value.get("items"); + if (items == null || !items.isArray()) { + throw new IllegalArgumentException(BAD_SUGGESTIONS); + } + Set unique = new LinkedHashSet<>(); + for (OdpJsonNode item : items) { + if (!item.isString()) { + throw new IllegalArgumentException(BAD_SUGGESTIONS); + } + String suggestion = item.asString(); + if (suggestion.isBlank() || suggestion.length() > MAXIMUM_SUGGESTION_CHARACTERS) { + throw new IllegalArgumentException(BAD_SUGGESTIONS); + } + unique.add(suggestion); } + // A directory that answers with more than was asked for is answering a different request; + // the caller gets what it asked for. + return unique.stream().limit(limit).toList(); } static DirectoryModels.SearchPage decodeSearchPage(String json) { + OdpJsonNode value; try { - OdpJsonNode value = OdpJson.parseTree(json); - if (value != null && value.isObject()) { - OdpJsonNode items = value.get("items"); - if (items != null && items.isArray()) { - items.forEach(DirectoryClient::normalizeServiceProtocols); - } - } - if (value == null) { - throw new IllegalArgumentException("Directory response is empty"); - } - DirectoryModels.SearchPage page = OdpJson.treeToValue(value, DirectoryModels.SearchPage.class); - validateFacets(page.facets()); - return page; + value = OdpJson.parseTree(json); } catch (IllegalArgumentException exception) { - throw new IllegalArgumentException("Directory response is invalid", exception); + throw new IllegalArgumentException(BAD_PAGE, exception); } + if (value == null || !value.isObject()) { + throw new IllegalArgumentException(BAD_PAGE); + } + // The continuation is what drives the next request, so it is a string or it is not there. + OdpJsonNode next = value.get("next"); + if (next != null && !next.isNull() && !next.isString()) { + throw new IllegalArgumentException(BAD_PAGE); + } + List issues = validateResults(value.get("items")); + // A directory that sends its own issues member is not describing what this client found. + value.remove("issues"); + DirectoryModels.SearchPage decoded; + try { + decoded = OdpJson.treeToValue(value, DirectoryModels.SearchPage.class); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException(BAD_PAGE, exception); + } + validateFacets(decoded.facets()); + return new DirectoryModels.SearchPage( + decoded.items(), decoded.next(), decoded.facets(), issues, decoded.additional()); } + /** + * SVC-43: `tap` is the only trust protocol this ODP version defines, so a facet counting any + * other name describes a vocabulary this client cannot read, and the page is not usable. + */ static void validateFacets(DirectoryModels.Facets facets) { - if (facets != null - && facets.trust().stream() - .anyMatch(facet -> facet.value() == null - || !"tap".equals(facet.value().name()))) { - throw new IllegalArgumentException("Directory trust facets are invalid"); + if (facets == null) { + return; + } + for (DirectoryModels.Facet facet : facets.trust()) { + if (facet.value() == null || !"tap".equals(facet.value().name())) { + throw new IllegalArgumentException("Directory trust facets are invalid"); + } } } - private static void normalizeServiceProtocols(OdpJsonNode value) { - if (!value.isObject() || value.get("protocols") == null) { - return; + /** + * Validates each result in place, dropping the ones that cannot be used. One unusable record does + * not discard the page it arrived on; it is reported alongside the results that did survive. + */ + private static List validateResults(OdpJsonNode items) { + List issues = new ArrayList<>(); + if (items == null || !items.isArray()) { + return issues; + } + List unusable = new ArrayList<>(items.size()); + int index = 0; + for (OdpJsonNode item : items) { + try { + requireResult(item); + OdpJson.treeToValue(item, DirectoryModels.Service.class); + unusable.add(false); + } catch (RuntimeException exception) { + issues.add(new DirectoryModels.Issue(index, String.valueOf(exception.getMessage()))); + unusable.add(true); + } + index++; } - OdpJsonNode service = value; + // removeIf visits the array from its end, so the decision for each result is read back the + // same way it was recorded. + int[] cursor = {unusable.size()}; + items.removeIf(item -> { + cursor[0] -= 1; + return unusable.get(cursor[0]); + }); + return issues; + } + + private static String encode(Object value) { + try { + return OdpJson.write(value); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("Directory request is not encodable", exception); + } + } + + private static void requireResult(OdpJsonNode service) { + if (!service.isObject()) { + throw new IllegalArgumentException("Directory result must be a JSON object"); + } + OdpJsonNode origin = service.get("service_origin"); + if (origin == null || !origin.isString()) { + throw new IllegalArgumentException("Directory result service_origin is missing"); + } + DirectoryOrigins.requireServiceOrigin(origin.asString()); + // The result claims to summarize a Service, so it is held to the shape of the document it + // summarizes, with the members only the Service itself can supply filled in here. OdpJsonNode document = service.deepCopy(); + document.remove(UNVERIFIED); + document.remove("mcp"); document.remove(List.of("service_origin", "indexed_at")); document.put("odp_version", "1.0"); document.putObject("http").put("endpoint_base", "/"); ServiceDocument parsed = OdpJson.parseAgentServiceDocument(document.toString()); + service.remove(UNVERIFIED); + service.set("operations", OdpJson.valueToTree(parsed.operations())); if (parsed.protocols() == null) { service.remove("protocols"); - return; + } else { + service.set("protocols", OdpJson.valueToTree(parsed.protocols())); } - service.set("protocols", OdpJson.valueToTree(parsed.protocols())); } } 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 index 7f55ec5..c5f05f1 100644 --- a/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryModels.java +++ b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryModels.java @@ -207,13 +207,16 @@ public Facets( } } + /** Usable search results and issues for rejected entries, indexed by their position in the response. */ public record SearchPage( List items, String next, Facets facets, + List issues, @JsonAnySetter @JsonAnyGetter Map additional) { public SearchPage { items = items == null ? List.of() : List.copyOf(items); + issues = issues == null ? List.of() : List.copyOf(issues); additional = additional == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(additional)); } } diff --git a/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryOrigins.java b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryOrigins.java new file mode 100644 index 0000000..e993aa1 --- /dev/null +++ b/odp-directory/src/main/java/org/offeringprotocol/odp/directory/DirectoryOrigins.java @@ -0,0 +1,94 @@ +package org.offeringprotocol.odp.directory; + +import java.net.InetAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.UnknownHostException; +import java.util.Locale; +import java.util.regex.Pattern; +import org.offeringprotocol.odp.core.OdpAddresses; + +/** + * Origin rules for the values a directory hands back. A directory result is an ordinary HTTP + * document written by a third party: an Agent connects to whatever {@code service_origin} says, so + * the value is held to the same shape a Service Origin has and to a destination that is routable on + * the public internet. + */ +final class DirectoryOrigins { + private static final Pattern IPV4 = Pattern.compile("^[0-9]{1,3}(?:\\.[0-9]{1,3}){3}$"); + private static final int MAXIMUM_ORIGIN_CHARACTERS = 2048; + + private DirectoryOrigins() {} + + /** Compares two origins the way RFC 3986 6.2.3 does, so a written-out default port still matches. */ + static boolean sameOrigin(URI left, URI right) { + return left.getScheme() != null + && right.getScheme() != null + && left.getScheme().equalsIgnoreCase(right.getScheme()) + && left.getHost() != null + && right.getHost() != null + && left.getHost().equalsIgnoreCase(right.getHost()) + && port(left) == port(right) + && left.getUserInfo() == null + && right.getUserInfo() == null; + } + + private static int port(URI value) { + if (value.getPort() != -1) { + return value.getPort(); + } + return "http".equalsIgnoreCase(value.getScheme()) ? 80 : 443; + } + + /** + * A Service Origin as ODP writes one: the ASCII serialization of a secure origin, and nothing + * else. A value carrying a path, a port, a case the Agent would have to fold, or an address the + * public internet does not route is not an origin an Agent can be pointed at. + */ + static void requireServiceOrigin(String value) { + if (value == null || value.isBlank() || value.length() > MAXIMUM_ORIGIN_CHARACTERS) { + throw new IllegalArgumentException("Directory result service_origin is missing"); + } + URI origin; + try { + origin = new URI(value); + } catch (URISyntaxException exception) { + throw new IllegalArgumentException("Directory result service_origin is not a URL", exception); + } + if (!"https".equals(origin.getScheme()) + || origin.getUserInfo() != null + || origin.getHost() == null + || origin.getPort() != -1 + || !origin.getRawPath().isEmpty() + || origin.getRawQuery() != null + || origin.getRawFragment() != null + || !value.equals("https://" + origin.getHost().toLowerCase(Locale.ROOT))) { + throw new IllegalArgumentException("Directory result service_origin must be a canonical HTTPS origin"); + } + requirePublicHost(origin.getHost()); + } + + private static void requirePublicHost(String host) { + String name = host.toLowerCase(Locale.ROOT); + if ("localhost".equals(name) || name.endsWith(".localhost")) { + throw new IllegalArgumentException("Directory result service_origin must name a public host"); + } + boolean bracketed = name.startsWith("["); + if (!bracketed && !IPV4.matcher(name).matches()) { + // A name is left to the Agent's own destination policy, which resolves it at the moment + // it connects; nothing is resolved here. + return; + } + // The value is a literal by now — URI would not have handed back a host otherwise — so this + // reads the address rather than resolving a name. + InetAddress address; + try { + address = InetAddress.getByName(bracketed ? name.substring(1, name.length() - 1) : name); + } catch (UnknownHostException exception) { + throw new IllegalArgumentException("Directory result service_origin is not a usable address", exception); + } + if (!OdpAddresses.isPublic(address)) { + throw new IllegalArgumentException("Directory result service_origin must name a public host"); + } + } +} diff --git a/odp-directory/src/test/java/org/offeringprotocol/odp/directory/BoundedBodySubscriberTest.java b/odp-directory/src/test/java/org/offeringprotocol/odp/directory/BoundedBodySubscriberTest.java new file mode 100644 index 0000000..bf7596f --- /dev/null +++ b/odp-directory/src/test/java/org/offeringprotocol/odp/directory/BoundedBodySubscriberTest.java @@ -0,0 +1,57 @@ +package org.offeringprotocol.odp.directory; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class BoundedBodySubscriberTest { + @Test + void boundsRealFixedLengthAndChunkedSuccessErrorAndRedirectBodies() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + String[] values = exchange.getRequestURI().getPath().substring(1).split("/"); + int status = Integer.parseInt(values[0]); + int length = Integer.parseInt(values[1]); + exchange.sendResponseHeaders(status, "chunked".equals(values[2]) ? 0 : length); + try (var output = exchange.getResponseBody()) { + output.write(new byte[length]); + } catch (IOException ignored) { + // The client cancels oversized responses. + } + }); + server.start(); + try { + HttpClient client = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + for (int status : new int[] {200, 400, 302}) { + int limit = status == 200 ? 524_288 : 16_384; + for (String mode : new String[] {"fixed", "chunked"}) { + String base = "http://127.0.0.1:" + server.getAddress().getPort() + "/" + status + "/"; + HttpRequest accepted = HttpRequest.newBuilder(URI.create(base + limit + "/" + mode)) + .timeout(Duration.ofSeconds(5)) + .build(); + assertEquals( + limit, + client.send(accepted, DirectoryClient.boundedBodyHandler()) + .body() + .length); + HttpRequest rejected = HttpRequest.newBuilder(URI.create(base + (limit + 1) + "/" + mode)) + .timeout(Duration.ofSeconds(5)) + .build(); + assertThrows(IOException.class, () -> client.send(rejected, DirectoryClient.boundedBodyHandler())); + } + } + } finally { + server.stop(0); + } + } +} diff --git a/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryModelsTest.java b/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryModelsTest.java new file mode 100644 index 0000000..a807b56 --- /dev/null +++ b/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryModelsTest.java @@ -0,0 +1,100 @@ +package org.offeringprotocol.odp.directory; + +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.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.offeringprotocol.odp.core.AuthenticationRequirement; +import org.offeringprotocol.odp.core.OdpOperation; +import org.offeringprotocol.odp.core.PaymentOption; +import org.offeringprotocol.odp.core.ServiceDocument; + +class DirectoryModelsTest { + /** An omitted list reads as an empty one, so a caller never has to check for null. */ + @Test + void readsAnOmittedListAsAnEmptyOne() { + DirectoryModels.ServiceFilters filters = new DirectoryModels.ServiceFilters(null, null, null, null); + assertTrue(filters.enrollment().isEmpty()); + assertTrue(filters.keywords().isEmpty()); + assertTrue(filters.operations().isEmpty()); + assertTrue(filters.payments().isEmpty()); + + assertTrue( + new DirectoryModels.PaymentFilter(null, "mpp", null).options().isEmpty()); + assertTrue(new DirectoryModels.Suggestions(null).items().isEmpty()); + assertTrue(new DirectoryModels.Facets(null, null, null, null, null) + .keywords() + .isEmpty()); + + DirectoryModels.Service service = new DirectoryModels.Service( + null, null, null, null, null, null, null, null, null, null, null, null, null, null); + assertTrue(service.localizations().isEmpty()); + assertTrue(service.keywords().isEmpty()); + assertTrue(service.operations().isEmpty()); + assertTrue(service.additional().isEmpty()); + + DirectoryModels.SearchPage page = new DirectoryModels.SearchPage(null, null, null, null, null); + assertTrue(page.items().isEmpty()); + assertTrue(page.issues().isEmpty()); + assertTrue(page.additional().isEmpty()); + } + + /** A caller cannot reach into a record it handed over, nor into one it was handed back. */ + @Test + void copiesEveryListItIsGiven() { + List keywords = new ArrayList<>(List.of("plants")); + DirectoryModels.ServiceFilters filters = + new DirectoryModels.ServiceFilters(List.of(), keywords, List.of(), List.of()); + keywords.add("seeds"); + assertEquals(List.of("plants"), filters.keywords()); + assertThrows( + UnsupportedOperationException.class, () -> filters.keywords().add("pots")); + + List options = new ArrayList<>(List.of(PaymentOption.BASE)); + DirectoryModels.PaymentFilter payment = + new DirectoryModels.PaymentFilter(AuthenticationRequirement.NOT_REQUIRED, "mpp", options); + options.clear(); + assertEquals(List.of(PaymentOption.BASE), payment.options()); + + List items = new ArrayList<>(List.of("plants")); + DirectoryModels.Suggestions suggestions = new DirectoryModels.Suggestions(items); + items.clear(); + assertEquals(List.of("plants"), suggestions.items()); + } + + @Test + void carriesTheFiltersASearchWasAskedFor() { + DirectoryModels.ServiceFilters filters = new DirectoryModels.ServiceFilters( + List.of(new ServiceDocument.EnrollmentProtocol("api-key")), + List.of("plants"), + List.of(new DirectoryModels.OperationFilter( + AuthenticationRequirement.NOT_REQUIRED, OdpOperation.LIST_OFFERINGS)), + List.of(new DirectoryModels.PaymentFilter( + AuthenticationRequirement.REQUIRED, "mpp", List.of(PaymentOption.BASE)))); + + Stub stub = Stub.json("{\"items\":[]}"); + stub.client().searchServices(new DirectoryModels.SearchRequest("plants", filters, 10)); + + assertEquals(OdpOperation.LIST_OFFERINGS, filters.operations().get(0).name()); + assertEquals("mpp", filters.payments().get(0).name()); + assertEquals("POST", stub.lastRequest().method()); + } + + @Test + void refusesASearchLimitOutsideItsRange() { + assertThrows(IllegalArgumentException.class, () -> new DirectoryModels.SearchRequest("plants", null, 0)); + assertThrows(IllegalArgumentException.class, () -> new DirectoryModels.SearchRequest("plants", null, 101)); + assertEquals(100, new DirectoryModels.SearchRequest("plants", null, 100).limit()); + } + + @Test + void namesTheValueAFacetCounted() { + DirectoryModels.Facet facet = + new DirectoryModels.Facet<>(new DirectoryModels.PaymentOptionFacetValue("mpp", PaymentOption.BASE), 3); + assertEquals("mpp", facet.value().name()); + assertEquals(3, facet.count()); + } +} diff --git a/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryOriginsTest.java b/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryOriginsTest.java new file mode 100644 index 0000000..423de6a --- /dev/null +++ b/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryOriginsTest.java @@ -0,0 +1,124 @@ +package org.offeringprotocol.odp.directory; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class DirectoryOriginsTest { + /** RFC 3986 6.2.3: a written-out default port names the same origin as an omitted one. */ + @Test + void readsAWrittenOutDefaultPortAsTheSameOrigin() { + assertTrue(DirectoryOrigins.sameOrigin( + URI.create("https://sandbox.inflowpay.ai:443/v1/services/search"), + URI.create("https://sandbox.inflowpay.ai"))); + assertTrue(DirectoryOrigins.sameOrigin(URI.create("http://x.example:80/a"), URI.create("http://x.example"))); + assertTrue(DirectoryOrigins.sameOrigin(URI.create("HTTPS://X.Example/a"), URI.create("https://x.example"))); + } + + @Test + void readsEveryOtherDifferenceAsAnotherOrigin() { + URI canonical = URI.create("https://sandbox.inflowpay.ai"); + assertFalse(DirectoryOrigins.sameOrigin(URI.create("https://elsewhere.example"), canonical)); + assertFalse(DirectoryOrigins.sameOrigin(URI.create("http://sandbox.inflowpay.ai"), canonical)); + assertFalse(DirectoryOrigins.sameOrigin(URI.create("https://sandbox.inflowpay.ai:8443"), canonical)); + assertFalse(DirectoryOrigins.sameOrigin(URI.create("https://user@sandbox.inflowpay.ai"), canonical)); + assertFalse(DirectoryOrigins.sameOrigin(URI.create("/v1/services/search"), canonical)); + assertFalse(DirectoryOrigins.sameOrigin(canonical, URI.create("/v1/services/search"))); + assertFalse(DirectoryOrigins.sameOrigin(URI.create("https:/v1/services/search"), canonical)); + assertFalse(DirectoryOrigins.sameOrigin(canonical, URI.create("https:/v1/services/search"))); + assertFalse(DirectoryOrigins.sameOrigin(canonical, URI.create("https://user@sandbox.inflowpay.ai"))); + assertFalse(DirectoryOrigins.sameOrigin(URI.create("mailto:ops@example.com"), canonical)); + assertFalse(DirectoryOrigins.sameOrigin(canonical, URI.create("mailto:ops@example.com"))); + } + + @Test + void acceptsTheAsciiSerializationOfASecureOrigin() { + DirectoryOrigins.requireServiceOrigin("https://plants.example"); + DirectoryOrigins.requireServiceOrigin("https://xn--caf-dma.example"); + DirectoryOrigins.requireServiceOrigin("https://8.8.8.8"); + DirectoryOrigins.requireServiceOrigin("https://[2001:4860:4860::8888]"); + } + + /** A value an Agent would have to repair before using it is not an origin the directory may send. */ + @ParameterizedTest + @ValueSource( + strings = { + "http://plants.example", + "https://Plants.Example", + "https://LOCALHOST", + "https://plants.example:443", + "https://plants.example/", + "https://plants.example/catalog", + "https://user@plants.example", + "https://plants.example?q=1", + "https://plants.example#top", + "https://", + "ftp://plants.example", + "plants.example" + }) + void rejectsAnOriginAnAgentWouldHaveToRepair(String value) { + assertThrows(IllegalArgumentException.class, () -> DirectoryOrigins.requireServiceOrigin(value)); + } + + @Test + void rejectsAnOriginThatIsMissingOrUnparseable() { + assertThrows(IllegalArgumentException.class, () -> DirectoryOrigins.requireServiceOrigin(null)); + assertThrows(IllegalArgumentException.class, () -> DirectoryOrigins.requireServiceOrigin(" ")); + assertThrows( + IllegalArgumentException.class, + () -> DirectoryOrigins.requireServiceOrigin("https://plants.example/" + "a".repeat(2048))); + assertThrows( + IllegalArgumentException.class, () -> DirectoryOrigins.requireServiceOrigin("https://a b.example")); + assertThrows( + IllegalArgumentException.class, () -> DirectoryOrigins.requireServiceOrigin("https://999.888.777.666")); + } + + /** A destination the public internet does not route is not somewhere an Agent may be sent. */ + @ParameterizedTest + @ValueSource( + strings = { + "https://localhost", + "https://service.localhost", + "https://127.0.0.1", + "https://127.9.9.9", + "https://0.0.0.0", + "https://10.1.2.3", + "https://172.16.9.9", + "https://192.168.1.1", + "https://169.254.169.254", + "https://224.0.0.1", + "https://[::1]", + "https://[fe80::1]", + "https://[fc00::1]", + "https://[ff02::1]" + }) + void rejectsADestinationThePublicInternetDoesNotRoute(String value) { + assertEquals( + "Directory result service_origin must name a public host", + assertThrows(IllegalArgumentException.class, () -> DirectoryOrigins.requireServiceOrigin(value)) + .getMessage()); + } + + /** An IPv6 transition range embeds an IPv4 address, so a public-looking one can still be internal. */ + @Test + void rejectsAnAddressThatReachesAnInternalOneThroughATransitionRange() { + for (String value : new String[] { + "https://[64:ff9b::a9fe:a9fe]", + "https://[::ffff:127.0.0.1]", + "https://[2002:a9fe:a9fe::1]", + "https://[2001::1]", + "https://100.64.0.1" + }) { + assertEquals( + "Directory result service_origin must name a public host", + assertThrows(IllegalArgumentException.class, () -> DirectoryOrigins.requireServiceOrigin(value)) + .getMessage()); + } + } +} diff --git a/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryTransportTest.java b/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryTransportTest.java index 6f8ab95..b0a88be 100644 --- a/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryTransportTest.java +++ b/odp-directory/src/test/java/org/offeringprotocol/odp/directory/DirectoryTransportTest.java @@ -183,7 +183,12 @@ public void cancel() {} }); subscriber.onNext(List.of(ByteBuffer.wrap(reply.body().getBytes(StandardCharsets.UTF_8)))); subscriber.onComplete(); - T body = subscriber.getBody().toCompletableFuture().join(); + T body; + try { + body = subscriber.getBody().toCompletableFuture().join(); + } catch (java.util.concurrent.CompletionException exception) { + throw new IOException(exception.getCause()); + } return new HttpResponse<>() { public int statusCode() { return reply.status(); diff --git a/odp-directory/src/test/java/org/offeringprotocol/odp/directory/ResultConformanceTest.java b/odp-directory/src/test/java/org/offeringprotocol/odp/directory/ResultConformanceTest.java new file mode 100644 index 0000000..71cf582 --- /dev/null +++ b/odp-directory/src/test/java/org/offeringprotocol/odp/directory/ResultConformanceTest.java @@ -0,0 +1,229 @@ +package org.offeringprotocol.odp.directory; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.offeringprotocol.odp.core.OdpOperation; +import org.offeringprotocol.odp.core.PaymentOption; + +class ResultConformanceTest { + private static final String OPERATIONS = """ + "operations":[{"authentication":"not-required","name":"get-offering"}, + {"authentication":"not-required","name":"list-offerings"}]"""; + private static final String RECORD = """ + {"service_origin":"https://plants.example","name":"Plants","description":"Plant store", + "language":"en","localizations":["en"],"keywords":["plants"], + "indexed_at":"2026-08-28T00:00:00Z",%s}""".formatted(OPERATIONS); + + private static DirectoryModels.SearchPage page(String items, String extra) { + return DirectoryClient.decodeSearchPage("{\"items\":[" + items + "]" + extra + "}"); + } + + @Test + void readsAWellFormedPage() { + DirectoryModels.SearchPage decoded = page( + RECORD, + ",\"next\":\"/v1/services/search?cursor=c2\",\"facets\":{" + + "\"keywords\":[{\"value\":\"plants\",\"count\":4}]," + + "\"enrollment\":[{\"value\":{\"name\":\"api-key\"},\"count\":1}]," + + "\"operations\":[{\"value\":{\"authentication\":\"not-required\"," + + "\"name\":\"get-offering\"},\"count\":2}]," + + "\"payments\":[{\"value\":{\"authentication\":\"required\",\"name\":\"mpp\"}," + + "\"count\":1}]," + + "\"payment_options\":[{\"value\":{\"name\":\"mpp\",\"option\":\"base\"}," + + "\"count\":1}]},\"total\":9"); + + assertEquals(1, decoded.items().size()); + DirectoryModels.Service service = decoded.items().get(0); + assertEquals("https://plants.example", service.serviceOrigin()); + assertEquals("Plants", service.name()); + assertEquals(Instant.parse("2026-08-28T00:00:00Z"), service.indexedAt()); + assertEquals("/v1/services/search?cursor=c2", decoded.next()); + assertEquals(List.of("plants"), service.keywords()); + assertEquals(4, decoded.facets().keywords().get(0).count()); + assertEquals("api-key", decoded.facets().enrollment().get(0).value().name()); + assertEquals( + OdpOperation.GET_OFFERING, + decoded.facets().operations().get(0).value().name()); + assertEquals("mpp", decoded.facets().payments().get(0).value().name()); + assertEquals( + PaymentOption.BASE, + decoded.facets().paymentOptions().get(0).value().option()); + assertTrue(decoded.issues().isEmpty()); + assertEquals("9", decoded.additional().get("total").toString()); + } + + @Test + void readsAPageThatCarriesNoResults() { + assertTrue(DirectoryClient.decodeSearchPage("{}").items().isEmpty()); + assertTrue(DirectoryClient.decodeSearchPage("{\"items\":null}").items().isEmpty()); + assertTrue(DirectoryClient.decodeSearchPage("{\"items\":[]}").issues().isEmpty()); + } + + /** One unusable result does not discard the page it arrived on; the rest are still handed over. */ + @Test + void dropsAnUnusableResultAndKeepsTheRestOfThePage() { + DirectoryModels.SearchPage decoded = + page(RECORD.replace("https://plants.example", "http://plants.example") + "," + RECORD, ""); + + assertEquals(1, decoded.items().size()); + assertEquals("https://plants.example", decoded.items().get(0).serviceOrigin()); + assertEquals(1, decoded.issues().size()); + assertEquals(0, decoded.issues().get(0).index()); + assertEquals( + "Directory result service_origin must be a canonical HTTPS origin", + decoded.issues().get(0).message()); + } + + /** An issue names where the result sat in the page as sent, not where it sat after the drops. */ + @Test + void reportsEachIssueAtItsPositionInThePageAsSent() { + String broken = RECORD.replace("https://plants.example", "https://127.0.0.1"); + DirectoryModels.SearchPage decoded = page(String.join(",", broken, RECORD, broken, RECORD, broken), ""); + + assertEquals(2, decoded.items().size()); + assertEquals( + List.of(0, 2, 4), + decoded.issues().stream().map(DirectoryModels.Issue::index).toList()); + } + + /** A result claims to summarize a Service, so it is held to the shape of the document it summarizes. */ + @ParameterizedTest + @ValueSource( + strings = { + "\"name\":\"Plants\",", + "\"description\":\"Plant store\",", + "\"language\":\"en\",", + "\"localizations\":[\"en\"]," + }) + void dropsAResultThatIsNotShapedLikeTheServiceItSummarizes(String member) { + assertEquals(0, page(RECORD.replace(member, ""), "").items().size()); + } + + @Test + void dropsAResultThatCannotNameItsService() { + assertEquals(List.of("Directory result must be a JSON object"), messages(page("\"plants\"", ""))); + assertEquals( + List.of("Directory result service_origin is missing"), + messages(page(RECORD.replace("\"https://plants.example\"", "7"), ""))); + assertEquals( + List.of("Directory result service_origin is missing"), + messages(page(RECORD.replace("\"service_origin\":\"https://plants.example\",", ""), ""))); + } + + /** A directory summarizes a Service; it does not serve the Service's own document. */ + @Test + void dropsTheMembersADirectoryCannotVouchFor() { + DirectoryModels.SearchPage decoded = page(RECORD.replace("\"name\":\"Plants\",", """ + "name":"Plants","odp_version":"1.0","branding":{"logo_url":"https://plants.example/logo.png"}, + "http":{"endpoint_base":"/odp"}, + "payment_origins":["https://pay.example"],"search_capabilities":{"offerings":{"query":true}},"""), ""); + + assertEquals(1, decoded.items().size()); + assertTrue( + decoded.items().get(0).additional().isEmpty(), + () -> decoded.items().get(0).additional().toString()); + } + + @Test + void preservesMcpAndIsolatesRecordDecodingFailures() { + String mcp = "[{\"transport\":\"streamable-http\",\"url\":\"https://plants.example/mcp\"}]"; + String withMcp = RECORD.replace("\"name\":\"Plants\",", "\"name\":\"Plants\",\"mcp\":" + mcp + ","); + String future = RECORD.replace( + "\"operations\":[", + "\"operations\":[{\"authentication\":\"not-required\",\"name\":\"future-operation\"},"); + DirectoryModels.SearchPage result = + page(RECORD.replace("2026-08-28T00:00:00Z", "yesterday") + "," + withMcp + "," + future, ""); + assertEquals(2, result.items().size()); + assertEquals(1, result.issues().size()); + assertEquals(0, result.issues().get(0).index()); + assertEquals(mcp, result.items().get(0).additional().get("mcp").toString()); + assertEquals(2, result.items().get(1).operations().size()); + } + + /** The issues a caller reads are the ones this client found, not ones the directory wrote itself. */ + @Test + void ignoresAnIssuesMemberTheDirectorySentItself() { + DirectoryModels.SearchPage decoded = page(RECORD, ",\"issues\":[{\"index\":0,\"message\":\"invented\"}]"); + + assertTrue(decoded.issues().isEmpty()); + assertNull(decoded.additional().get("issues")); + } + + @Test + void keepsAnUnknownMemberOfAResultTheDirectoryCanVouchFor() { + DirectoryModels.SearchPage decoded = + page(RECORD.replace("\"name\":\"Plants\",", "\"name\":\"Plants\",\"rank\":3,"), ""); + assertEquals("3", decoded.items().get(0).additional().get("rank").toString()); + } + + @Test + void refusesAPageItCannotRead() { + for (String body : List.of("not json", "[1,2,3]", "null", "\"page\"")) { + assertEquals( + "Directory response is invalid", + assertThrows(IllegalArgumentException.class, () -> DirectoryClient.decodeSearchPage(body)) + .getMessage()); + } + assertThrows(IllegalArgumentException.class, () -> DirectoryClient.decodeSearchPage("{\"next\":7}")); + assertThrows(IllegalArgumentException.class, () -> DirectoryClient.decodeSearchPage("{\"facets\":7}")); + assertThrows(IllegalArgumentException.class, () -> DirectoryClient.decodeSearchPage("")); + assertNull(DirectoryClient.decodeSearchPage("{\"next\":null}").next()); + } + + @Test + void readsSuggestionsInTheOrderTheyArrived() { + assertEquals( + List.of("plants", "planters"), + DirectoryClient.decodeSuggestions("{\"items\":[\"plants\",\"planters\"]}", 25)); + } + + /** A directory answering with more than was asked for is answering a different request. */ + @Test + void handsBackNoMoreSuggestionsThanWereAskedFor() { + assertEquals(List.of("a", "b"), DirectoryClient.decodeSuggestions("{\"items\":[\"a\",\"b\",\"c\",\"d\"]}", 2)); + } + + @Test + void keepsOneCopyOfARepeatedSuggestion() { + assertEquals(List.of("a", "b"), DirectoryClient.decodeSuggestions("{\"items\":[\"a\",\"b\",\"a\"]}", 25)); + } + + @ParameterizedTest + @ValueSource( + strings = { + "not json", + "[\"a\"]", + "null", + "{}", + "{\"items\":null}", + "{\"items\":\"a\"}", + "{\"items\":[7]}", + "{\"items\":[\" \"]}", + "{\"items\":[null]}", + "" + }) + void refusesSuggestionsItCannotRead(String body) { + assertEquals( + "Directory suggestions response is invalid", + assertThrows(IllegalArgumentException.class, () -> DirectoryClient.decodeSuggestions(body, 25)) + .getMessage()); + } + + @Test + void refusesASuggestionLongerThanAPrefixCouldEverBe() { + String body = "{\"items\":[\"" + "a".repeat(129) + "\"]}"; + assertThrows(IllegalArgumentException.class, () -> DirectoryClient.decodeSuggestions(body, 25)); + } + + private static List messages(DirectoryModels.SearchPage page) { + return page.issues().stream().map(DirectoryModels.Issue::message).toList(); + } +} diff --git a/odp-directory/src/test/java/org/offeringprotocol/odp/directory/Stub.java b/odp-directory/src/test/java/org/offeringprotocol/odp/directory/Stub.java new file mode 100644 index 0000000..35ee89f --- /dev/null +++ b/odp-directory/src/test/java/org/offeringprotocol/odp/directory/Stub.java @@ -0,0 +1,209 @@ +package org.offeringprotocol.odp.directory; + +import java.io.IOException; +import java.net.Authenticator; +import java.net.CookieHandler; +import java.net.ProxySelector; +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.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSession; + +/** A directory that answers exactly what a test tells it to, and records what it was asked. */ +final class Stub extends HttpClient { + private final Exchange exchange; + private final List requests = new ArrayList<>(); + + private Stub(Exchange exchange) { + this.exchange = exchange; + } + + /** Answers every request the same way. */ + static Stub always(int status, String body, String contentType) { + return new Stub(request -> reply(request, status, body, headers(contentType, -1))); + } + + /** Answers a JSON document, the way a healthy directory does. */ + static Stub json(String body) { + return always(200, body, "application/json"); + } + + /** Answers with a declared length that need not match what arrives. */ + static Stub declaring(String body, String contentType, long declared) { + return new Stub(request -> reply(request, 200, body, headers(contentType, declared))); + } + + /** Answers each request from the script in turn, and repeats the last answer after it runs out. */ + static Stub script(Exchange... steps) { + int[] cursor = {0}; + return new Stub(request -> { + Exchange step = steps[Math.min(cursor[0], steps.length - 1)]; + cursor[0] += 1; + return step.reply(request); + }); + } + + static Exchange redirect(int status, String location) { + return request -> reply(request, status, "", Map.of("Location", List.of(location))); + } + + static Exchange reply(int status, String body, String contentType) { + return request -> reply(request, status, body, headers(contentType, -1)); + } + + static Exchange raw(int status, String body, Map> headers) { + return request -> reply(request, status, body, headers); + } + + List requests() { + return List.copyOf(requests); + } + + HttpRequest lastRequest() { + return requests.get(requests.size() - 1); + } + + DirectoryClient into(DirectoryEnvironment environment) { + return DirectoryClient.create(environment, this); + } + + DirectoryClient client() { + return into(DirectoryEnvironment.SANDBOX); + } + + private static Map> headers(String contentType, long declared) { + if (contentType == null) { + return Map.of(); + } + return declared < 0 + ? Map.of("Content-Type", List.of(contentType)) + : Map.of("Content-Type", List.of(contentType), "Content-Length", List.of(Long.toString(declared))); + } + + private static HttpResponse reply( + HttpRequest request, int status, String body, Map> headers) { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + return new HttpResponse() { + @Override + public int statusCode() { + return status; + } + + @Override + public HttpRequest request() { + return request; + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return HttpHeaders.of(headers, (name, value) -> true); + } + + @Override + public byte[] body() { + return bytes.clone(); + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + + @Override + public URI uri() { + return request.uri(); + } + + @Override + public Version version() { + return Version.HTTP_1_1; + } + }; + } + + @FunctionalInterface + interface Exchange { + HttpResponse reply(HttpRequest request) throws IOException, InterruptedException; + } + + @SuppressWarnings("unchecked") + @Override + public HttpResponse send(HttpRequest request, HttpResponse.BodyHandler handler) + throws IOException, InterruptedException { + requests.add(request); + return (HttpResponse) exchange.reply(request); + } + + @Override + public CompletableFuture> sendAsync(HttpRequest request, HttpResponse.BodyHandler handler) { + throw new UnsupportedOperationException("the directory client sends synchronously"); + } + + @Override + public CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler handler, HttpResponse.PushPromiseHandler push) { + return sendAsync(request, handler); + } + + @Override + public Optional cookieHandler() { + return Optional.empty(); + } + + @Override + public Optional connectTimeout() { + return Optional.empty(); + } + + @Override + public Redirect followRedirects() { + return Redirect.NEVER; + } + + @Override + public Optional proxy() { + return Optional.empty(); + } + + @Override + public SSLContext sslContext() { + return null; + } + + @Override + public SSLParameters sslParameters() { + return new SSLParameters(); + } + + @Override + public Optional authenticator() { + return Optional.empty(); + } + + @Override + public Version version() { + return Version.HTTP_1_1; + } + + @Override + public Optional executor() { + return Optional.empty(); + } +} diff --git a/odp-directory/src/test/java/org/offeringprotocol/odp/directory/TransportConformanceTest.java b/odp-directory/src/test/java/org/offeringprotocol/odp/directory/TransportConformanceTest.java new file mode 100644 index 0000000..fad3989 --- /dev/null +++ b/odp-directory/src/test/java/org/offeringprotocol/odp/directory/TransportConformanceTest.java @@ -0,0 +1,293 @@ +package org.offeringprotocol.odp.directory; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class TransportConformanceTest { + private static final String EMPTY_PAGE = "{\"items\":[]}"; + private static final String JSON = "application/json"; + + @Test + void searchesWithAJsonRequestOnTheCanonicalOrigin() { + Stub stub = Stub.json(EMPTY_PAGE); + stub.client().searchServices(new DirectoryModels.SearchRequest("plants", null, 10)); + + assertEquals( + "https://sandbox.inflowpay.ai/v1/services/search", + stub.lastRequest().uri().toString()); + assertEquals("POST", stub.lastRequest().method()); + assertEquals(JSON, stub.lastRequest().headers().firstValue("Accept").orElseThrow()); + assertEquals( + JSON, stub.lastRequest().headers().firstValue("Content-Type").orElseThrow()); + } + + @Test + void asksForSuggestionsWithAnEscapedPrefix() { + Stub stub = Stub.json(EMPTY_PAGE); + stub.client().suggestServices("pl ants&", 5); + + assertEquals( + "https://sandbox.inflowpay.ai/v1/services/suggestions?prefix=pl+ants%26&limit=5", + stub.lastRequest().uri().toString()); + assertEquals("GET", stub.lastRequest().method()); + } + + @Test + void leavesTheLimitOutWhenTheCallerDidNotSetOne() { + Stub stub = Stub.json(EMPTY_PAGE); + stub.client().suggestServices("pl", null); + + assertEquals( + "https://sandbox.inflowpay.ai/v1/services/suggestions?prefix=pl", + stub.lastRequest().uri().toString()); + } + + @Test + void refusesASuggestionRequestItCannotSend() { + DirectoryClient client = Stub.json(EMPTY_PAGE).client(); + assertThrows(IllegalArgumentException.class, () -> client.suggestServices(null, null)); + assertThrows(IllegalArgumentException.class, () -> client.suggestServices(" ", null)); + assertThrows(IllegalArgumentException.class, () -> client.suggestServices("a".repeat(129), null)); + assertThrows(IllegalArgumentException.class, () -> client.suggestServices("pl", 0)); + assertThrows(IllegalArgumentException.class, () -> client.suggestServices("pl", 26)); + assertThrows(NullPointerException.class, () -> client.searchServices(null)); + } + + /** RFC 9110 15.4: a 303, and a 301 or 302 answering a POST, continue as a GET with no body. */ + @ParameterizedTest + @ValueSource(ints = {301, 302, 303}) + void continuesARedirectedPostAsAGet(int status) { + Stub stub = Stub.script(Stub.redirect(status, "/v1/services/search?page=2"), Stub.reply(200, EMPTY_PAGE, JSON)); + stub.client().searchServices(new DirectoryModels.SearchRequest("plants", null, null)); + + assertEquals(2, stub.requests().size()); + assertEquals("GET", stub.lastRequest().method()); + assertEquals( + "https://sandbox.inflowpay.ai/v1/services/search?page=2", + stub.lastRequest().uri().toString()); + assertTrue(stub.lastRequest().headers().firstValue("Content-Type").isEmpty()); + } + + /** A 307 or 308 keeps the method and the body it was answering. */ + @ParameterizedTest + @ValueSource(ints = {307, 308}) + void keepsTheMethodAcrossAPreservingRedirect(int status) { + Stub stub = Stub.script(Stub.redirect(status, "/v1/services/search/v2"), Stub.reply(200, EMPTY_PAGE, JSON)); + stub.client().searchServices(new DirectoryModels.SearchRequest("plants", null, null)); + + assertEquals("POST", stub.lastRequest().method()); + assertEquals( + JSON, stub.lastRequest().headers().firstValue("Content-Type").orElseThrow()); + } + + @Test + void stopsAtItsRedirectLimit() { + Stub stub = Stub.script(Stub.redirect(302, "/v1/services/suggestions?prefix=pl")); + DirectoryClient client = stub.client(); + + assertEquals( + "Directory response exceeded its redirect limit", + assertThrows(IllegalStateException.class, () -> client.suggestServices("pl", null)) + .getMessage()); + assertEquals(6, stub.requests().size()); + } + + @Test + void refusesARedirectThatNamesNowhere() { + DirectoryClient client = Stub.script(Stub.raw(302, "", Map.of())).client(); + assertEquals( + "Directory redirect omitted Location", + assertThrows(IllegalStateException.class, () -> client.suggestServices("pl", null)) + .getMessage()); + } + + /** A directory may move a request within its own origin and nowhere else. */ + @Test + void refusesARedirectOffTheCanonicalOrigin() { + DirectoryClient client = + Stub.script(Stub.redirect(302, "https://elsewhere.example/v1")).client(); + assertEquals( + "Directory continuation must remain on the canonical origin", + assertThrows(IllegalArgumentException.class, () -> client.suggestServices("pl", null)) + .getMessage()); + } + + @Test + void readsAContinuationOnTheCanonicalOrigin() { + Stub stub = Stub.json(EMPTY_PAGE); + stub.client().continueSearchServices("/v1/services/search?cursor=c2"); + + assertEquals( + "https://sandbox.inflowpay.ai/v1/services/search?cursor=c2", + stub.lastRequest().uri().toString()); + assertEquals("GET", stub.lastRequest().method()); + } + + /** A written-out default port is the same origin, so a continuation naming one still belongs. */ + @Test + void readsAContinuationThatWritesOutTheDefaultPort() { + Stub stub = Stub.json(EMPTY_PAGE); + DirectoryModels.SearchPage page = + stub.client().continueSearchServices("https://sandbox.inflowpay.ai:443/v1/services/search?cursor=c"); + assertTrue(page.items().isEmpty()); + } + + @Test + void refusesAContinuationItCannotFollow() { + DirectoryClient client = Stub.json(EMPTY_PAGE).client(); + assertThrows(IllegalArgumentException.class, () -> client.continueSearchServices(null)); + assertThrows(IllegalArgumentException.class, () -> client.continueSearchServices(" ")); + assertThrows(IllegalArgumentException.class, () -> client.continueSearchServices("/v1?c=" + "a".repeat(2048))); + assertThrows(IllegalArgumentException.class, () -> client.continueSearchServices("/v1?c=a\nb")); + assertThrows(IllegalArgumentException.class, () -> client.continueSearchServices("/v1?c=café")); + assertThrows( + IllegalArgumentException.class, () -> client.continueSearchServices("https://elsewhere.example/v1")); + } + + /** ERR-20: a body past the limit is refused rather than read into memory and parsed. */ + @Test + void refusesABodyPastItsLimit() { + DirectoryClient large = Stub.json("{\"items\":[], \"padding\":\"" + "a".repeat(524_288) + "\"}") + .client(); + assertEquals( + "Directory response exceeds its byte limit", + assertThrows(IllegalStateException.class, () -> large.suggestServices("pl", null)) + .getMessage()); + + DirectoryClient failing = + Stub.always(500, "e".repeat(16_385), "application/problem+json").client(); + assertEquals( + "Directory response exceeds its byte limit", + assertThrows(IllegalStateException.class, () -> failing.suggestServices("pl", null)) + .getMessage()); + } + + /** A declared length past the limit is refused before the body is decoded at all. */ + @Test + void refusesADeclaredLengthPastItsLimit() { + DirectoryClient client = Stub.declaring(EMPTY_PAGE, JSON, 10_000_000).client(); + assertEquals( + "Directory response exceeds its byte limit", + assertThrows(IllegalStateException.class, () -> client.suggestServices("pl", null)) + .getMessage()); + } + + @Test + void acceptsADeclaredLengthWithinTheLimit() { + DirectoryClient client = + Stub.declaring(EMPTY_PAGE, JSON, EMPTY_PAGE.length()).client(); + assertTrue(client.suggestServices("pl", null).isEmpty()); + } + + /** MED-08/09: the media type is compared by essence, case-insensitively, and must match exactly. */ + @Test + void comparesTheMediaTypeByItsEssence() { + assertTrue(Stub.always(200, EMPTY_PAGE, "APPLICATION/JSON; charset=utf-8") + .client() + .suggestServices("pl", null) + .isEmpty()); + + for (String type : List.of("application/jsonx", "text/html", "", "application/problem+json")) { + DirectoryClient client = Stub.always(200, EMPTY_PAGE, type).client(); + assertEquals( + "Directory response must use application/json", + assertThrows(IllegalStateException.class, () -> client.suggestServices("pl", null)) + .getMessage()); + } + } + + /** A failure describes itself; it does not echo back whatever the response happened to carry. */ + @Test + void describesAFailureWithoutEchoingItsBody() { + DirectoryRequestException thrown = assertThrows( + DirectoryRequestException.class, + () -> Stub.always(503, "upstream failed\nGET /admin HTTP/1.1", "text/html") + .client() + .suggestServices("pl", null)); + + assertEquals("Directory request failed with HTTP 503", thrown.getMessage()); + assertEquals( + "Directory request failed with HTTP 199", + assertThrows( + DirectoryRequestException.class, + () -> Stub.always(199, "", JSON).client().suggestServices("pl", null)) + .getMessage()); + assertEquals(503, thrown.status()); + assertEquals("text/html", thrown.headers().firstValue("Content-Type").orElseThrow()); + } + + @Test + void quotesOnlyAStructuredFieldOfAJsonError() { + assertEquals( + "Directory request failed with HTTP 400: query is required", + failure("{\"detail\":\"query is required\",\"title\":\"Bad Request\"}", "application/problem+json")); + assertEquals( + "Directory request failed with HTTP 400: Bad Request", + failure("{\"title\":\"Bad Request\"}", "application/problem+json")); + assertEquals( + "Directory request failed with HTTP 400: rate limited", + failure("{\"message\":\"rate limited\"}", JSON)); + } + + /** A field that could forge a log line is flattened before it is quoted. */ + @Test + void flattensTheTextItQuotes() { + assertEquals( + "Directory request failed with HTTP 400: forged INFO ok", + failure("{\"detail\":\"forged\\n\\tINFO ok\"}", JSON)); + assertEquals( + "Directory request failed with HTTP 400: " + "a".repeat(2048) + "…", + failure("{\"detail\":\"" + "a".repeat(4000) + "\"}", JSON)); + } + + @Test + void quotesNothingFromAnErrorItCannotRead() { + String summary = "Directory request failed with HTTP 400"; + assertEquals(summary, failure("not json at all", JSON)); + assertEquals(summary, failure("[1,2,3]", JSON)); + assertEquals(summary, failure("null", JSON)); + assertEquals(summary, failure("{\"detail\":7}", JSON)); + assertEquals(summary, failure("{\"detail\":\" \"}", JSON)); + assertEquals(summary, failure("{}", JSON)); + assertEquals(summary, failure("{\"detail\":\"readable\"}", "text/plain")); + } + + @Test + void reportsATransportFailureAsSuchAndKeepsAnInterruptVisible() { + DirectoryClient broken = Stub.script(request -> { + throw new IOException("no route to host"); + }) + .client(); + assertEquals( + "Directory request failed", + assertThrows(IllegalStateException.class, () -> broken.suggestServices("pl", null)) + .getMessage()); + + DirectoryClient interrupted = Stub.script(request -> { + throw new InterruptedException("stopped"); + }) + .client(); + assertEquals( + "Directory request was interrupted", + assertThrows(IllegalStateException.class, () -> interrupted.suggestServices("pl", null)) + .getMessage()); + assertTrue(Thread.interrupted(), "the interrupt is handed back to the caller's thread"); + assertFalse(Thread.currentThread().isInterrupted()); + } + + private static String failure(String body, String contentType) { + return assertThrows( + DirectoryRequestException.class, + () -> Stub.always(400, body, contentType).client().suggestServices("pl", null)) + .getMessage(); + } +} diff --git a/odp-json-jackson2/src/test/java/org/offeringprotocol/odp/json/jackson2/Jackson2JsonProviderTest.java b/odp-json-jackson2/src/test/java/org/offeringprotocol/odp/json/jackson2/Jackson2JsonProviderTest.java index 46e0d4e..53f0d18 100644 --- a/odp-json-jackson2/src/test/java/org/offeringprotocol/odp/json/jackson2/Jackson2JsonProviderTest.java +++ b/odp-json-jackson2/src/test/java/org/offeringprotocol/odp/json/jackson2/Jackson2JsonProviderTest.java @@ -9,6 +9,71 @@ import org.offeringprotocol.odp.core.OdpValidationException; class Jackson2JsonProviderTest { + @Test + void acceptsCompatibleVersionsWithoutChangingTheReceivedVersion() { + String offering = "{\"odp_version\":\"1.0\",\"id\":\"desk\",\"name\":\"Desk\"}"; + for (String version : java.util.List.of("1.0", "1.1", "1.7")) { + assertEquals( + version, + OdpJson.parseOffering(offering.replace("1.0", version)).odpVersion()); + assertEquals( + version, + OdpJson.parseOfferingSearchRequest("{\"odp_version\":\"" + version + "\",\"query\":\"desk\"}") + .odpVersion()); + assertEquals( + version, + OdpJson.parsePage( + "{\"odp_version\":\"" + version + + "\",\"items\":[{\"id\":\"desk\",\"name\":\"Desk\"}]}", + org.offeringprotocol.odp.core.Offering.class) + .odpVersion()); + } + for (String version : java.util.List.of("2.0", "3.0", "1", "01.0", "1.01", "1.0.0", "1.7\\n")) { + assertThrows( + OdpValidationException.class, + () -> OdpJson.parseOffering(offering.replace("1.0", version)), + version); + } + } + + @Test + void validatesTypedSearchValuesWithoutProviderCoercion() { + var definition = OdpJson.parseFilterDefinition(""" + {"id":"price","title":"Price","description":"Price","type":"decimal","operators":["eq","in"]} + """); + var catalog = + new org.offeringprotocol.odp.core.SearchCatalog(java.util.List.of(definition), java.util.List.of()); + String valid = + "{\"odp_version\":\"1.0\",\"filters\":[{\"id\":\"price\",\"operator\":\"eq\",\"value\":\"1.00\"}]}"; + catalog.validateRequest(OdpJson.parseOfferingSearchRequest(valid)); + assertThrows( + IllegalArgumentException.class, + () -> catalog.validateRequest(OdpJson.parseOfferingSearchRequest(valid.replace("\"1.00\"", "1.00")))); + assertThrows( + IllegalArgumentException.class, + () -> catalog.validateRequest(OdpJson.parseOfferingSearchRequest( + valid.replace("\"eq\"", "\"in\"").replace("\"1.00\"", "[\"1.0\",\"1.00\"]")))); + } + + @Test + void rejectsNumericPricesBeforePageDeserialization() { + String page = """ + {"odp_version":"1.0","items":[{"id":"desk","name":"Desk", + "price":{"type":"fixed","amount":1450,"currency":"USD"}}]} + """; + assertThrows(OdpValidationException.class, () -> OdpJson.parseOfferingSearchResponse(page)); + assertThrows( + OdpValidationException.class, + () -> OdpJson.parsePage(page, org.offeringprotocol.odp.core.Offering.class)); + assertEquals( + "1450", + OdpJson.parsePage(page.replace(":1450", ":\"1450\""), org.offeringprotocol.odp.core.Offering.class) + .items() + .get(0) + .price() + .amount()); + } + @Test void decodesDirectoryTimestamps() { assertEquals( diff --git a/odp-json-jackson3/src/test/java/org/offeringprotocol/odp/core/OdpJsonTest.java b/odp-json-jackson3/src/test/java/org/offeringprotocol/odp/core/OdpJsonTest.java index 49fbfad..8575cca 100644 --- a/odp-json-jackson3/src/test/java/org/offeringprotocol/odp/core/OdpJsonTest.java +++ b/odp-json-jackson3/src/test/java/org/offeringprotocol/odp/core/OdpJsonTest.java @@ -10,6 +10,42 @@ import org.junit.jupiter.api.Test; class OdpJsonTest { + @Test + void rejectsUnsupportedAndMalformedProtocolVersions() { + String offering = "{\"odp_version\":\"1.0\",\"id\":\"desk\",\"name\":\"Desk\"}"; + assertEquals("1.0", OdpJson.parseOffering(offering).odpVersion()); + for (String version : List.of("1.1", "1.7", "1.999999999999999999999999999999")) { + assertEquals( + version, + OdpJson.parseOffering(offering.replace("1.0", version)).odpVersion()); + } + for (String version : List.of("2.0", "3.0", "1", "01.0", "1.01", "1.0.0", "1.7\\n")) { + assertThrows( + OdpValidationException.class, + () -> OdpJson.parseOffering(offering.replace("1.0", version)), + version); + } + } + + @Test + void rejectsNumericPricesBeforePageDeserialization() { + String page = """ + {"odp_version":"1.0","items":[{"id":"desk","name":"Desk", + "price":{"type":"fixed","amount":1450,"currency":"USD"}}]} + """; + assertThrows(OdpValidationException.class, () -> OdpJson.parseOfferingSearchResponse(page)); + assertThrows( + OdpValidationException.class, + () -> OdpJson.parsePage(page, org.offeringprotocol.odp.core.Offering.class)); + assertEquals( + "1450", + OdpJson.parsePage(page.replace(":1450", ":\"1450\""), org.offeringprotocol.odp.core.Offering.class) + .items() + .get(0) + .price() + .amount()); + } + private static final String DOCUMENT = """ { "odp_version":"1.0", @@ -35,6 +71,36 @@ void parsesAndPreservesServiceDocumentExtensions() { assertTrue(OdpJson.write(document).contains("example_extension")); } + @Test + void acceptsCompatibleVersionsAcrossDocumentTypes() { + for (String version : List.of("1.1", "1.7")) { + assertEquals( + version, + OdpJson.parseServiceDocument(DOCUMENT.replace("1.0", version)) + .odpVersion()); + assertEquals( + version, + OdpJson.parseCollection("{\"odp_version\":\"" + version + "\",\"id\":\"desks\",\"name\":\"Desks\"}") + .odpVersion()); + assertEquals( + version, + OdpJson.parseOfferingSearchRequest("{\"odp_version\":\"" + version + "\",\"query\":\"desk\"}") + .odpVersion()); + assertEquals( + version, + OdpJson.parsePage( + "{\"odp_version\":\"" + version + + "\",\"items\":[{\"id\":\"desk\",\"name\":\"Desk\"}]}", + Offering.class) + .odpVersion()); + } + assertEquals( + "1.0", + ServiceDocument.builder("Example", "Example Service", "en", new ServiceDocument.Http("/odp", null)) + .build() + .odpVersion()); + } + @Test void buildsAndRoundTripsServiceDocuments() { List operations = List.of( @@ -91,6 +157,22 @@ void filtersUnknownProtocolsForAgentsWithoutWeakeningServiceValidation() { document.protocols().trust()); } + @Test + void rejectsInvalidFilterSemanticsWithoutDiscardingTheAgentServiceDocument() { + for (String definition : List.of( + "{\"id\":\"material\",\"title\":\"Material\",\"description\":\"Material\",\"type\":\"string\",\"operators\":[\"gte\"]}", + "{\"id\":\"available\",\"title\":\"Available\",\"description\":\"Available\",\"type\":\"boolean\",\"operators\":[\"eq\"],\"unit\":{\"system\":\"ucum\",\"code\":\"1\"}}")) { + OdpJsonNode node = OdpJson.parseTree(DOCUMENT); + node.set( + "operations", + OdpJson.parseTree( + "[{\"name\":\"get-offering\",\"authentication\":\"not-required\"},{\"name\":\"list-offerings\",\"authentication\":\"not-required\"},{\"name\":\"search-offerings\",\"authentication\":\"not-required\"}]")); + node.set("search_capabilities", OdpJson.parseTree("{\"filters\":{\"inline\":[" + definition + "]}}")); + assertThrows(OdpValidationException.class, () -> OdpJson.parseServiceDocument(node.toString())); + assertNull(OdpJson.parseAgentServiceDocument(node.toString()).searchCapabilities()); + } + } + @Test void omitsAgentProtocolCategoriesContainingOnlyUnknownNames() { String protocols = """ diff --git a/odp-json-jackson3/src/test/java/org/offeringprotocol/odp/core/OdpMediaTypeTest.java b/odp-json-jackson3/src/test/java/org/offeringprotocol/odp/core/OdpMediaTypeTest.java new file mode 100644 index 0000000..0129dee --- /dev/null +++ b/odp-json-jackson3/src/test/java/org/offeringprotocol/odp/core/OdpMediaTypeTest.java @@ -0,0 +1,42 @@ +package org.offeringprotocol.odp.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class OdpMediaTypeTest { + @Test + void acceptsValidParametersWithoutInterpretingThem() { + for (String value : List.of( + "application/odp+json", + " APPLICATION/ODP+JSON \t", + "application/odp+json; charset=utf-8", + "application/odp+json; p=\"semi;colon\"", + "application/odp+json; p=\"escaped\\\"quote\"", + "application/odp+json; ;")) { + assertEquals("application/odp+json", OdpMediaType.essence(value), value); + } + } + + @Test + void rejectsMalformedTypesAndParameters() { + assertEquals("", OdpMediaType.essence(null)); + for (String value : List.of( + "", + "application", + "/json", + "application/", + "application/odp+json;broken", + "application/odp+json; p=", + "application/odp+json; p=\"unterminated", + "application/odp+json; p =v", + "application/odp+json; p=has space", + "application/odp+json; p=\"line\nbreak\"", + "application/odp+json; p=\"x\"garbage", + "application/odp+json, application/json", + "application/odp+json\r\n")) { + assertEquals("", OdpMediaType.essence(value), value); + } + } +} diff --git a/odp-json-jackson3/src/test/java/org/offeringprotocol/odp/core/OdpPaginationTest.java b/odp-json-jackson3/src/test/java/org/offeringprotocol/odp/core/OdpPaginationTest.java new file mode 100644 index 0000000..e7a0318 --- /dev/null +++ b/odp-json-jackson3/src/test/java/org/offeringprotocol/odp/core/OdpPaginationTest.java @@ -0,0 +1,146 @@ +package org.offeringprotocol.odp.core; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class OdpPaginationTest { + @Test + void loadsOnlyOnDemandAndHonorsTheItemLimit() { + AtomicInteger first = new AtomicInteger(); + List continuations = new ArrayList<>(); + var iterator = OdpPagination.iterate( + () -> { + first.incrementAndGet(); + return page(List.of("a", "b"), "/next?opaque=1"); + }, + next -> { + continuations.add(next); + return page(List.of("c", "d"), "/not-fetched"); + }, + 3); + assertEquals(0, first.get()); + assertTrue(iterator.hasNext()); + assertTrue(iterator.hasNext()); + assertEquals(1, first.get()); + assertEquals("a", iterator.next()); + assertEquals("b", iterator.next()); + assertTrue(continuations.isEmpty()); + assertEquals("c", iterator.next()); + assertEquals(List.of("/next?opaque=1"), continuations); + assertFalse(iterator.hasNext()); + assertThrows(NoSuchElementException.class, iterator::next); + assertThrows(UnsupportedOperationException.class, iterator::remove); + } + + @Test + void skipsEmptyPagesAndStopsAtTheEnd() { + AtomicInteger calls = new AtomicInteger(); + var iterator = OdpPagination.iterate( + () -> page(List.of(), "/1"), + next -> { + calls.incrementAndGet(); + return next.equals("/1") ? page(List.of(), "/2") : page(List.of("a"), null); + }, + Long.MAX_VALUE); + assertEquals("a", iterator.next()); + assertFalse(iterator.hasNext()); + assertFalse(iterator.hasNext()); + assertEquals(2, calls.get()); + } + + @Test + void preservesDeliveredItemsAndNeverResumesAfterFailure() { + AtomicInteger calls = new AtomicInteger(); + IllegalStateException failure = new IllegalStateException("failed page"); + var iterator = OdpPagination.iterate( + () -> page(List.of("a"), "/next"), + next -> { + calls.incrementAndGet(); + throw failure; + }, + 10); + List delivered = new ArrayList<>(); + delivered.add(iterator.next()); + var reported = assertThrows(IllegalStateException.class, iterator::hasNext); + assertSame(failure, reported.getCause()); + assertSame(reported, assertThrows(IllegalStateException.class, iterator::next)); + assertEquals(List.of("a"), delivered); + assertEquals(1, calls.get()); + } + + @Test + void preservesTypedLimitFailuresWithoutWrappingOrResuming() { + AtomicInteger calls = new AtomicInteger(); + var failure = new OdpResponseLimitException("Response exceeds its byte limit"); + var iterator = OdpPagination.iterate( + () -> page(List.of("a"), "/next"), + next -> { + calls.incrementAndGet(); + throw failure; + }, + 10); + assertEquals("a", iterator.next()); + assertSame(failure, assertThrows(OdpResponseLimitException.class, iterator::hasNext)); + assertSame(failure, assertThrows(OdpResponseLimitException.class, iterator::next)); + assertEquals("RESPONSE_LIMIT_EXCEEDED", failure.code()); + assertFalse(failure.retryable()); + assertEquals(1, calls.get()); + } + + @Test + void refusesLoopsAndExcessivePagesWithoutFetchingAgain() { + AtomicInteger loops = new AtomicInteger(); + var looping = OdpPagination.iterate( + () -> page(List.of(), "/same"), + next -> { + loops.incrementAndGet(); + return page(List.of(), "/same"); + }, + 100); + assertThrows(IllegalStateException.class, looping::hasNext); + assertEquals(1, loops.get()); + AtomicInteger pages = new AtomicInteger(1); + var bounded = OdpPagination.iterate( + () -> page(List.of(), "/1"), next -> page(List.of(), "/" + pages.incrementAndGet()), 100); + assertThrows(IllegalStateException.class, bounded::hasNext); + assertEquals(OdpPagination.MAXIMUM_PAGES, pages.get()); + } + + @Test + void searchPagesAdaptWithoutLosingTheOriginalRefinements() { + var groups = List.of(new OfferingPage.RefinementGroup( + "color", List.of(new OfferingPage.RefinementBucket(OdpJson.parseTree("\"black\""), 1, "exact")))); + var search = new OfferingPage(true, Odp.VERSION, List.of(), "/next", groups, Map.of()); + var page = search.asPage(); + assertEquals(search.items(), page.items()); + assertEquals(search.next(), page.next()); + assertEquals(search.odpVersion(), page.odpVersion()); + assertEquals(search.authExpands(), page.authExpands()); + assertEquals(search.additional(), page.additional()); + assertEquals(groups, search.refinements()); + } + + @Test + void zeroLimitDoesNotLoadAndInvalidInputsFailLocally() { + var empty = OdpPagination.iterate(() -> fail("must not fetch"), next -> fail("must not fetch"), 0); + assertFalse(empty.hasNext()); + assertThrows(NoSuchElementException.class, empty::next); + assertThrows( + IllegalArgumentException.class, + () -> OdpPagination.iterate(() -> page(List.of(), null), next -> page(List.of(), null), -1)); + var invalid = OdpPagination.iterate(() -> null, next -> null, 1); + var failure = assertThrows(IllegalStateException.class, invalid::hasNext); + assertInstanceOf(NullPointerException.class, failure.getCause()); + assertSame(failure, assertThrows(IllegalStateException.class, invalid::hasNext)); + } + + private static Page page(List items, String next) { + return new Page<>(null, Odp.VERSION, items, next, Map.of()); + } +} diff --git a/odp-json-jackson3/src/test/java/org/offeringprotocol/odp/core/SearchCatalogTest.java b/odp-json-jackson3/src/test/java/org/offeringprotocol/odp/core/SearchCatalogTest.java new file mode 100644 index 0000000..c5f0241 --- /dev/null +++ b/odp-json-jackson3/src/test/java/org/offeringprotocol/odp/core/SearchCatalogTest.java @@ -0,0 +1,113 @@ +package org.offeringprotocol.odp.core; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class SearchCatalogTest { + private static SearchCatalog catalog(String type) { + return new SearchCatalog(List.of(OdpJson.parseFilterDefinition(""" + {"id":"value","title":"Value","description":"Search value","type":"%s", + "operators":["eq","in","exists"],"refinable":true} + """.formatted(type))), List.of()); + } + + private static SearchRequests.Offerings request(String operator, String value) { + return OdpJson.parseOfferingSearchRequest(""" + {"odp_version":"1.0","filters":[{"id":"value","operator":"%s","value":%s}]} + """.formatted(operator, value)); + } + + @Test + void checksTypedScalarsAndOperatorShapes() { + for (String[] example : List.of( + new String[] {"string", "\"text\"", "1"}, + new String[] {"boolean", "true", "\"true\""}, + new String[] {"integer", "1e3", "1.5"}, + new String[] {"number", "1.5", "\"1.5\""}, + new String[] {"decimal", "\"1.5\"", "\"1e3\""}, + new String[] {"date", "\"2024-02-29\"", "\"2025-02-29\""}, + new String[] {"date-time", "\"2026-01-01T00:00:00.123456789123Z\"", "\"2026-01-01T00:00:00\""})) { + SearchCatalog catalog = catalog(example[0]); + assertDoesNotThrow(() -> catalog.validateRequest(request("eq", example[1]))); + assertThrows(IllegalArgumentException.class, () -> catalog.validateRequest(request("eq", example[2]))); + assertDoesNotThrow(() -> catalog.validateRequest(request("exists", "false"))); + assertThrows(IllegalArgumentException.class, () -> catalog.validateRequest(request("exists", "\"false\""))); + assertThrows(IllegalArgumentException.class, () -> catalog.validateRequest(request("in", example[1]))); + assertThrows( + IllegalArgumentException.class, + () -> catalog.validateRequest(request("eq", "[" + example[1] + "]"))); + } + } + + @Test + void comparesValuesByTheirDeclaredEquality() { + assertThrows( + IllegalArgumentException.class, + () -> catalog("date-time") + .validateRequest(request("in", "[\"2026-01-01T00:00:00Z\",\"2026-01-01T23:00:00+23:00\"]"))); + assertDoesNotThrow(() -> catalog("date-time") + .validateRequest(request("in", "[\"2016-12-31T23:59:60Z\",\"2017-01-01T00:00:00Z\"]"))); + assertThrows( + IllegalArgumentException.class, + () -> catalog("decimal").validateRequest(request("in", "[\"1.0\",\"1.00\"]"))); + assertThrows( + IllegalArgumentException.class, + () -> catalog("date-time") + .validateRequest(request("in", "[\"2026-01-01T00:00:00Z\",\"2025-12-31T19:00:00-05:00\"]"))); + assertDoesNotThrow(() -> catalog("string").validateRequest(request("in", "[\"A\",\"a\"]"))); + } + + @Test + void rejectsUnavailableFiltersSortsAndRefinements() { + SearchCatalog catalog = catalog("string"); + String base = "{\"odp_version\":\"1.0\",\"query\":\"test\","; + assertThrows( + IllegalArgumentException.class, + () -> catalog.validateRequest(OdpJson.parseOfferingSearchRequest(base + "\"sort\":\"missing\"}"))); + assertThrows( + IllegalArgumentException.class, + () -> catalog.validateRequest( + OdpJson.parseOfferingSearchRequest(base + "\"refinements\":[\"missing\"]}"))); + assertThrows( + IllegalArgumentException.class, + () -> new SearchCatalog(List.of(), List.of()).validateRequest(request("eq", "\"value\""))); + assertThrows(IllegalArgumentException.class, () -> catalog.validateRequest(request("lt", "\"value\""))); + } + + @Test + void validatesRefinementsInRequestContext() { + SearchCatalog catalog = catalog("decimal"); + SearchRequests.Offerings request = OdpJson.parseOfferingSearchRequest(""" + {"odp_version":"1.0","query":"test","refinements":["value"]} + """); + String json = """ + {"odp_version":"1.0","items":[],"refinements":[ + {"filter_id":"value","values":[{"value":"1.00","count":3}]}]} + """; + OfferingPage page = OdpJson.parseOfferingSearchResponse(json); + assertEquals( + "value", + catalog.validateRefinements(page, request, false) + .get(0) + .filter() + .id()); + assertThrows(IllegalArgumentException.class, () -> catalog.validateRefinements(page, request, true)); + assertThrows(IllegalArgumentException.class, () -> catalog.validateRefinements(page, null, false)); + assertThrows( + IllegalArgumentException.class, + () -> catalog.validateRefinements( + OdpJson.parseOfferingSearchResponse(json.replace("\"1.00\"", "true")), request, false)); + assertThrows( + IllegalArgumentException.class, + () -> catalog.validateRefinements( + OdpJson.parseOfferingSearchResponse(json.replace( + "{\"value\":\"1.00\",\"count\":3}", + "{\"value\":\"1.00\",\"count\":3},{\"value\":\"1.0\",\"count\":4}")), + request, + false)); + assertThrows( + UnsupportedOperationException.class, () -> catalog.filters().clear()); + } +} diff --git a/odp-service/README.md b/odp-service/README.md index 3a8b993..80c772f 100644 --- a/odp-service/README.md +++ b/odp-service/README.md @@ -67,9 +67,13 @@ The static catalog: - takes immutable snapshots of the supplied lists; - verifies unique Offering and Collection identifiers; +- rejects missing parents, cycles, hierarchies deeper than 32 parent edges, and Offering memberships + referencing Collections absent from the supplied catalog; - returns terse or full representations; - defaults page size to 50 and accepts limits through 100; -- uses opaque, integrity-protected stateless continuations that expire after one hour; and +- uses opaque, HMAC-protected stateless continuations bound to the path, representation, and page + size they were issued for, valid for at least the hour PAG-19 requires and expiring on an hour + boundary so a cursor does not record the moment it was handed out; and - advertises only the operations supplied by its resources. The simple overload generates a new continuation signing key when the catalog is created. For @@ -115,13 +119,44 @@ actually supports. `CatalogRequest` exposes: | `representation` | Normalized `terse` or `full` representation | | `limit` | Optional validated limit from 1 through 100 | | `cursor` | Opaque cursor query value when the Service uses one | -| `language` | First `Accept-Language` header value | +| `language` | Language selected by RFC 4647 Lookup against `localizations` | | `body` | Search body for an initial POST | | `request` | Original normalized ODP request | Return `null` when a requested resource does not exist. Throw `OdpServiceException` with a status, -stable code, and safe message for an intentional ODP Problem Details response. Unexpected handler -exceptions remain visible to the hosting application rather than being mislabeled by the SDK. +stable code, and safe message for an intentional ODP Problem Details response; that message reaches +the caller as the problem's `detail`, so it describes the request rather than the Service's internal +state. Any other exception a handler throws becomes a `500` `INTERNAL_ERROR` whose detail says only +that the request could not be processed: an exception message can name a query, a table, or a host +an Agent is not permitted to learn. Log the original where the Service logs, not in the response. + +### Validate structured search + +`SearchCatalog` validates Filters, Sort selection, and requested refinements against the effective +definitions for a search. It also checks returned refinement values, including typed equality and +unique groups. It does not query your database, sort results, or compute bucket counts. + +Supply the effective definitions through the builder's `searchCatalog` callback: + +```java +builder.searchCatalog(request -> { + SearchRequests.Offerings search = OdpJson.parseOfferingSearchRequest(request.body()); + return new SearchCatalog( + catalog.filtersFor(search.collectionId(), request), + catalog.sortsFor(search.collectionId(), request)); +}); +``` + +Here `catalog` is your application repository. Return Service-wide definitions plus those for the +explicitly selected Collection, not its ancestors or descendants, in the current access context. +Resolve linked definitions from your application data; the Service runtime does not fetch its own +HTTP endpoints. Invalid caller expressions produce `400 INVALID_REQUEST` before the search handler +runs. Invalid handler responses produce a masked `500 INTERNAL_ERROR`. + +Without this callback, the runtime still validates request/response schemas and ensures refinements +were requested and are absent from continuation responses. Your handler then owns definition-dependent +validation and can invoke `SearchCatalog` itself. In both cases, the application must implement +the advertised matching, stable ordering, and contextual count semantics. ## HTTP framework adapter @@ -148,9 +183,58 @@ standard-library HTTP adapter is in [`SmallService.java`](../examples/src/main/java/org/offeringprotocol/odp/examples/SmallService.java). The runtime owns fixed operation routes, representation and limit validation, the 65,536-byte -request-body ceiling, Service Document generation, media types, and ODP Problem Details. The host -application owns connection policy, HTTP caching headers, compression, observability, rate limits, -and deployment lifecycle. +request-body ceiling, Service Document generation, media types, content negotiation, language +selection, validators and conditional retrieval, freshness, and ODP Problem Details. The host +application owns connection policy, compression, observability, rate limits, and deployment +lifecycle. Relay every header the response carries; several of them are protocol requirements. + +### What the runtime answers on its own + +| Condition | Response | +| --- | --- | +| `Accept` excludes `application/odp+json` (MED-04) | `406` `NOT_ACCEPTABLE` | +| A request body whose media type is not `application/odp+json` (MED-06) | `415` `UNSUPPORTED_MEDIA_TYPE` | +| A published path reached by another method | `405` `METHOD_NOT_ALLOWED` with `Allow` | +| `HEAD` of any resource | The fields of the `GET`, with `Content-Length` and no body | +| `If-None-Match` matching the current validator on GET or HEAD (PAG-31) | `304` `Not Modified` | +| `If-None-Match` matching the current validator on POST | `412` `PRECONDITION_FAILED` | +| `If-Match` with no strong match | `412` `PRECONDITION_FAILED` | +| A repeated or unsupported `representation`, `limit`, or `cursor` (SVC-73) | `400` `INVALID_REQUEST` | +| A path segment that is not a Local Resource Identifier (IDN-08) | `404` `NOT_FOUND` | +| A request body past 65,536 bytes (ERR-31) | `413` `REQUEST_TOO_LARGE` | +| A `429` or `503` raised by a handler (ERR-32/33) | `Retry-After`, defaulting to 60 seconds | + +Every successful response carries `Content-Language`, `Vary: Accept-Language`, an `ETag`, and a +`Cache-Control` chosen for the resource class — four hours for the Service Document, one hour for +Collections, five minutes for Offerings, and `no-store` for a search. An operation whose advertised +authentication is not `not-required` is marked `private` rather than `public`. Problem responses are +`no-store`. + +`Accept-Language` is resolved by the RFC 4647 Lookup scheme against the Service Document's +`localizations`, and a request whose ranges match nothing receives the default representation rather +than a refusal (SVC-59). The selected tag is what `CatalogRequest.language()` carries. +This selection is a preference, not a translation: handlers return the actual localized data and +set its `language` field. `Content-Language` describes those returned resources, falling back to the +Service's default language when they omit it. Static catalogs return their stored content unchanged; +the fixed Service Document is always served in its declared language. The Service metadata's +localizations do not guarantee the same choices for every catalog resource. Handlers with different +available languages can inspect `CatalogRequest.request()` to negotiate against their own set. + +### What the runtime checks before it sends + +Individual Offering and Collection retrieval defaults to `full`; list and search operations default +to `terse`. An explicit `representation` query parameter overrides that default. + +A handler's response is validated against the resource contract before it leaves: Terse Offerings +carry no `actions`, Full representations carry no `detail_fields`, a page's items do not restate +`odp_version` (VER-03), and a `next` is a bounded same-origin reference that advances the +traversal rather than repeating the cursor it was given (PAG-06/07/11). A document past 524,288 +bytes or 16 levels of nesting, or a Service Document past 65,536 bytes or 8 levels, is refused +(ERR-19). Any of these becomes a `500` `INTERNAL_ERROR` instead of a non-conformant response. + +Configure `.origin("https://store.example")` on the Service builder when handlers return absolute +continuation links. This is trusted deployment configuration, not a request Host or forwarded +header. Without it, handlers can return origin-relative links only. Service Document protocol advertisements are validated against the declared ODP version and accept only the enrollment, payment, and trust protocol names defined by that version. 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 index e45d23b..64b3176 100644 --- a/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpHttpRequest.java +++ b/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpHttpRequest.java @@ -4,20 +4,35 @@ import java.util.Map; import java.util.stream.Collectors; +/** + * One inbound HTTP request, as whatever framework is in front of this Service describes it. + * + *

{@code path} is the decoded request path: percent-encoded octets are decoded by the framework + * before they reach here, so a path segment naming a resource is the identifier itself. Absent + * query or header maps read as empty ones. + */ 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() + query = copy(query); + headers = copy(headers); + } + + private static Map> copy(Map> values) { + if (values == null) { + return Map.of(); + } + return values.entrySet().stream() .collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, entry -> List.copyOf(entry.getValue()))); } + /** The first value of a query parameter, or null. Repeated parameters are refused upstream. */ public String queryValue(String name) { List values = query.get(name); return values == null || values.isEmpty() ? null : values.get(0); } + /** The first value of a header field, compared without regard to case. */ public String headerValue(String name) { return headers.entrySet().stream() .filter(entry -> entry.getKey().equalsIgnoreCase(name)) 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 index b395c05..cbff103 100644 --- a/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpHttpResponse.java +++ b/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpHttpResponse.java @@ -2,8 +2,10 @@ import java.util.Map; +/** One outbound HTTP response. An absent header map reads as an empty one. */ public record OdpHttpResponse(int status, Map headers, String body) { public OdpHttpResponse { - headers = Map.copyOf(headers); + headers = headers == null ? Map.of() : Map.copyOf(headers); + body = body == null ? "" : body; } } diff --git a/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpService.java b/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpService.java index 1bd6b05..c782dda 100644 --- a/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpService.java +++ b/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpService.java @@ -1,104 +1,641 @@ package org.offeringprotocol.odp.service; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Set; import org.offeringprotocol.odp.core.AuthenticationRequirement; import org.offeringprotocol.odp.core.Collection; import org.offeringprotocol.odp.core.Odp; import org.offeringprotocol.odp.core.OdpJson; import org.offeringprotocol.odp.core.OdpJsonNode; +import org.offeringprotocol.odp.core.OdpMediaType; import org.offeringprotocol.odp.core.OdpOperation; +import org.offeringprotocol.odp.core.OdpUris; import org.offeringprotocol.odp.core.OdpValidationException; import org.offeringprotocol.odp.core.Offering; import org.offeringprotocol.odp.core.OperationDescriptor; import org.offeringprotocol.odp.core.ProblemDetails; import org.offeringprotocol.odp.core.SearchCapabilities; +import org.offeringprotocol.odp.core.SearchCatalog; +import org.offeringprotocol.odp.core.SearchRequests; import org.offeringprotocol.odp.core.ServiceDocument; /** Framework-neutral ODP Service request handler. */ public final class OdpService { private static final int MAXIMUM_REQUEST_BYTES = 65_536; + private static final int MAXIMUM_RESPONSE_BYTES = 524_288; + private static final int MAXIMUM_DOCUMENT_BYTES = 65_536; + private static final int MAXIMUM_RESPONSE_DEPTH = 16; + private static final int MAXIMUM_DOCUMENT_DEPTH = 8; + private static final int MAXIMUM_NEXT_CHARACTERS = 2_048; + private static final int MAXIMUM_TITLE_POINTS = 128; + private static final int MAXIMUM_DETAIL_POINTS = 2_048; + private static final int MAXIMUM_CODE_CHARACTERS = 64; + /** A header a Service reads is bounded like any other untrusted input. */ + private static final int MAXIMUM_HEADER_ENTRIES = 64; + + private static final int DEFAULT_RETRY_AFTER_SECONDS = 60; + private static final int SERVICE_DOCUMENT_SECONDS = 14_400; + private static final int COLLECTION_SECONDS = 3_600; + private static final int OFFERING_SECONDS = 300; + private static final int TAG_CHARACTERS = 22; + private static final String MEDIA_TYPE = "application/odp+json"; + private static final String PROBLEM_MEDIA_TYPE = "application/problem+json"; private static final String GET = "GET"; + private static final String HEAD = "HEAD"; + private static final String POST = "POST"; + private static final String TERSE = "terse"; + private static final String FULL = "full"; private static final String INTERNAL_ERROR = "INTERNAL_ERROR"; + private static final String INVALID_REQUEST = "INVALID_REQUEST"; private static final String NOT_FOUND = "NOT_FOUND"; + private static final String NOT_FOUND_DETAIL = "ODP endpoint not found"; + private static final String CONTENT_TYPE = "Content-Type"; + private static final String CACHE_CONTROL = "Cache-Control"; + private static final String ACCEPT_LANGUAGE = "Accept-Language"; + private static final String VERSION_MEMBER = "odp_version"; + private static final String ANY_APPLICATION = "application/*"; + private static final String ANY_MEDIA = "*/*"; + private static final String ANY = "*"; + private static final String COLLECTIONS = "collections"; + private static final String OFFERINGS = "offerings"; + private static final String SEARCH = "search"; + private static final char QUOTE = '"'; + private static final int MINIMUM_PAGE_ITEMS = 1; + private static final int ONE_VALUE = 1; + private static final int MAXIMUM_PAGE_ITEMS = 100; + + private static final Set SAFE_METHODS = Set.of(GET, HEAD); + private static final Set SEARCH_METHODS = Set.of(GET, HEAD, POST); private final ServiceDocument serviceDocument; private final Map endpoints; private final String endpointBase; + private final List localizations; + private final String origin; + private final java.util.function.Function searchCatalog; public static Builder builder(String name, String description, String language, String endpointBase) { return new Builder(name, description, language, endpointBase); } public OdpService(ServiceDocument template, Map endpoints) { + this(template, endpoints, null); + } + + private OdpService(ServiceDocument template, Map endpoints, String origin) { + this(template, endpoints, origin, null); + } + + private OdpService( + ServiceDocument template, + Map endpoints, + String origin, + java.util.function.Function searchCatalog) { Objects.requireNonNull(template, "template"); + Objects.requireNonNull(endpoints, "endpoints"); 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.origin = origin; + this.searchCatalog = searchCatalog; 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 = template.toBuilder().operations(operations).build(); + // SVC-05/SVC-57 make localizations a required, non-empty member, so there is always a list. + this.localizations = List.copyOf(this.serviceDocument.localizations()); OdpJson.parseServiceDocument(OdpJson.write(this.serviceDocument)); + SearchCatalog.validateAdvertisement( + this.serviceDocument.searchCapabilities(), + endpoints.containsKey(OdpOperation.SEARCH_OFFERINGS), + origin); } public ServiceDocument document() { return serviceDocument.toBuilder().build(); } + /** + * Answers one ODP request. Every outcome is an ODP response: a handler that fails in a way this + * Service did not ask for becomes an {@code INTERNAL_ERROR} problem rather than escaping to the + * framework, and no exception message a handler did not choose to publish reaches the caller. + */ public OdpHttpResponse handle(OdpHttpRequest request) { + Objects.requireNonNull(request, "request"); try { - if (request.body() != null - && request.body().getBytes(java.nio.charset.StandardCharsets.UTF_8).length - > MAXIMUM_REQUEST_BYTES) { - return problem(413, "REQUEST_TOO_LARGE", "ODP request exceeds its byte limit"); + return answer(request); + } catch (OdpServiceException exception) { + return problem( + exception.status(), + exception.code(), + exception.getMessage(), + exception.retryAfter(), + exception.allow() == null ? Map.of() : Map.of("Allow", exception.allow())); + } catch (RuntimeException exception) { + // A handler's own failure is not a message to publish: it can name internal state. + return problem(500, INTERNAL_ERROR, "ODP request could not be processed", null); + } + } + + private OdpHttpResponse answer(OdpHttpRequest request) { + if (byteLength(request.body()) > MAXIMUM_REQUEST_BYTES) { + throw new OdpServiceException(413, "REQUEST_TOO_LARGE", "ODP request exceeds its byte limit"); + } + Route route = route(request); + requireAcceptable(request); + String language = selectLanguage(request); + + if (route.operation() == null) { + return served( + request, + OdpJson.write(serviceDocument), + serviceDocument.language(), + SERVICE_DOCUMENT_SECONDS, + true, + true); + } + Endpoint endpoint = endpoints.get(route.operation()); + if (endpoint == null) { + throw new OdpServiceException(404, NOT_FOUND, NOT_FOUND_DETAIL); + } + requireRequestMediaType(request); + + validateSearchRequest(request, route.operation()); + String representation = requireRepresentation(request, route.operation()); + Integer limit = requireLimit(request); + String cursor = requireSingle(request, "cursor"); + CatalogRequest catalogRequest = new CatalogRequest( + route.identifier(), representation, limit, cursor, language, request.body(), request); + + SearchRequests.Offerings searchRequest = + route.operation() == OdpOperation.SEARCH_OFFERINGS && POST.equals(request.method()) + ? OdpJson.parseOfferingSearchRequest(request.body()) + : null; + SearchCatalog catalog = searchRequest != null && searchCatalog != null + ? Objects.requireNonNull(searchCatalog.apply(catalogRequest), "search catalog") + : null; + if (catalog != null) { + try { + catalog.validateRequest(searchRequest); + } catch (IllegalArgumentException exception) { + throw new OdpServiceException( + 400, INVALID_REQUEST, "ODP search request does not match its capabilities", exception); } - if (GET.equals(request.method()) && Odp.SERVICE_DOCUMENT_PATH.equals(request.path())) { - return json(200, serviceDocument); + } + + Object response = endpoint.handler().handle(catalogRequest); + if (response == null) { + throw new OdpServiceException(404, NOT_FOUND, "ODP resource not found"); + } + String json = OdpJson.write(response); + validateResponse(route.operation(), json, representation, cursor); + if (route.operation() == OdpOperation.SEARCH_OFFERINGS) { + var page = OdpJson.parseOfferingSearchResponse(json); + SearchCatalog.validateRefinementContext(page, searchRequest, searchRequest == null); + if (catalog != null) catalog.validateRefinements(page, searchRequest, false); + } + boolean cacheable = endpoint.authentication() == AuthenticationRequirement.NOT_REQUIRED; + return served( + request, + json, + responseLanguage(route.operation(), json), + freshness(route.operation()), + cacheable, + false); + } + + private static void validateSearchRequest(OdpHttpRequest request, OdpOperation operation) { + if (!POST.equals(request.method()) + || (operation != OdpOperation.SEARCH_OFFERINGS && operation != OdpOperation.SEARCH_COLLECTIONS)) { + return; + } + if (request.body() == null || request.body().isBlank()) { + throw new OdpServiceException(400, INVALID_REQUEST, "ODP search request is required"); + } + try { + OdpJson.parseTree(request.body()); + if (operation == OdpOperation.SEARCH_OFFERINGS) { + OdpJson.parseOfferingSearchRequest(request.body()); + } else if (operation == OdpOperation.SEARCH_COLLECTIONS) { + OdpJson.parseCollectionSearchRequest(request.body()); } - Route route = route(request); - Endpoint endpoint = endpoints.get(route.operation()); - if (endpoint == null) { - return problem(404, NOT_FOUND, "ODP endpoint not found"); + } catch (IllegalArgumentException exception) { + throw new OdpServiceException(400, INVALID_REQUEST, "ODP search request is invalid", exception); + } + } + + private String responseLanguage(OdpOperation operation, String json) { + OdpJsonNode value = OdpJson.parseTree(json); + if (!isPage(operation)) { + return resourceLanguage(value); + } + OdpJsonNode items = value.get("items"); + if (items != null && items.isArray() && items.size() > 0) { + Set languages = new LinkedHashSet<>(); + for (OdpJsonNode item : items) { + languages.add(resourceLanguage(item)); } - String representation = request.queryValue("representation"); - if (representation == null) { - representation = "terse"; + return String.join(", ", languages); + } + return serviceDocument.language(); + } + + private String resourceLanguage(OdpJsonNode value) { + OdpJsonNode language = value.get("language"); + return language != null && language.isString() ? language.asString() : serviceDocument.language(); + } + + // -- request negotiation ---------------------------------------------------------------- + + /** + * MED-04: an ODP resource is served only when the request will take {@code application/odp+json}. + * An absent field, a wildcard, and a range that covers the type all qualify; a range that scores + * it {@code q=0} excludes it as surely as leaving it out. + */ + private static void requireAcceptable(OdpHttpRequest request) { + List fields = headerValues(request, "Accept"); + if (fields.isEmpty()) { + return; + } + double best = -1; + int precision = -1; + boolean stated = false; + for (String field : fields) { + for (String entry : entries(field)) { + stated = true; + String[] parts = entry.split(";"); + int scored = score(parts[0].trim().toLowerCase(Locale.ROOT)); + if (scored < 0 || scored < precision) { + continue; + } + double quality = quality(parts); + // The most specific range that covers the type decides, as RFC 9110 12.5.1 says. + if (scored > precision || quality > best) { + precision = scored; + best = quality; + } } - if (!"terse".equals(representation) && !"full".equals(representation)) { - return problem(400, "INVALID_REQUEST", "representation must be terse or full"); + } + if (stated && best <= 0) { + throw new OdpServiceException(406, "NOT_ACCEPTABLE", "ODP resources are application/odp+json"); + } + } + + /** How specifically a media range names the ODP media type, or -1 when it does not name it. */ + private static int score(String range) { + if (MEDIA_TYPE.equals(range)) { + return 2; + } + if (ANY_APPLICATION.equals(range)) { + return 1; + } + return ANY_MEDIA.equals(range) ? 0 : -1; + } + + private static double quality(String... parts) { + for (int index = 1; index < parts.length; index++) { + String parameter = parts[index].trim(); + if (parameter.regionMatches(true, 0, "q=", 0, 2)) { + try { + double value = Double.parseDouble(parameter.substring(2).trim()); + return Double.isFinite(value) && value >= 0 && value <= 1 ? value : 0; + } catch (NumberFormatException exception) { + // An unreadable quality is no quality; nothing about it is worth reporting. + return 0; + } } - 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 1; + } + + /** MED-06: a body carried into an ODP operation is an ODP document or it is not read. */ + private static void requireRequestMediaType(OdpHttpRequest request) { + if (request.body() == null || request.body().isEmpty()) { + return; + } + String declared = request.headerValue(CONTENT_TYPE); + if (declared == null || !MEDIA_TYPE.equals(essence(declared))) { + throw new OdpServiceException( + 415, "UNSUPPORTED_MEDIA_TYPE", "ODP request bodies must use application/odp+json"); + } + } + + /** MED-09: media-type essence comparison ignores case and parameters. */ + private static String essence(String value) { + return OdpMediaType.essence(value); + } + + /** + * SVC-58/59: the response language is chosen by the RFC 4647 Lookup scheme against the advertised + * localizations, and a request whose ranges match nothing gets the default representation rather + * than a refusal. + */ + private String selectLanguage(OdpHttpRequest request) { + List fields = headerValues(request, ACCEPT_LANGUAGE); + List ranges = new ArrayList<>(); + for (String field : fields) { + for (String entry : entries(field)) { + String[] parts = entry.split(";"); + String range = parts[0].trim().toLowerCase(Locale.ROOT); + if (!range.isEmpty()) { + ranges.add(new Range(range, quality(parts))); + } } - validateResponse(route.operation(), response, representation); - 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()); } + ranges.sort(Comparator.comparingDouble(Range::quality).reversed()); + for (Range range : ranges) { + if (range.quality() <= 0 || ANY.equals(range.value())) { + continue; + } + String matched = lookup(range.value()); + if (matched != null) { + return matched; + } + } + return serviceDocument.language(); } - private static void validateResponse(OdpOperation operation, Object response, String representation) { - String json = OdpJson.write(response); + /** RFC 4647 Lookup: the range is shortened at each subtag boundary until a tag matches. */ + private String lookup(String range) { + String candidate = range; + while (!candidate.isEmpty()) { + for (String tag : localizations) { + if (tag.equalsIgnoreCase(candidate)) { + return tag; + } + } + int cut = candidate.lastIndexOf('-'); + if (cut < 0) { + return null; + } + candidate = candidate.substring(0, cut); + // A truncation that leaves a single-character subtag keeps going past it. + if (candidate.length() > 1 && candidate.charAt(candidate.length() - 2) == '-') { + candidate = candidate.substring(0, candidate.length() - 2); + } + } + return null; + } + + /** SVC-73: a repeated value is as unusable as an unsupported one, and is refused the same way. */ + private static String requireRepresentation(OdpHttpRequest request, OdpOperation operation) { + String representation = requireSingle(request, "representation"); + if (representation == null) { + return isPage(operation) ? TERSE : FULL; + } + if (!TERSE.equals(representation) && !FULL.equals(representation)) { + throw new OdpServiceException(400, INVALID_REQUEST, "representation must be terse or full"); + } + return representation; + } + + private static Integer requireLimit(OdpHttpRequest request) { + String value = requireSingle(request, "limit"); + if (value == null) { + return null; + } + try { + int parsed = Integer.parseInt(value); + if (parsed < MINIMUM_PAGE_ITEMS || parsed > MAXIMUM_PAGE_ITEMS) { + throw new NumberFormatException(value); + } + return parsed; + } catch (NumberFormatException exception) { + throw new OdpServiceException( + 400, INVALID_REQUEST, "limit must be from 1 through " + MAXIMUM_PAGE_ITEMS, exception); + } + } + + private static String requireSingle(OdpHttpRequest request, String name) { + List values = request.query().get(name); + if (values == null || values.isEmpty()) { + return null; + } + if (values.size() > ONE_VALUE) { + throw new OdpServiceException(400, INVALID_REQUEST, name + " must not be repeated"); + } + return values.get(0); + } + + private static List headerValues(OdpHttpRequest request, String name) { + List found = new ArrayList<>(); + for (Map.Entry> entry : request.headers().entrySet()) { + if (entry.getKey().equalsIgnoreCase(name)) { + for (String value : entry.getValue()) { + if (found.size() >= MAXIMUM_HEADER_ENTRIES) { + return found; + } + found.add(value); + } + } + } + return found; + } + + private static List entries(String field) { + List found = new ArrayList<>(); + for (String entry : field.split(",")) { + if (!entry.isBlank()) { + if (found.size() >= MAXIMUM_HEADER_ENTRIES) { + return found; + } + found.add(entry); + } + } + return found; + } + + // -- routing ---------------------------------------------------------------------------- + + /** + * Resolves the target of a request. A path this Service publishes but by another method is + * answered {@code 405} with {@code Allow} rather than pretending the resource is absent, and a + * path segment standing where an identifier belongs is an identifier or the route does not exist. + */ + private Route route(OdpHttpRequest request) { + String path = request.path(); + if (Odp.SERVICE_DOCUMENT_PATH.equals(path)) { + requireMethod(request, SAFE_METHODS); + return new Route(null, null); + } + if (!path.startsWith(endpointBase + "/")) { + throw new OdpServiceException(404, NOT_FOUND, NOT_FOUND_DETAIL); + } + String[] parts = path.substring(endpointBase.length()).split("/", -1); + Route route = shape(parts); + if (route == null) { + throw new OdpServiceException(404, NOT_FOUND, NOT_FOUND_DETAIL); + } + requireMethod( + request, + route.operation() == OdpOperation.SEARCH_COLLECTIONS + || route.operation() == OdpOperation.SEARCH_OFFERINGS + ? SEARCH_METHODS + : SAFE_METHODS); + return route; + } + + private static Route shape(String... parts) { + // parts[0] is the empty string before the leading slash; a trailing slash leaves an empty + // final segment, so "/offerings/plant-1/" never reaches the resource route. + if (parts.length == 2 && COLLECTIONS.equals(parts[1])) { + return new Route(OdpOperation.LIST_COLLECTIONS, null); + } + if (parts.length == 2 && OFFERINGS.equals(parts[1])) { + return new Route(OdpOperation.LIST_OFFERINGS, null); + } + if (parts.length == 3 && COLLECTIONS.equals(parts[1]) && SEARCH.equals(parts[2])) { + return new Route(OdpOperation.SEARCH_COLLECTIONS, null); + } + if (parts.length == 3 && OFFERINGS.equals(parts[1]) && SEARCH.equals(parts[2])) { + return new Route(OdpOperation.SEARCH_OFFERINGS, null); + } + if (parts.length == 3 && COLLECTIONS.equals(parts[1]) && identifier(parts[2])) { + return new Route(OdpOperation.GET_COLLECTION, parts[2]); + } + if (parts.length == 3 && OFFERINGS.equals(parts[1]) && identifier(parts[2])) { + return new Route(OdpOperation.GET_OFFERING, parts[2]); + } + if (parts.length == 4 && COLLECTIONS.equals(parts[1]) && identifier(parts[2]) && OFFERINGS.equals(parts[3])) { + return new Route(OdpOperation.LIST_COLLECTION_OFFERINGS, parts[2]); + } + return null; + } + + /** IDN-08: a path segment naming a resource is a Local Resource Identifier or it names nothing. */ + private static boolean identifier(String value) { + return OdpUris.isLocalResourceIdentifier(value); + } + + private static void requireMethod(OdpHttpRequest request, Set allowed) { + if (!allowed.contains(request.method())) { + String allow = String.join(", ", sorted(allowed)); + throw OdpServiceException.notAllowed("ODP resource accepts " + allow, allow); + } + } + + private static List sorted(Set values) { + return values.stream().sorted().toList(); + } + + // -- responses -------------------------------------------------------------------------- + + /** + * Serializes one successful response. A GET or HEAD carries a validator so an Agent can revalidate + * it, and the conditional fields RFC 9110 defines are honoured before the body is written. + */ + private OdpHttpResponse served( + OdpHttpRequest request, String json, String language, int seconds, boolean cacheable, boolean document) { + requireWithinLimits( + json, + document ? MAXIMUM_DOCUMENT_BYTES : MAXIMUM_RESPONSE_BYTES, + document ? MAXIMUM_DOCUMENT_DEPTH : MAXIMUM_RESPONSE_DEPTH); + + Map headers = new LinkedHashMap<>(); + headers.put(CONTENT_TYPE, MEDIA_TYPE); + headers.put("Content-Language", language); + headers.put("Vary", ACCEPT_LANGUAGE); + headers.put(CACHE_CONTROL, cacheHeader(seconds, cacheable)); + String tag = entityTag(json, language); + headers.put("ETag", tag); + + // RFC 9110 13.2.2: If-Match is evaluated first, and a failed precondition never reaches a body. + if (!matches(headerValues(request, "If-Match"), tag, true)) { + throw new OdpServiceException(412, "PRECONDITION_FAILED", "ODP resource has changed"); + } + if (!matches(headerValues(request, "If-None-Match"), tag, false)) { + if (!SAFE_METHODS.contains(request.method())) { + throw new OdpServiceException(412, "PRECONDITION_FAILED", "ODP resource has not changed"); + } + headers.remove(CONTENT_TYPE); + return new OdpHttpResponse(304, headers, ""); + } + if (HEAD.equals(request.method())) { + headers.put("Content-Length", Integer.toString(byteLength(json))); + return new OdpHttpResponse(200, headers, ""); + } + return new OdpHttpResponse(200, headers, json); + } + + private static String cacheHeader(int seconds, boolean cacheable) { + if (seconds == 0) { + return "no-store"; + } + return (cacheable ? "public" : "private") + ", max-age=" + seconds; + } + + private static int freshness(OdpOperation operation) { + return switch (operation) { + case SEARCH_COLLECTIONS, SEARCH_OFFERINGS -> 0; + case LIST_COLLECTIONS, GET_COLLECTION -> COLLECTION_SECONDS; + case LIST_COLLECTION_OFFERINGS, LIST_OFFERINGS, GET_OFFERING -> OFFERING_SECONDS; + }; + } + + /** SVC-61: the validator covers the variant, so two languages never share an entity tag. */ + private static String entityTag(String json, String language) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + digest.update(language.getBytes(StandardCharsets.UTF_8)); + digest.update((byte) 0); + digest.update(json.getBytes(StandardCharsets.UTF_8)); + return '"' + + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(digest.digest()) + .substring(0, TAG_CHARACTERS) + + '"'; + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } + + /** If-Match uses strong comparison; If-None-Match uses weak comparison. */ + private static boolean matches(List fields, String tag, boolean expected) { + if (fields.isEmpty()) { + return true; + } + for (String field : fields) { + for (String entry : entries(field)) { + String candidate = entry.trim(); + if (ANY.equals(candidate)) { + return expected; + } + if (candidate.startsWith("W/")) { + if (expected) { + continue; + } + candidate = candidate.substring(2); + } + if (candidate.equals(tag)) { + return expected; + } + } + } + return !expected; + } + + // -- response validation ---------------------------------------------------------------- + + private void validateResponse(OdpOperation operation, String json, String representation, String cursor) { + requireWithinLimits(json, MAXIMUM_RESPONSE_BYTES, MAXIMUM_RESPONSE_DEPTH); try { switch (operation) { case GET_COLLECTION -> validateCollection(OdpJson.parseCollection(json), representation); @@ -119,89 +656,199 @@ private static void validateResponse(OdpOperation operation, Object response, St } catch (OdpValidationException exception) { throw new OdpServiceException(500, INTERNAL_ERROR, "ODP catalog returned an invalid response", exception); } - } - - private static void validateOffering(Offering offering, String representation) { - if ("terse".equals(representation) && offering.actions() != null) { - throw new OdpServiceException(500, INTERNAL_ERROR, "ODP catalog returned Actions in a Terse Offering"); - } - if ("full".equals(representation) && offering.detailFields() != null) { - throw new OdpServiceException(500, INTERNAL_ERROR, "ODP catalog returned detail_fields in a Full Offering"); + if (isPage(operation)) { + validatePage(json, cursor); } } - private static void validateCollection(Collection collection, String representation) { - if ("full".equals(representation) && collection.detailFields() != null) { - throw new OdpServiceException( - 500, INTERNAL_ERROR, "ODP catalog returned detail_fields in a Full Collection"); - } + private static boolean isPage(OdpOperation operation) { + return operation != OdpOperation.GET_COLLECTION && operation != OdpOperation.GET_OFFERING; } - 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); + /** + * VER-03/VER-04, PAG-06/07/11: a page's items inherit the version of the document that carries + * them, and its continuation is a bounded same-origin reference that advances the traversal. + */ + private void validatePage(String json, String cursor) { + OdpJsonNode page = OdpJson.parseTree(json); + OdpJsonNode items = page.get("items"); + if (items != null && items.isArray()) { + for (OdpJsonNode item : items) { + if (item.isObject() && item.get(VERSION_MEMBER) != null) { + throw invalidCatalogResponse(); + } + } } - if (("POST".equals(request.method()) || GET.equals(request.method())) && "/collections/search".equals(path)) { - return new Route(OdpOperation.SEARCH_COLLECTIONS, null); + OdpJsonNode next = page.get("next"); + if (next == null || next.isNull()) { + return; } - if (GET.equals(request.method()) && parts.length == 3 && "collections".equals(parts[1])) { - return new Route(OdpOperation.GET_COLLECTION, parts[2]); + if (!next.isString()) { + throw invalidCatalogResponse(); } - 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]); + String reference = next.asString(); + if (reference.length() > MAXIMUM_NEXT_CHARACTERS || reference.chars().anyMatch(value -> value > 127)) { + throw invalidCatalogResponse(); } - if (GET.equals(request.method()) && "/offerings".equals(path)) { - return new Route(OdpOperation.LIST_OFFERINGS, null); + if (origin == null && !reference.startsWith("/")) { + throw invalidCatalogResponse(); } - if (("POST".equals(request.method()) || GET.equals(request.method())) && "/offerings/search".equals(path)) { - return new Route(OdpOperation.SEARCH_OFFERINGS, null); + try { + OdpUris.resolveContinuation(reference, origin == null ? "https://relative.invalid" : origin); + } catch (IllegalArgumentException exception) { + throw new OdpServiceException(500, INTERNAL_ERROR, "ODP catalog returned an invalid response", exception); } - if (GET.equals(request.method()) && parts.length == 3 && "offerings".equals(parts[1])) { - return new Route(OdpOperation.GET_OFFERING, parts[2]); + if (cursor != null && cursor.equals(parameter(reference, "cursor"))) { + // PAG-11: a continuation that hands back the cursor it was given never terminates. + throw invalidCatalogResponse(); } - 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) { + private static String parameter(String reference, String name) { + int start = reference.indexOf('?'); + if (start < 0) { return null; } - try { - int parsed = Integer.parseInt(value); - if (parsed < minimum || parsed > maximum) { - throw new NumberFormatException(); + for (String pair : reference.substring(start + 1).split("&")) { + int equals = pair.indexOf('='); + if (equals > 0 && pair.substring(0, equals).equals(name)) { + return pair.substring(equals + 1); + } + } + return null; + } + + private static void validateOffering(Offering offering, String representation) { + if (TERSE.equals(representation) && offering.actions() != null) { + throw invalidCatalogResponse(); + } + if (FULL.equals(representation) && offering.detailFields() != null) { + throw invalidCatalogResponse(); + } + } + + private void validateCollection(Collection collection, String representation) { + SearchCatalog.validateAdvertisement( + collection.searchCapabilities(), endpoints.containsKey(OdpOperation.SEARCH_OFFERINGS), origin); + if (FULL.equals(representation) && collection.detailFields() != null) { + throw invalidCatalogResponse(); + } + } + + private static OdpServiceException invalidCatalogResponse() { + return new OdpServiceException(500, INTERNAL_ERROR, "ODP catalog returned an invalid response"); + } + + /** ERR-19: a document this Service would not accept from anybody else is not one it sends. */ + private static void requireWithinLimits(String json, int bytes, int allowedDepth) { + if (byteLength(json) > bytes || depth(json) > allowedDepth) { + throw new OdpServiceException(500, INTERNAL_ERROR, "ODP response exceeds its limits"); + } + } + + /** Nesting depth read off the serialized form, so no second tree is built to measure it. */ + private static int depth(String json) { + int deepest = 0; + int current = 0; + boolean inString = false; + boolean escaped = false; + for (int index = 0; index < json.length(); index++) { + char character = json.charAt(index); + if (escaped) { + escaped = false; + } else if (inString && character == '\\') { + escaped = true; + } else if (character == QUOTE) { + inString = !inString; + } else if (!inString && (character == '{' || character == '[')) { + current++; + deepest = Math.max(deepest, current); + } else if (!inString && (character == '}' || character == ']')) { + current--; } - return parsed; - } catch (NumberFormatException exception) { - throw new IllegalArgumentException(name + " must be from " + minimum + " through " + maximum, exception); } + return deepest; } - private static OdpHttpResponse json(int status, Object body) { - return new OdpHttpResponse(status, Map.of("Content-Type", MEDIA_TYPE), OdpJson.write(body)); + private static int byteLength(String value) { + return value == null ? 0 : value.getBytes(StandardCharsets.UTF_8).length; } - private static OdpHttpResponse problem(int status, String code, String detail) { - ProblemDetails problem = new ProblemDetails( + // -- problems --------------------------------------------------------------------------- + + /** + * ERR-02/04/06/07: a problem carries a well-formed code, a type derived from it, and strings + * within their limits. A code a handler invented that ODP could not carry becomes + * {@code INTERNAL_ERROR}, because an invalid code would make the whole document invalid. + */ + private static OdpHttpResponse problem(int status, String rawCode, String rawDetail, Integer retryAfter) { + return problem(status, rawCode, rawDetail, retryAfter, Map.of()); + } + + private static OdpHttpResponse problem( + int status, String rawCode, String rawDetail, Integer retryAfter, Map extra) { + int reported = status < 400 || status > 599 ? 500 : status; + String code = isCode(rawCode) ? rawCode : (reported < 500 ? INVALID_REQUEST : INTERNAL_ERROR); + ProblemDetails details = new ProblemDetails( "https://offeringprotocol.org/problems/" + code.toLowerCase(Locale.ROOT).replace('_', '-'), - detail, - status, + title(code), + reported, code, - detail, + bounded(rawDetail, MAXIMUM_DETAIL_POINTS), null, null, Map.of()); - return new OdpHttpResponse(status, Map.of("Content-Type", "application/problem+json"), OdpJson.write(problem)); + // ERR-21 bounds a Problem Details response at 16,384 bytes, and nothing here can reach it: + // the type and title are built from a code of at most 64 characters and the detail is cut to + // 2,048 code points, which is at most 8,192 bytes of UTF-8. + String json = OdpJson.write(details); + Map headers = new LinkedHashMap<>(extra); + headers.put(CONTENT_TYPE, PROBLEM_MEDIA_TYPE); + headers.put(CACHE_CONTROL, "no-store"); + // ERR-32 makes Retry-After part of a 429; ERR-33 asks for it on a 503. + if (reported == 429 || reported == 503) { + headers.put("Retry-After", Integer.toString(retryAfter == null ? DEFAULT_RETRY_AFTER_SECONDS : retryAfter)); + } else if (retryAfter != null) { + headers.put("Retry-After", Integer.toString(retryAfter)); + } + return new OdpHttpResponse(reported, headers, json); + } + + /** ERR-06: 1-64 uppercase ASCII letters, digits, or underscores, beginning with a letter. */ + private static boolean isCode(String value) { + if (value == null || value.isEmpty() || value.length() > MAXIMUM_CODE_CHARACTERS) { + return false; + } + if (value.charAt(0) < 'A' || value.charAt(0) > 'Z') { + return false; + } + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + boolean allowed = (character >= 'A' && character <= 'Z') + || (character >= '0' && character <= '9') + || character == '_'; + if (!allowed) { + return false; + } + } + return true; + } + + /** A short human-readable summary of the code, which is what ERR-02 asks {@code title} to be. */ + private static String title(String code) { + String words = code.toLowerCase(Locale.ROOT).replace('_', ' '); + return bounded(Character.toUpperCase(words.charAt(0)) + words.substring(1), MAXIMUM_TITLE_POINTS); + } + + private static String bounded(String value, int points) { + if (value == null) { + return null; + } + if (value.codePointCount(0, value.length()) <= points) { + return value; + } + return value.substring(0, value.offsetByCodePoints(0, points - 1)) + "…"; } public record Endpoint(AuthenticationRequirement authentication, CatalogHandler handler) { @@ -211,11 +858,14 @@ public record Endpoint(AuthenticationRequirement authentication, CatalogHandler } } + private record Range(String value, double quality) {} + public static final class Builder { private final String name; private final String description; private final String language; private final String endpointBase; + private String configuredOrigin; private String configuredDocumentationUrl; private List configuredLocalizations; private List configuredMcp; @@ -225,6 +875,7 @@ public static final class Builder { private ServiceDocument.Protocols configuredProtocols; private List configuredPaymentOrigins; private SearchCapabilities configuredSearchCapabilities; + private java.util.function.Function configuredSearchCatalog; private String configuredStatusUrl; private String configuredSupportUrl; private String configuredWebsiteUrl; @@ -245,6 +896,18 @@ public Builder documentationUrl(String value) { return this; } + /** Trusted public origin used to validate absolute continuation links. */ + public Builder origin(String value) { + URI uri = URI.create(Objects.requireNonNull(value, "origin")); + if ((uri.getRawPath() != null && !uri.getRawPath().isEmpty() && !"/".equals(uri.getRawPath())) + || uri.getRawQuery() != null + || uri.getRawFragment() != null) { + throw new IllegalArgumentException("origin must not contain a path, query, or fragment"); + } + this.configuredOrigin = OdpUris.deriveServiceOrigin(uri); + return this; + } + public Builder localizations(List values) { this.configuredLocalizations = List.copyOf(values); return this; @@ -285,6 +948,12 @@ public Builder searchCapabilities(SearchCapabilities value) { return this; } + /** Supplies definitions for this request's access context and selected Collection. */ + public Builder searchCatalog(java.util.function.Function value) { + this.configuredSearchCatalog = Objects.requireNonNull(value, "searchCatalog"); + return this; + } + public Builder statusUrl(String value) { this.configuredStatusUrl = value; return this; @@ -335,19 +1004,20 @@ public OdpService build() { .websiteUrl(configuredWebsiteUrl) .additional(configuredAdditional) .build(); - Map configuredEndpoints = new EnumMap<>(OdpOperation.class); - configuredEndpoints.putAll(this.configuredEndpoints); + Map resolved = new EnumMap<>(OdpOperation.class); + resolved.putAll(this.configuredEndpoints); configuredOperationAuthentication.forEach((operation, requirement) -> { - Endpoint endpoint = configuredEndpoints.get(operation); + Endpoint endpoint = resolved.get(operation); if (endpoint == null) { throw new IllegalArgumentException( "authentication requirement refers to an unconfigured operation: " + operation.value()); } - configuredEndpoints.put(operation, new Endpoint(requirement, endpoint.handler())); + resolved.put(operation, new Endpoint(requirement, endpoint.handler())); }); - return new OdpService(template, configuredEndpoints); + return new OdpService(template, resolved, configuredOrigin, configuredSearchCatalog); } } + /** A resolved target. A null operation is the Service Document itself. */ 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 index 9725940..dcc4c8f 100644 --- a/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpServiceException.java +++ b/odp-service/src/main/java/org/offeringprotocol/odp/service/OdpServiceException.java @@ -1,21 +1,46 @@ package org.offeringprotocol.odp.service; +/** + * A failure a handler chose to publish. Its message reaches the caller as the problem's + * {@code detail}, so it describes the request rather than the Service's internal state; a failure + * the Service did not ask for becomes an {@code INTERNAL_ERROR} carrying no message at all. + */ public final class OdpServiceException extends RuntimeException { private static final long serialVersionUID = 1L; private final int responseStatus; private final String problemCode; + private final Integer retryAfterSeconds; + private final String allowedMethods; public OdpServiceException(int status, String code, String message) { - super(message); - this.responseStatus = status; - this.problemCode = code; + this(status, code, message, null, null, null); } public OdpServiceException(int status, String code, String message, Throwable cause) { + this(status, code, message, cause, null, null); + } + + /** + * @param retryAfter seconds a caller should wait before retrying. A {@code 429} carries the field + * whether or not one is given, because ERR-32 requires it; a {@code 503} carries it too. + */ + public static OdpServiceException retryable(int status, String code, String message, int retryAfter) { + return new OdpServiceException(status, code, message, null, retryAfter, null); + } + + /** A 405, which RFC 9110 15.5.6 requires to name the methods the resource does allow. */ + static OdpServiceException notAllowed(String message, String allow) { + return new OdpServiceException(405, "METHOD_NOT_ALLOWED", message, null, null, allow); + } + + private OdpServiceException( + int status, String code, String message, Throwable cause, Integer retryAfter, String allow) { super(message, cause); this.responseStatus = status; this.problemCode = code; + this.retryAfterSeconds = retryAfter; + this.allowedMethods = allow; } public int status() { @@ -25,4 +50,14 @@ public int status() { public String code() { return problemCode; } + + /** Seconds to wait before retrying, when the Service knows; otherwise null. */ + public Integer retryAfter() { + return retryAfterSeconds; + } + + /** The {@code Allow} field value this failure carries, when it is a 405; otherwise null. */ + public String allow() { + return allowedMethods; + } } 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 index d6be0f2..bf4d78b 100644 --- a/odp-service/src/main/java/org/offeringprotocol/odp/service/StaticCatalog.java +++ b/odp-service/src/main/java/org/offeringprotocol/odp/service/StaticCatalog.java @@ -6,9 +6,13 @@ import java.security.SecureRandom; import java.time.Instant; import java.util.Base64; +import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.function.Function; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; @@ -24,6 +28,11 @@ 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 int DEFAULT_PAGE_ITEMS = 50; + private static final int MAXIMUM_PARENT_EDGES = 32; + /** PAG-19: a continuation stays usable for at least an hour after it is issued. */ + private static final long CONTINUATION_SECONDS = 3_600; + private static final SecureRandom RANDOM = new SecureRandom(); private StaticCatalog() {} @@ -37,6 +46,9 @@ public static Map create( public static Map create( List offerings, List collections, byte[] continuationKey) { + Objects.requireNonNull(offerings, "offerings"); + Objects.requireNonNull(collections, "collections"); + Objects.requireNonNull(continuationKey, "continuationKey"); if (continuationKey.length < MINIMUM_KEY_BYTES) { throw new IllegalArgumentException("continuationKey must contain at least 32 bytes"); } @@ -45,10 +57,24 @@ public static Map create( byte[] key = continuationKey.clone(); Map offeringsById = unique(catalogOfferings, Offering::id, "Offering"); Map collectionsById = unique(catalogCollections, Collection::id, "Collection"); + Map depths = new HashMap<>(); + for (Collection collection : catalogCollections) { + parentDepth(collection, collectionsById, depths, new HashSet<>()); + } + for (Offering offering : catalogOfferings) { + if (offering.collectionIds() != null && !collectionsById.keySet().containsAll(offering.collectionIds())) { + throw new IllegalArgumentException("Offering membership references a missing Collection"); + } + } Map handlers = new LinkedHashMap<>(); handlers.put( OdpOperation.LIST_OFFERINGS, - endpoint(request -> page(catalogOfferings, request, StaticCatalog::terseOfferingItem, key))); + endpoint(request -> page( + catalogOfferings, + request, + StaticCatalog::terseOfferingItem, + StaticCatalog::fullOfferingItem, + key))); handlers.put( OdpOperation.GET_OFFERING, endpoint(request -> @@ -56,7 +82,12 @@ public static Map create( if (!catalogCollections.isEmpty()) { handlers.put( OdpOperation.LIST_COLLECTIONS, - endpoint(request -> page(catalogCollections, request, StaticCatalog::terseCollectionItem, key))); + endpoint(request -> page( + catalogCollections, + request, + StaticCatalog::terseCollectionItem, + StaticCatalog::fullCollectionItem, + key))); handlers.put( OdpOperation.GET_COLLECTION, endpoint(request -> represent( @@ -69,7 +100,7 @@ public static Map create( .filter(offering -> offering.collectionIds() != null && offering.collectionIds().contains(request.identifier())) .toList(); - return page(matches, request, StaticCatalog::terseOfferingItem, key); + return page(matches, request, StaticCatalog::terseOfferingItem, StaticCatalog::fullOfferingItem, key); })); } return Map.copyOf(handlers); @@ -79,14 +110,43 @@ private static OdpService.Endpoint endpoint(CatalogHandler handler) { return new OdpService.Endpoint(AuthenticationRequirement.NOT_REQUIRED, handler); } + private static int parentDepth( + Collection collection, + Map collections, + Map depths, + Set visiting) { + Integer known = depths.get(collection.id()); + if (known != null) return known; + if (visiting.size() > MAXIMUM_PARENT_EDGES || !visiting.add(collection.id())) { + throw new IllegalArgumentException("Collection hierarchy contains a cycle or exceeds 32 parent edges"); + } + int depth = 0; + if (collection.parentIds() != null) { + for (String identifier : collection.parentIds()) { + Collection parent = collections.get(identifier); + if (parent == null) throw new IllegalArgumentException("Collection references a missing parent"); + depth = Math.max(depth, 1 + parentDepth(parent, collections, depths, visiting)); + } + } + if (depth > MAXIMUM_PARENT_EDGES) + throw new IllegalArgumentException("Collection hierarchy exceeds 32 parent edges"); + visiting.remove(collection.id()); + depths.put(collection.id(), depth); + return depth; + } + private static Page page( - List values, CatalogRequest request, Function terseRepresentation, byte[] continuationKey) { - int limit = request.limit() == null ? 50 : request.limit(); + List values, + CatalogRequest request, + Function terseItem, + Function fullItem, + byte[] continuationKey) { + int limit = request.limit() == null ? DEFAULT_PAGE_ITEMS : 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)) + .map(value -> "full".equals(request.representation()) ? fullItem.apply(value) : terseItem.apply(value)) .toList(); int nextOffset = offset + items.size(); String next = nextOffset >= values.size() @@ -100,7 +160,7 @@ private static Page page( 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" + .encodeToString((expiry(Instant.now()) + "\n" + offset + "\n" + limit + "\n" + request.representation() + "\n" + request.request().path()) .getBytes(StandardCharsets.UTF_8)); @@ -142,6 +202,16 @@ private static int decodeCursor(String cursor, CatalogRequest request, int limit } } + /** + * When a continuation stops working. The instant is rounded to the hour and then advanced two + * hours, so every continuation lasts at least the hour PAG-19 requires and the value is the same + * for every cursor issued in that hour rather than a record of when this one was handed out. + */ + private static long expiry(Instant now) { + long hour = Math.floorDiv(now.getEpochSecond(), CONTINUATION_SECONDS); + return (hour + 2) * CONTINUATION_SECONDS; + } + private static byte[] sign(String payload, byte[] key) { try { Mac mac = Mac.getInstance("HmacSHA256"); @@ -157,9 +227,7 @@ private static OdpServiceException expiredCursor() { } private static OdpServiceException expiredCursor(Throwable cause) { - OdpServiceException exception = expiredCursor(); - exception.initCause(cause); - return exception; + return new OdpServiceException(410, "CONTINUATION_EXPIRED", "Continuation is unavailable", cause); } private static T represent(T value, CatalogRequest request, Function terseRepresentation) { @@ -177,6 +245,27 @@ private static Offering terseOfferingItem(Offering value) { return terseOffering(value, true); } + /** VER-04: an item in a page inherits the version of the page, so it does not carry its own. */ + private static Offering fullOfferingItem(Offering value) { + return new Offering( + value.authExpands(), + null, + value.id(), + value.name(), + value.description(), + value.images(), + value.language(), + value.localizations(), + value.webUrl(), + value.collectionIds(), + value.price(), + value.schema(), + value.attributes(), + value.actions(), + value.detailFields(), + value.additional()); + } + private static Offering terseOffering(Offering value, boolean embedded) { return new Offering( value.authExpands(), @@ -205,6 +294,23 @@ private static Collection terseCollectionItem(Collection value) { return terseCollection(value, true); } + private static Collection fullCollectionItem(Collection value) { + return new Collection( + value.authExpands(), + null, + value.id(), + value.name(), + value.description(), + value.images(), + value.language(), + value.localizations(), + value.parentIds(), + value.webUrl(), + value.searchCapabilities(), + value.detailFields(), + value.additional()); + } + private static Collection terseCollection(Collection value, boolean embedded) { return new Collection( value.authExpands(), @@ -225,7 +331,11 @@ private static Collection terseCollection(Collection value, boolean embedded) { 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) { + String id = identifier.apply(value); + if (id == null) { + throw new IllegalArgumentException(resourceType + " identifiers must be present"); + } + if (result.put(id, value) != null) { throw new IllegalArgumentException(resourceType + " identifiers must be unique"); } } diff --git a/odp-service/src/test/java/org/offeringprotocol/odp/service/CachingConformanceTest.java b/odp-service/src/test/java/org/offeringprotocol/odp/service/CachingConformanceTest.java new file mode 100644 index 0000000..8af9b4a --- /dev/null +++ b/odp-service/src/test/java/org/offeringprotocol/odp/service/CachingConformanceTest.java @@ -0,0 +1,199 @@ +package org.offeringprotocol.odp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.offeringprotocol.odp.service.Catalog.COLLECTIONS; +import static org.offeringprotocol.odp.service.Catalog.OFFERINGS; +import static org.offeringprotocol.odp.service.Catalog.get; +import static org.offeringprotocol.odp.service.Catalog.with; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.offeringprotocol.odp.core.AuthenticationRequirement; +import org.offeringprotocol.odp.core.Odp; +import org.offeringprotocol.odp.core.OdpOperation; + +class CachingConformanceTest { + private final OdpService service = Catalog.service(); + + /** PAG-31: a GET carries a validator, so an Agent can ask whether anything changed. */ + @Test + void offersAValidatorOnEveryRetrieval() { + for (String path : List.of(Odp.SERVICE_DOCUMENT_PATH, OFFERINGS, OFFERINGS + "/plant-1", COLLECTIONS)) { + String tag = service.handle(get(path)).headers().get("ETag"); + assertTrue(tag != null && tag.startsWith("\"") && tag.endsWith("\""), path + " -> " + tag); + assertEquals(tag, service.handle(get(path)).headers().get("ETag"), "the same document, the same tag"); + } + } + + @Test + void answersAConditionalRetrievalOfAnUnchangedResourceWithNotModified() { + String tag = service.handle(get(OFFERINGS)).headers().get("ETag"); + OdpHttpResponse response = service.handle(with(OFFERINGS, "If-None-Match", tag)); + + assertEquals(304, response.status()); + assertEquals("", response.body()); + assertEquals(tag, response.headers().get("ETag")); + assertNull(response.headers().get("Content-Type"), "a 304 carries no representation to describe"); + assertEquals("Accept-Language", response.headers().get("Vary")); + } + + @Test + void readsEveryFormOfTheConditionalField() { + String tag = service.handle(get(OFFERINGS)).headers().get("ETag"); + + assertEquals(304, service.handle(with(OFFERINGS, "If-None-Match", "*")).status()); + assertEquals( + 304, + service.handle(with(OFFERINGS, "If-None-Match", "W/" + tag)).status()); + assertEquals( + 304, + service.handle(with(OFFERINGS, "If-None-Match", "\"other\", " + tag)) + .status()); + assertEquals( + 200, + service.handle(with(OFFERINGS, "If-None-Match", "\"other\"")).status()); + assertEquals(200, service.handle(with(OFFERINGS, "If-None-Match", "")).status()); + } + + @Test + void answersAFailedPreconditionWithoutTheResource() { + String tag = service.handle(get(OFFERINGS)).headers().get("ETag"); + + assertEquals(200, service.handle(with(OFFERINGS, "If-Match", tag)).status()); + assertEquals( + 412, service.handle(with(OFFERINGS, "If-Match", "W/" + tag)).status()); + assertEquals(200, service.handle(with(OFFERINGS, "If-Match", "*")).status()); + + OdpHttpResponse response = service.handle(with(OFFERINGS, "If-Match", "\"stale\"")); + assertEquals(412, response.status()); + assertTrue(response.body().contains("PRECONDITION_FAILED")); + assertTrue(!response.body().contains("plant-1")); + } + + @Test + void matchingIfNoneMatchOnPostIsAPreconditionFailure() { + OdpService searching = Catalog.handling(request -> Catalog.page(List.of(), null)); + String body = "{\"odp_version\":\"1.0\",\"query\":\"plants\"}"; + String tag = searching + .handle(Catalog.post(OFFERINGS + "/search", Catalog.ODP_JSON, body)) + .headers() + .get("ETag"); + for (String conditional : List.of(tag, "W/" + tag, "*")) { + OdpHttpResponse response = searching.handle(new OdpHttpRequest( + "POST", + OFFERINGS + "/search", + Map.of(), + Map.of("Content-Type", List.of(Catalog.ODP_JSON), "If-None-Match", List.of(conditional)), + body)); + assertEquals(412, response.status()); + } + } + + /** SVC-61: two variants of one resource never share a validator. */ + @Test + void givesEachVariantItsOwnValidator() { + OdpService acting = acting(); + String terse = acting.handle(get(OFFERINGS + "/plant-1", Catalog.query("representation", "terse"))) + .headers() + .get("ETag"); + String full = acting.handle(get(OFFERINGS + "/plant-1", Catalog.query("representation", "full"))) + .headers() + .get("ETag"); + assertNotEquals(terse, full); + + OdpService multilingual = Catalog.multilingual("en", "fr"); + assertNotEquals( + multilingual + .handle(with(OFFERINGS, "Accept-Language", "en")) + .headers() + .get("ETag"), + multilingual + .handle(with(OFFERINGS, "Accept-Language", "fr")) + .headers() + .get("ETag")); + } + + /** A conditional retrieval of one variant does not answer for another. */ + @Test + void doesNotAnswerOneVariantWithAnothersValidator() { + OdpService acting = acting(); + String terse = acting.handle(get(OFFERINGS + "/plant-1", Catalog.query("representation", "terse"))) + .headers() + .get("ETag"); + OdpHttpResponse response = acting.handle(new OdpHttpRequest( + "GET", + OFFERINGS + "/plant-1", + Catalog.query("representation", "full"), + Map.of("If-None-Match", List.of(terse)), + null)); + + assertEquals(200, response.status()); + } + + /** CCH-01: each resource class states its own freshness rather than leaving a cache to guess. */ + @ParameterizedTest + @CsvSource({ + "/.well-known/odp,public max-age=14400", + "/odp/offerings,public max-age=300", + "/odp/offerings/plant-1,public max-age=300", + "/odp/collections,public max-age=3600", + "/odp/collections/plants,public max-age=3600", + "/odp/collections/plants/offerings,public max-age=300" + }) + void statesTheFreshnessOfEachResourceClass(String path, String expected) { + assertEquals( + expected.replace(' ', ','), + service.handle(get(path)).headers().get("Cache-Control").replace(", ", ",")); + } + + /** A search answers the request that was made, and is never reused for the next one. */ + @Test + void keepsSearchResponsesOutOfCaches() { + OdpService searching = Catalog.handling(request -> Catalog.page(List.of(), null)); + + assertEquals( + "no-store", + searching.handle(get(OFFERINGS + "/search")).headers().get("Cache-Control")); + } + + /** CCH-05/06: a response an Agent had to authenticate for is not a shared one. */ + @Test + void marksAnAuthenticatedResponsePrivate() { + OdpService guarded = OdpService.builder("Plant Store", "Plants for agents.", "en", Catalog.BASE) + .endpoints(StaticCatalog.create(List.of(Catalog.offering("plant-1", "Rubber Plant")), List.of())) + .protocols(new org.offeringprotocol.odp.core.ServiceDocument.Protocols( + List.of(new org.offeringprotocol.odp.core.ServiceDocument.EnrollmentProtocol("aep")), null)) + .operationAuthentication(Map.of(OdpOperation.GET_OFFERING, AuthenticationRequirement.REQUIRED)) + .build(); + + assertTrue(guarded.handle(get(OFFERINGS + "/plant-1")) + .headers() + .get("Cache-Control") + .startsWith("private")); + assertTrue(guarded.handle(get(OFFERINGS)).headers().get("Cache-Control").startsWith("public")); + } + + /** An Offering whose Full representation says more than its Terse one. */ + private static OdpService acting() { + return new OdpService( + Catalog.template(List.of("en")), + StaticCatalog.create( + List.of(Catalog.offering("plant-1", "Rubber Plant", List.of(Catalog.action()), null)), + List.of())); + } + + /** A problem describes this request only, so nothing may store it and answer the next one. */ + @Test + void keepsProblemsOutOfCaches() { + OdpHttpResponse response = service.handle(get(OFFERINGS + "/absent")); + + assertEquals("no-store", response.headers().get("Cache-Control")); + assertEquals("application/problem+json", response.headers().get("Content-Type")); + } +} diff --git a/odp-service/src/test/java/org/offeringprotocol/odp/service/Catalog.java b/odp-service/src/test/java/org/offeringprotocol/odp/service/Catalog.java new file mode 100644 index 0000000..c93e0b8 --- /dev/null +++ b/odp-service/src/test/java/org/offeringprotocol/odp/service/Catalog.java @@ -0,0 +1,151 @@ +package org.offeringprotocol.odp.service; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +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; +import org.offeringprotocol.odp.core.ServiceDocument; + +/** The fixtures the Service tests are written against. */ +final class Catalog { + static final String BASE = "/odp"; + static final String OFFERINGS = BASE + "/offerings"; + static final String COLLECTIONS = BASE + "/collections"; + static final String ODP_JSON = "application/odp+json"; + + private Catalog() {} + + /** A Service over two Offerings in one Collection, with every static handler registered. */ + static OdpService service() { + return new OdpService( + template(List.of("en")), + StaticCatalog.create( + List.of(offering("plant-1", "Rubber Plant"), offering("plant-2", "Snake Plant")), + List.of(collection("plants")))); + } + + /** The same Service, answering in whichever of these languages the request asks for. */ + static OdpService multilingual(String... localizations) { + CatalogHandler handler = request -> { + var value = org.offeringprotocol.odp.core.OdpJson.valueToTree( + offering("plant-1", "fr".equals(request.language()) ? "Plante" : "Plant")); + value.put("language", request.language()); + if (request.identifier() != null) { + return value; + } + value.remove("odp_version"); + return new Page<>(null, Odp.VERSION, List.of(value), null, Map.of()); + }; + OdpService.Endpoint endpoint = new OdpService.Endpoint(AuthenticationRequirement.NOT_REQUIRED, handler); + return new OdpService( + template(List.of(localizations)), + Map.of(OdpOperation.LIST_OFFERINGS, endpoint, OdpOperation.GET_OFFERING, endpoint)); + } + + /** A Service whose list and get handlers are whatever a test wants them to be. */ + static OdpService handling(CatalogHandler handler) { + Map endpoints = + new EnumMap<>(StaticCatalog.create(List.of(offering("plant-1", "Rubber Plant")), List.of())); + OdpService.Endpoint endpoint = new OdpService.Endpoint(AuthenticationRequirement.NOT_REQUIRED, handler); + endpoints.put(OdpOperation.GET_OFFERING, endpoint); + endpoints.put(OdpOperation.LIST_OFFERINGS, endpoint); + endpoints.put(OdpOperation.SEARCH_OFFERINGS, endpoint); + return new OdpService(template(List.of("en")), endpoints); + } + + static OdpService failing(RuntimeException thrown) { + return handling(request -> { + throw thrown; + }); + } + + static ServiceDocument template(List localizations) { + return ServiceDocument.builder( + "Plant Store", "Plants for agents.", localizations.get(0), new ServiceDocument.Http(BASE, null)) + .localizations(localizations) + .operations(List.of()) + .build(); + } + + static Offering offering(String id, String name) { + return offering(id, name, null, null); + } + + static Offering offering(String id, String name, List actions, List detailFields) { + return new Offering( + null, + Odp.VERSION, + id, + name, + null, + null, + null, + null, + null, + null, + null, + null, + null, + actions, + detailFields, + Map.of()); + } + + /** A page item, which inherits the version of the document that carries it. */ + static Offering item(String id, String name) { + return new Offering( + null, null, id, name, null, null, null, null, null, null, null, null, null, null, null, Map.of()); + } + + static Collection collection(String id) { + return collection(id, null); + } + + static Collection collection(String id, List detailFields) { + return new Collection( + null, Odp.VERSION, id, "Plants", null, null, null, null, null, null, null, detailFields, Map.of()); + } + + static Offering.Action action() { + return new Offering.Action( + AuthenticationRequirement.NOT_REQUIRED, + "purchase", + "purchase", + null, + new Offering.HttpTarget("/purchase", "POST", null, null), + null); + } + + static Page page(List items, String next) { + return new Page<>(null, Odp.VERSION, items, next, Map.of()); + } + + // -- requests --------------------------------------------------------------------------- + + static OdpHttpRequest get(String path) { + return new OdpHttpRequest("GET", path, Map.of(), Map.of(), null); + } + + static OdpHttpRequest get(String path, Map> query) { + return new OdpHttpRequest("GET", path, query, Map.of(), null); + } + + static OdpHttpRequest with(String path, String header, String value) { + return new OdpHttpRequest("GET", path, Map.of(), Map.of(header, List.of(value)), null); + } + + static OdpHttpRequest post(String path, String contentType, String body) { + Map> headers = + contentType == null ? Map.of() : Map.of("Content-Type", List.of(contentType)); + return new OdpHttpRequest("POST", path, Map.of(), headers, body); + } + + static Map> query(String name, String... values) { + return Map.of(name, List.of(values)); + } +} diff --git a/odp-service/src/test/java/org/offeringprotocol/odp/service/EdgeCaseTest.java b/odp-service/src/test/java/org/offeringprotocol/odp/service/EdgeCaseTest.java new file mode 100644 index 0000000..635a7e2 --- /dev/null +++ b/odp-service/src/test/java/org/offeringprotocol/odp/service/EdgeCaseTest.java @@ -0,0 +1,400 @@ +package org.offeringprotocol.odp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.offeringprotocol.odp.service.Catalog.COLLECTIONS; +import static org.offeringprotocol.odp.service.Catalog.OFFERINGS; +import static org.offeringprotocol.odp.service.Catalog.get; +import static org.offeringprotocol.odp.service.Catalog.query; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.offeringprotocol.odp.core.AuthenticationRequirement; +import org.offeringprotocol.odp.core.Collection; +import org.offeringprotocol.odp.core.Odp; +import org.offeringprotocol.odp.core.OdpJson; +import org.offeringprotocol.odp.core.OdpOperation; +import org.offeringprotocol.odp.core.Offering; +import org.offeringprotocol.odp.core.ResourceImage; +import org.offeringprotocol.odp.core.ServiceDocument; + +class EdgeCaseTest { + /** A Service Document with no localizations of its own still answers in the one language it has. */ + @Test + void answersInItsOnlyLanguageWhenItListsNoLocalizations() { + ServiceDocument template = ServiceDocument.builder( + "Plant Store", "Plants for agents.", "en", new ServiceDocument.Http(Catalog.BASE, null)) + .operations(List.of()) + .build(); + OdpService service = new OdpService( + template, StaticCatalog.create(List.of(Catalog.offering("plant-1", "Rubber Plant")), List.of())); + + assertEquals( + "en", + service.handle(Catalog.with(OFFERINGS, "Accept-Language", "fr")) + .headers() + .get("Content-Language")); + } + + /** A field carrying parameters that are not a quality is still a range at full quality. */ + @Test + void readsARangeWhoseParametersAreNotQualities() { + assertEquals( + 200, + Catalog.service() + .handle(Catalog.with(OFFERINGS, "Accept", "application/odp+json;profile=x;charset=utf-8")) + .status()); + } + + /** A request stuffed with header entries is read up to a bound and no further. */ + @Test + void readsOnlyAsManyHeaderEntriesAsItWillConsider() { + List many = new ArrayList<>(); + for (int index = 0; index < 200; index++) { + many.add("lang-" + index); + } + many.add("fr"); + OdpService service = Catalog.multilingual("en", "fr"); + + assertEquals( + "en", + service.handle(new OdpHttpRequest("GET", OFFERINGS, Map.of(), Map.of("Accept-Language", many), null)) + .headers() + .get("Content-Language"), + "the hundredth range is past what any real request states"); + assertEquals( + "en", + service.handle(Catalog.with(OFFERINGS, "Accept-Language", String.join(",", many))) + .headers() + .get("Content-Language")); + } + + /** REP-27: a Terse Representation carries the primary image only. */ + @Test + void carriesOnlyThePrimaryImageInATerseRepresentation() { + Offering illustrated = illustrated("plant-1"); + Collection pictured = pictured("plants"); + Map endpoints = + new EnumMap<>(StaticCatalog.create(List.of(illustrated), List.of(pictured))); + OdpService service = new OdpService(Catalog.template(List.of("en")), endpoints); + + assertFalse(service.handle(get(OFFERINGS + "/plant-1", query("representation", "terse"))) + .body() + .contains("second.png")); + assertTrue(service.handle(get(OFFERINGS + "/plant-1", query("representation", "full"))) + .body() + .contains("second.png")); + assertFalse(service.handle(get(COLLECTIONS + "/plants", query("representation", "terse"))) + .body() + .contains("second.png")); + assertTrue(service.handle(get(COLLECTIONS + "/plants", query("representation", "full"))) + .body() + .contains("second.png")); + assertFalse(service.handle(get(COLLECTIONS)).body().contains("second.png")); + } + + /** A Full page of Collections carries the complete records, without restating the version. */ + @Test + void servesAFullPageOfCollections() { + OdpService service = new OdpService( + Catalog.template(List.of("en")), + StaticCatalog.create( + List.of(Catalog.offering("plant-1", "Rubber Plant")), List.of(pictured("plants")))); + + OdpHttpResponse response = service.handle(get(COLLECTIONS, query("representation", "full"))); + assertEquals(200, response.status()); + assertTrue(response.body().contains("second.png")); + assertEquals(1, occurrences(response.body(), "\"odp_version\"")); + } + + /** A search over Collections is validated as a page of Collections. */ + @Test + void validatesASearchOverCollections() { + Map endpoints = new EnumMap<>(StaticCatalog.create( + List.of(Catalog.offering("plant-1", "Rubber Plant")), List.of(Catalog.collection("plants")))); + endpoints.put( + OdpOperation.SEARCH_COLLECTIONS, + new OdpService.Endpoint( + AuthenticationRequirement.NOT_REQUIRED, + request -> new org.offeringprotocol.odp.core.Page<>( + null, + Odp.VERSION, + List.of(request.cursor() == null ? embedded("plants") : Catalog.collection("plants")), + null, + Map.of()))); + OdpService service = new OdpService(Catalog.template(List.of("en")), endpoints); + + assertEquals(200, service.handle(get(COLLECTIONS + "/search")).status()); + assertEquals( + 500, + service.handle(get(COLLECTIONS + "/search", query("cursor", "c1"))) + .status(), + "a Collection item that restates the version is not one this Service sends"); + } + + /** ERR-18: nesting is measured from the top-level value, and a brace inside a string is text. */ + @Test + void measuresNestingWithoutCountingBracesInsideStrings() { + OdpService service = Catalog.handling(request -> Catalog.offering("plant-1", "{[\\\"] not nesting")); + + assertEquals(200, service.handle(get(OFFERINGS + "/plant-1")).status()); + } + + /** A continuation with no query, and one whose query names something else, both read as absent. */ + @Test + void readsAContinuationThatNamesNoCursor() { + assertEquals( + 200, + Catalog.handling(request -> Catalog.page(List.of(), "/odp/offerings")) + .handle(get(OFFERINGS, query("cursor", "c1"))) + .status()); + assertEquals( + 200, + Catalog.handling(request -> Catalog.page(List.of(), "/odp/offerings?limit=2&=empty")) + .handle(get(OFFERINGS, query("cursor", "c1"))) + .status()); + } + + /** A failure with nothing to say carries no detail rather than the word null. */ + @Test + void publishesNoDetailWhenAFailureHasNoMessage() { + OdpHttpResponse response = Catalog.failing(new OdpServiceException(400, "INVALID_REQUEST", null)) + .handle(get(OFFERINGS + "/plant-1")); + + assertEquals(400, response.status()); + assertNull(OdpJson.read(response.body(), org.offeringprotocol.odp.core.ProblemDetails.class) + .detail()); + assertFalse(response.body().contains("null")); + } + + @Test + void replacesACodeThatIsNotThereAtAll() { + assertEquals( + "INVALID_REQUEST", + OdpJson.read( + Catalog.failing(new OdpServiceException(400, null, "nope")) + .handle(get(OFFERINGS + "/plant-1")) + .body(), + org.offeringprotocol.odp.core.ProblemDetails.class) + .code()); + } + + /** A range less specific than one already read does not unseat it. */ + @Test + void keepsTheMoreSpecificRangeItAlreadyRead() { + assertEquals( + 200, + Catalog.service() + .handle(Catalog.with(OFFERINGS, "Accept", "application/odp+json, */*;q=0.1")) + .status()); + } + + /** Every range a request states can be one it ruled out, and the default still answers. */ + @Test + void answersWhenEveryLanguageRangeWasRuledOut() { + assertEquals( + "en", + Catalog.multilingual("en", "fr") + .handle(Catalog.with(OFFERINGS, "Accept-Language", "fr;q=0")) + .headers() + .get("Content-Language")); + } + + /** A range that is nothing but parameters names no language at all. */ + @Test + void ignoresARangeThatNamesNoLanguage() { + assertEquals( + "en", + Catalog.multilingual("en", "fr") + .handle(Catalog.with(OFFERINGS, "Accept-Language", " ;q=0.5, x")) + .headers() + .get("Content-Language")); + } + + /** Lookup truncates to nothing rather than looping when a range begins with a separator. */ + @Test + void stopsTruncatingWhenNothingIsLeftOfTheRange() { + OdpService service = Catalog.multilingual("en", "fr"); + + assertEquals( + "en", + service.handle(Catalog.with(OFFERINGS, "Accept-Language", "-fr")) + .headers() + .get("Content-Language")); + assertEquals( + "en", + service.handle(Catalog.with(OFFERINGS, "Accept-Language", "x-foo")) + .headers() + .get("Content-Language")); + } + + /** A Collection path segment is held to the same identifier syntax as an Offering's. */ + @Test + void refusesACollectionSegmentThatIsNotAnIdentifier() { + assertEquals(404, Catalog.service().handle(get(COLLECTIONS + "/a b")).status()); + assertEquals( + 404, + Catalog.service().handle(get(COLLECTIONS + "/a b/offerings")).status()); + } + + /** A Collection may be called "search"; only the search path itself is reserved. */ + @Test + void readsSearchAsACollectionNameWhereAnIdentifierBelongs() { + OdpService service = new OdpService( + Catalog.template(List.of("en")), + StaticCatalog.create( + List.of(Catalog.offering("plant-1", "Rubber Plant")), List.of(Catalog.collection("search")))); + + assertEquals(200, service.handle(get(COLLECTIONS + "/search/offerings")).status()); + } + + /** SEARCH_OFFERINGS results are validated as Offerings like any other page. */ + @Test + void validatesTheOfferingsASearchReturned() { + OdpService searching = + Catalog.handling(request -> Catalog.page(List.of(Catalog.item("plant-1", "Rubber Plant")), null)); + assertEquals(200, searching.handle(get(OFFERINGS + "/search")).status()); + + OdpService acting = Catalog.handling(request -> Catalog.page( + List.of(new Offering( + null, + null, + "plant-1", + "Rubber Plant", + null, + null, + null, + null, + null, + null, + null, + null, + null, + List.of(Catalog.action()), + null, + Map.of())), + null)); + assertEquals(500, acting.handle(get(OFFERINGS + "/search")).status()); + } + + /** A failure that says when to come back says so whatever its status. */ + @Test + void carriesRetryAfterOnAnyStatusThatNamesOne() { + assertEquals( + "7", + Catalog.failing(OdpServiceException.retryable(409, "CONFLICT", "reindexing", 7)) + .handle(get(OFFERINGS + "/plant-1")) + .headers() + .get("Retry-After")); + } + + @Test + void replacesACodeLongerThanACodeCanBe() { + assertEquals( + "INVALID_REQUEST", + OdpJson.read( + Catalog.failing(new OdpServiceException(400, "A".repeat(65), "nope")) + .handle(get(OFFERINGS + "/plant-1")) + .body(), + org.offeringprotocol.odp.core.ProblemDetails.class) + .code()); + } + + /** Among equally specific ranges the highest quality wins. */ + @Test + void takesTheHighestQualityAmongEquallySpecificRanges() { + assertEquals( + 200, + Catalog.service() + .handle(Catalog.with(OFFERINGS, "Accept", "application/odp+json;q=0, application/odp+json;q=1")) + .status()); + } + + /** An Accept field that states no range at all states no preference. */ + @Test + void readsAnEmptyAcceptFieldAsNoPreference() { + assertEquals( + 200, + Catalog.service() + .handle(Catalog.with(OFFERINGS, "Accept", " , ")) + .status()); + } + + /** SVC-82 names two operations, and either one missing is the same refusal. */ + @Test + void refusesToStandUpWithoutEitherBaselineOperation() { + OdpService.Endpoint endpoint = new OdpService.Endpoint( + AuthenticationRequirement.NOT_REQUIRED, request -> Catalog.offering("plant-1", "Rubber Plant")); + + assertThrows( + IllegalArgumentException.class, + () -> new OdpService(Catalog.template(List.of("en")), Map.of(OdpOperation.LIST_OFFERINGS, endpoint))); + assertThrows( + IllegalArgumentException.class, + () -> new OdpService(Catalog.template(List.of("en")), Map.of(OdpOperation.GET_OFFERING, endpoint))); + } + + /** A query parameter present with no value at all reads as absent. */ + @Test + void readsAParameterWithNoValuesAsAbsent() { + OdpHttpRequest request = new OdpHttpRequest("GET", OFFERINGS, Map.of("limit", List.of()), Map.of(), null); + + assertNull(request.queryValue("limit")); + assertEquals(200, Catalog.service().handle(request).status()); + } + + private static Offering illustrated(String id) { + return new Offering( + null, + Odp.VERSION, + id, + "Rubber Plant", + null, + List.of( + new ResourceImage(null, null, "https://plants.example/first.png", null, null), + new ResourceImage(null, null, "https://plants.example/second.png", null, null)), + null, + null, + null, + null, + null, + null, + null, + null, + null, + Map.of()); + } + + private static Collection pictured(String id) { + return new Collection( + null, + Odp.VERSION, + id, + "Plants", + null, + List.of( + new ResourceImage(null, null, "https://plants.example/first.png", null, null), + new ResourceImage(null, null, "https://plants.example/second.png", null, null)), + null, + null, + null, + null, + null, + null, + Map.of()); + } + + private static Collection embedded(String id) { + return new Collection(null, null, id, "Plants", null, null, null, null, null, null, null, null, Map.of()); + } + + private static int occurrences(String value, String token) { + return value.split(java.util.regex.Pattern.quote(token), -1).length - 1; + } +} diff --git a/odp-service/src/test/java/org/offeringprotocol/odp/service/LanguageConformanceTest.java b/odp-service/src/test/java/org/offeringprotocol/odp/service/LanguageConformanceTest.java new file mode 100644 index 0000000..53fa2e1 --- /dev/null +++ b/odp-service/src/test/java/org/offeringprotocol/odp/service/LanguageConformanceTest.java @@ -0,0 +1,170 @@ +package org.offeringprotocol.odp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.offeringprotocol.odp.service.Catalog.OFFERINGS; +import static org.offeringprotocol.odp.service.Catalog.get; +import static org.offeringprotocol.odp.service.Catalog.with; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.offeringprotocol.odp.core.Odp; + +class LanguageConformanceTest { + private final OdpService service = Catalog.multilingual("en", "en-GB", "fr", "de-CH-1901"); + + /** SVC-60: a localized response says which language it is in and what it varies by. */ + @Test + void saysWhichLanguageItAnsweredIn() { + OdpHttpResponse response = service.handle(get(Odp.SERVICE_DOCUMENT_PATH)); + + assertEquals("en", response.headers().get("Content-Language")); + assertEquals("Accept-Language", response.headers().get("Vary")); + } + + @Test + void doesNotRelabelFixedMetadataOrStaticCatalogs() { + OdpService fixed = new OdpService( + Catalog.template(List.of("en", "fr")), + StaticCatalog.create(List.of(Catalog.offering("plant-1", "Rubber Plant")), List.of())); + for (String path : List.of(Odp.SERVICE_DOCUMENT_PATH, OFFERINGS, OFFERINGS + "/plant-1")) { + OdpHttpResponse english = fixed.handle(with(path, "Accept-Language", "en")); + OdpHttpResponse french = fixed.handle(with(path, "Accept-Language", "fr")); + assertEquals(200, french.status()); + assertEquals("en", french.headers().get("Content-Language")); + assertEquals(english.body(), french.body()); + assertEquals(english.headers().get("ETag"), french.headers().get("ETag")); + } + } + + @Test + void doesNotUseUnknownPageFieldsAsLanguageHeaders() { + OdpService empty = Catalog.handling( + request -> Map.of("odp_version", "1.0", "items", List.of(), "language", "untrusted\r\nheader")); + OdpHttpResponse response = empty.handle(get(OFFERINGS)); + assertEquals(200, response.status()); + assertEquals("en", response.headers().get("Content-Language")); + } + + /** + * SVC-58: RFC 4647 Lookup, so a range is shortened at each subtag until a tag matches. Lookup + * never crosses to a sibling, which is why de-CH-1902 falls back rather than finding de-CH-1901. + */ + @ParameterizedTest + @CsvSource({ + "fr,fr", + "FR,fr", + "fr-CA,fr", + "fr-CA-x-private,fr", + "en-GB,en-GB", + "en-GB-oxendict,en-GB", + "en-US,en", + "de-CH-1901,de-CH-1901", + "de-CH-1902,en", + "de-CH,en", + "*,en", + "zz,en", + "'',en" + }) + void selectsTheLanguageByLookup(String accepted, String expected) { + assertEquals( + expected, + service.handle(with(OFFERINGS, "Accept-Language", accepted)) + .headers() + .get("Content-Language")); + } + + /** A single-character subtag is not a language of its own, so Lookup steps past it. */ + @Test + void stepsPastASingletonSubtagWhileTruncating() { + assertEquals( + "fr", + service.handle(with(OFFERINGS, "Accept-Language", "fr-x-private")) + .headers() + .get("Content-Language")); + } + + /** The highest quality a request states is the one that is tried first. */ + @Test + void triesTheRangesInTheOrderTheRequestRankedThem() { + assertEquals( + "fr", + service.handle(with(OFFERINGS, "Accept-Language", "de;q=0.2, fr;q=0.9, en;q=0.5")) + .headers() + .get("Content-Language")); + assertEquals( + "en", + service.handle(with(OFFERINGS, "Accept-Language", "fr;q=0, en")) + .headers() + .get("Content-Language"), + "a range scored zero is a range the request ruled out"); + } + + @Test + void triesSpecificRangesBeforeWildcardDefault() { + assertEquals( + "fr", + service.handle(with(OFFERINGS, "Accept-Language", "*, fr;q=0.9")) + .headers() + .get("Content-Language")); + } + + @Test + void ignoresInvalidQualityWeights() { + for (String quality : List.of("NaN", "Infinity", "1.5", "-0.5", "nonsense")) { + assertEquals( + "en", + service.handle(with(OFFERINGS, "Accept-Language", "fr;q=" + quality + ", en;q=0.5")) + .headers() + .get("Content-Language"), + quality); + } + } + + /** SVC-59: a request whose ranges match nothing gets the default, never a refusal. */ + @Test + void answersAnUnmatchedRequestWithTheDefaultRepresentation() { + OdpHttpResponse response = service.handle(with(OFFERINGS, "Accept-Language", "ja, ko;q=0.8")); + + assertEquals(200, response.status()); + assertEquals("en", response.headers().get("Content-Language")); + } + + /** The handler is told which language was chosen, not left to negotiate the header again. */ + @Test + void handsTheSelectedLanguageToTheHandler() { + OdpService echoing = new OdpService( + Catalog.template(List.of("en", "fr")), + Map.of( + org.offeringprotocol.odp.core.OdpOperation.LIST_OFFERINGS, + new OdpService.Endpoint( + org.offeringprotocol.odp.core.AuthenticationRequirement.NOT_REQUIRED, + request -> Catalog.page(List.of(Catalog.item("plant-1", request.language())), null)), + org.offeringprotocol.odp.core.OdpOperation.GET_OFFERING, + new OdpService.Endpoint( + org.offeringprotocol.odp.core.AuthenticationRequirement.NOT_REQUIRED, + request -> Catalog.offering("plant-1", request.language())))); + + assertEquals( + "\"name\":\"fr\"", + extract(echoing.handle(with(OFFERINGS, "Accept-Language", "fr-CA")) + .body())); + assertEquals("\"name\":\"en\"", extract(echoing.handle(get(OFFERINGS)).body())); + } + + /** A header repeated across several lines is one field, as RFC 9110 5.2 says. */ + @Test + void readsAFieldSplitAcrossSeveralLines() { + OdpHttpRequest request = + new OdpHttpRequest("GET", OFFERINGS, Map.of(), Map.of("Accept-Language", List.of("ja", "fr")), null); + + assertEquals("fr", service.handle(request).headers().get("Content-Language")); + } + + private static String extract(String body) { + int at = body.indexOf("\"name\""); + return body.substring(at, body.indexOf('"', body.indexOf(':', at) + 2) + 1); + } +} 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 deleted file mode 100644 index eaff21b..0000000 --- a/odp-service/src/test/java/org/offeringprotocol/odp/service/OdpServiceTest.java +++ /dev/null @@ -1,253 +0,0 @@ -package org.offeringprotocol.odp.service; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; -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; -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 = OdpService.builder("Plant Store", "Plants for agents.", "en", "/odp") - .keywords(List.of("plants")) - .endpoints(StaticCatalog.create(List.of(offering), List.of())) - .protocols(new ServiceDocument.Protocols(List.of(new ServiceDocument.EnrollmentProtocol("aep")), null)) - .operationAuthentication(Map.of(OdpOperation.GET_OFFERING, AuthenticationRequirement.REQUIRED)) - .build(); - - 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(document.body().contains("\"authentication\":\"required\"")); - assertTrue(list.body().contains("Rubber Plant")); - assertEquals(2, occurrences(list.body(), "\"odp_version\":\"1.0\"")); - assertTrue(detail.body().contains("plant-1")); - assertEquals(1, occurrences(detail.body(), "\"odp_version\":\"1.0\"")); - - OdpHttpResponse terseList = service.handle(request("GET", "/odp/offerings", Map.of())); - assertEquals(1, occurrences(terseList.body(), "\"odp_version\":\"1.0\"")); - } - - @Test - void builderRequiresEndpoints() { - IllegalStateException exception = org.junit.jupiter.api.Assertions.assertThrows( - IllegalStateException.class, - () -> OdpService.builder("Plant Store", "Plants for agents.", "en", "/odp") - .build()); - assertEquals("endpoints must be configured", exception.getMessage()); - } - - @Test - void boundsRequestBodies() { - Offering offering = new Offering( - null, - Odp.VERSION, - "plant-1", - "Rubber Plant", - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - Map.of()); - OdpService service = new OdpService(template(), StaticCatalog.create(List.of(offering), List.of())); - - OdpHttpResponse boundary = - service.handle(new OdpHttpRequest("GET", "/.well-known/odp", Map.of(), Map.of(), "a".repeat(65_536))); - OdpHttpResponse exceeded = - service.handle(new OdpHttpRequest("GET", "/.well-known/odp", Map.of(), Map.of(), "a".repeat(65_537))); - - assertEquals(200, boundary.status()); - assertEquals(413, exceeded.status()); - assertTrue(exceeded.body().contains("REQUEST_TOO_LARGE")); - } - - @Test - void staticCatalogSnapshotsCallerCollections() { - Offering original = offering("plant-1", "Rubber Plant"); - List offerings = new ArrayList<>(List.of(original)); - OdpService service = new OdpService(template(), StaticCatalog.create(offerings, List.of())); - - offerings.add(offering("plant-2", "Snake Plant")); - - OdpHttpResponse response = - service.handle(request("GET", "/odp/offerings", Map.of("representation", List.of("full")))); - assertTrue(response.body().contains("Rubber Plant")); - assertFalse(response.body().contains("Snake Plant")); - } - - @Test - void routesSearchPostAndContinuationGetToTheSameHandler() { - Map endpoints = - new java.util.EnumMap<>(StaticCatalog.create(List.of(offering("plant-1", "Rubber Plant")), List.of())); - endpoints.put( - OdpOperation.SEARCH_OFFERINGS, - new OdpService.Endpoint(AuthenticationRequirement.NOT_REQUIRED, request -> { - String name = request.cursor() == null ? "Initial result" : "Continued result"; - return new Page<>(null, Odp.VERSION, List.of(offering("result", name)), null, Map.of()); - })); - OdpService service = new OdpService(template(), endpoints); - - OdpHttpResponse initial = service.handle(request("POST", "/odp/offerings/search", Map.of())); - OdpHttpResponse continuation = - service.handle(request("GET", "/odp/offerings/search", Map.of("cursor", List.of("opaque")))); - - assertTrue(initial.body().contains("Initial result")); - assertTrue(continuation.body().contains("Continued result")); - } - - @Test - void rejectsInvalidCatalogResponses() { - Map endpoints = new java.util.EnumMap<>(StaticCatalog.create( - List.of(offering("plant-1", "Rubber Plant")), List.of(collection("plants", null)))); - endpoints.put( - OdpOperation.GET_OFFERING, - new OdpService.Endpoint(AuthenticationRequirement.NOT_REQUIRED, request -> Map.of("name", "Invalid"))); - OdpService service = new OdpService(template(), endpoints); - - OdpHttpResponse response = service.handle(request("GET", "/odp/offerings/plant-1", Map.of())); - - assertEquals(500, response.status()); - assertTrue(response.body().contains("INTERNAL_ERROR")); - - endpoints.put( - OdpOperation.GET_OFFERING, - new OdpService.Endpoint(AuthenticationRequirement.NOT_REQUIRED, request -> offeringWithAction())); - service = new OdpService(template(), endpoints); - assertEquals( - 500, - service.handle(request("GET", "/odp/offerings/plant-1", Map.of())) - .status()); - - endpoints.put( - OdpOperation.GET_OFFERING, - new OdpService.Endpoint( - AuthenticationRequirement.NOT_REQUIRED, - request -> offering("plant-1", "Rubber Plant", List.of("/description")))); - endpoints.put( - OdpOperation.GET_COLLECTION, - new OdpService.Endpoint( - AuthenticationRequirement.NOT_REQUIRED, - request -> collection("plants", List.of("/description")))); - service = new OdpService(template(), endpoints); - assertEquals( - 500, - service.handle(request("GET", "/odp/offerings/plant-1", Map.of("representation", List.of("full")))) - .status()); - assertEquals( - 500, - service.handle(request("GET", "/odp/collections/plants", Map.of("representation", List.of("full")))) - .status()); - } - - private static Offering offering(String id, String name) { - return offering(id, name, null); - } - - private static Offering offering(String id, String name, List detailFields) { - return new Offering( - null, - Odp.VERSION, - id, - name, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - detailFields, - Map.of()); - } - - private static Offering offeringWithAction() { - Offering.Action action = new Offering.Action( - AuthenticationRequirement.NOT_REQUIRED, - "purchase", - "purchase", - null, - new Offering.HttpTarget("/purchase", "POST", null, null), - null); - Offering value = offering("plant-1", "Rubber Plant"); - return new Offering( - value.authExpands(), - value.odpVersion(), - value.id(), - value.name(), - value.description(), - value.images(), - value.language(), - value.localizations(), - value.webUrl(), - value.collectionIds(), - value.price(), - value.schema(), - value.attributes(), - List.of(action), - value.detailFields(), - value.additional()); - } - - private static Collection collection(String id, List detailFields) { - return new Collection( - null, Odp.VERSION, id, "Plants", null, null, null, null, null, null, null, detailFields, Map.of()); - } - - private static ServiceDocument template() { - return ServiceDocument.builder( - "Plant Store", "Plants for agents.", "en", new ServiceDocument.Http("/odp", null)) - .keywords(List.of("plants")) - .operations(List.of()) - .build(); - } - - private static OdpHttpRequest request(String method, String path, Map> query) { - return new OdpHttpRequest(method, path, query, Map.of(), null); - } - - private static int occurrences(String value, String token) { - return value.split(java.util.regex.Pattern.quote(token), -1).length - 1; - } -} diff --git a/odp-service/src/test/java/org/offeringprotocol/odp/service/ProblemConformanceTest.java b/odp-service/src/test/java/org/offeringprotocol/odp/service/ProblemConformanceTest.java new file mode 100644 index 0000000..599e930 --- /dev/null +++ b/odp-service/src/test/java/org/offeringprotocol/odp/service/ProblemConformanceTest.java @@ -0,0 +1,178 @@ +package org.offeringprotocol.odp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.offeringprotocol.odp.service.Catalog.OFFERINGS; +import static org.offeringprotocol.odp.service.Catalog.get; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.offeringprotocol.odp.core.OdpJson; +import org.offeringprotocol.odp.core.ProblemDetails; + +class ProblemConformanceTest { + /** ERR-01/02/03/07: every failure is a Problem Details document whose members agree. */ + @Test + void describesEveryFailureAsProblemDetails() { + ProblemDetails problem = problem(Catalog.service().handle(get(OFFERINGS + "/absent"))); + + assertEquals("https://offeringprotocol.org/problems/not-found", problem.type()); + assertEquals("Not found", problem.title()); + assertEquals(404, problem.status()); + assertEquals("NOT_FOUND", problem.code()); + assertEquals("ODP resource not found", problem.detail()); + assertNull(problem.instance()); + } + + /** ERR-04: title stays within 128 code points and detail within 2048, however long the message. */ + @Test + void boundsTheStringsAProblemCarries() { + ProblemDetails problem = + problem(Catalog.failing(new OdpServiceException(400, "INVALID_REQUEST", "d".repeat(5_000))) + .handle(get(OFFERINGS + "/plant-1"))); + + assertEquals(2_048, problem.detail().codePointCount(0, problem.detail().length())); + assertTrue(problem.detail().endsWith("…")); + assertTrue(problem.title().codePointCount(0, problem.title().length()) <= 128); + } + + /** ERR-21: a Problem Details response stays within 16,384 bytes even at its widest. */ + @Test + void keepsAProblemWithinItsByteLimit() { + OdpHttpResponse response = Catalog.failing( + new OdpServiceException(400, "I" + "X".repeat(63), "🪴".repeat(5_000))) + .handle(get(OFFERINGS + "/plant-1")); + String detail = problem(response).detail(); + + assertTrue(response.body().getBytes(java.nio.charset.StandardCharsets.UTF_8).length <= 16_384); + assertEquals(2_048, detail.codePointCount(0, detail.length())); + } + + /** ERR-06: a code ODP could not carry is replaced by one that matches the status. */ + @ParameterizedTest + @ValueSource(strings = {"not a code!", "lowercase", "9LEADING", "", "WITH-HYPHEN", "TRAILING "}) + void replacesACodeItCouldNotCarry(String code) { + assertEquals( + "INVALID_REQUEST", + problem(Catalog.failing(new OdpServiceException(400, code, "nope")) + .handle(get(OFFERINGS + "/plant-1"))) + .code(), + code); + assertEquals( + "INTERNAL_ERROR", + problem(Catalog.failing(new OdpServiceException(503, code, "nope")) + .handle(get(OFFERINGS + "/plant-1"))) + .code(), + code); + } + + @Test + void keepsACodeItCanCarry() { + assertEquals( + "RATE_LIMITED9_X", + problem(Catalog.failing(new OdpServiceException(400, "RATE_LIMITED9_X", "nope")) + .handle(get(OFFERINGS + "/plant-1"))) + .code()); + } + + /** A status outside the range a problem can describe is reported as the failure it really is. */ + @Test + void reportsAStatusThatIsNotAFailureAsOne() { + assertEquals( + 500, + Catalog.failing(new OdpServiceException(200, "NOT_FOUND", "nope")) + .handle(get(OFFERINGS + "/plant-1")) + .status()); + assertEquals( + 500, + Catalog.failing(new OdpServiceException(700, "NOT_FOUND", "nope")) + .handle(get(OFFERINGS + "/plant-1")) + .status()); + } + + /** ERR-32/33: a caller told to come back later is told when. */ + @Test + void saysWhenToComeBack() { + assertEquals( + "60", + Catalog.failing(new OdpServiceException(429, "RATE_LIMITED", "slow down")) + .handle(get(OFFERINGS + "/plant-1")) + .headers() + .get("Retry-After")); + assertEquals( + "5", + Catalog.failing(OdpServiceException.retryable(503, "UNAVAILABLE", "restarting", 5)) + .handle(get(OFFERINGS + "/plant-1")) + .headers() + .get("Retry-After")); + assertEquals( + "60", + Catalog.failing(new OdpServiceException(503, "UNAVAILABLE", "restarting")) + .handle(get(OFFERINGS + "/plant-1")) + .headers() + .get("Retry-After")); + assertNull( + Catalog.service().handle(get(OFFERINGS + "/absent")).headers().get("Retry-After")); + } + + /** + * A failure this Service did not ask for says nothing about the Service. SEC-33 and PRV-06 turn + * on that: an exception message can name a query, a table, or a host an Agent may not learn. + */ + @Test + void publishesNothingOfAFailureItDidNotAskFor() { + for (RuntimeException thrown : List.of( + new IllegalArgumentException("SELECT * FROM secrets WHERE tenant = 7"), + new IllegalStateException("connection pool 10.1.2.3 exhausted"), + new NullPointerException("catalog.lookup(...) is null"))) { + OdpHttpResponse response = Catalog.failing(thrown).handle(get(OFFERINGS + "/plant-1")); + + assertEquals(500, response.status()); + assertEquals("ODP request could not be processed", problem(response).detail()); + assertFalse(response.body().contains("secrets")); + assertFalse(response.body().contains("10.1.2.3")); + assertFalse(response.body().contains("catalog.lookup")); + } + } + + /** A failure a handler did choose to publish reaches the caller as the handler wrote it. */ + @Test + void publishesTheFailureAHandlerChoseTo() { + OdpHttpResponse response = Catalog.failing( + new OdpServiceException(400, "INVALID_REQUEST", "filters.price must be a decimal string")) + .handle(get(OFFERINGS + "/plant-1")); + + assertEquals(400, response.status()); + assertEquals("filters.price must be a decimal string", problem(response).detail()); + } + + /** A cause is kept for the Service's own logs and never serialized into the response. */ + @Test + void keepsTheCauseOutOfTheResponse() { + OdpServiceException thrown = new OdpServiceException( + 400, "INVALID_REQUEST", "cursor is unreadable", new IllegalStateException("row 9 of shard 3")); + OdpHttpResponse response = Catalog.failing(thrown).handle(get(OFFERINGS + "/plant-1")); + + assertEquals("row 9 of shard 3", thrown.getCause().getMessage()); + assertFalse(response.body().contains("shard")); + } + + @Test + void carriesTheAllowFieldOnlyOnAMethodRefusal() { + OdpServiceException refusal = new OdpServiceException(404, "NOT_FOUND", "nope"); + + assertNull(refusal.allow()); + assertNull(refusal.retryAfter()); + assertEquals(404, refusal.status()); + assertEquals("NOT_FOUND", refusal.code()); + } + + private static ProblemDetails problem(OdpHttpResponse response) { + assertEquals("application/problem+json", response.headers().get("Content-Type")); + return OdpJson.read(response.body(), ProblemDetails.class); + } +} diff --git a/odp-service/src/test/java/org/offeringprotocol/odp/service/RepresentationConformanceTest.java b/odp-service/src/test/java/org/offeringprotocol/odp/service/RepresentationConformanceTest.java new file mode 100644 index 0000000..c5773c6 --- /dev/null +++ b/odp-service/src/test/java/org/offeringprotocol/odp/service/RepresentationConformanceTest.java @@ -0,0 +1,249 @@ +package org.offeringprotocol.odp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.offeringprotocol.odp.service.Catalog.COLLECTIONS; +import static org.offeringprotocol.odp.service.Catalog.OFFERINGS; +import static org.offeringprotocol.odp.service.Catalog.get; +import static org.offeringprotocol.odp.service.Catalog.query; + +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.Page; + +class RepresentationConformanceTest { + private final OdpService service = Catalog.service(); + + /** VER-01: every Top-Level Document declares the version that governs it. */ + @Test + void declaresTheVersionOfEveryDocumentItSends() { + for (String path : List.of(Odp.SERVICE_DOCUMENT_PATH, OFFERINGS, OFFERINGS + "/plant-1", COLLECTIONS)) { + assertEquals(1, occurrences(service.handle(get(path)).body(), "\"odp_version\":\"1.0\""), path); + } + } + + /** VER-03/VER-04: an item inherits the version of the document carrying it, and never restates it. */ + @Test + void refusesAPageWhoseItemsRestateTheVersion() { + OdpService restating = + Catalog.handling(request -> Catalog.page(List.of(Catalog.offering("plant-1", "Rubber Plant")), null)); + + OdpHttpResponse response = restating.handle(get(OFFERINGS)); + assertEquals(500, response.status()); + assertTrue(response.body().contains("INTERNAL_ERROR")); + + OdpService inheriting = + Catalog.handling(request -> Catalog.page(List.of(Catalog.item("plant-1", "Rubber Plant")), null)); + assertEquals(200, inheriting.handle(get(OFFERINGS)).status()); + } + + /** REP-12: a Full Representation contains the fields themselves, not a list of what is missing. */ + @Test + void refusesDetailFieldsInAFullRepresentation() { + OdpService offering = + Catalog.handling(request -> Catalog.offering("plant-1", "Rubber Plant", null, List.of("/price"))); + assertEquals( + 500, + offering.handle(get(OFFERINGS + "/plant-1", query("representation", "full"))) + .status()); + assertEquals( + 200, + offering.handle(get(OFFERINGS + "/plant-1", query("representation", "terse"))) + .status(), + "REP-11 allows them in a Terse one"); + assertEquals(500, offering.handle(get(OFFERINGS + "/plant-1")).status()); + + OdpService collecting = withCollection(List.of("/description")); + assertEquals( + 500, + collecting + .handle(get(COLLECTIONS + "/plants", query("representation", "full"))) + .status()); + assertEquals( + 200, + collecting + .handle(get(COLLECTIONS + "/plants", query("representation", "terse"))) + .status()); + assertEquals(500, collecting.handle(get(COLLECTIONS + "/plants")).status()); + } + + /** OFR-55: a Terse Offering advertises its Actions through detail_fields, it does not carry them. */ + @Test + void refusesActionsInATerseOffering() { + OdpService acting = Catalog.handling( + request -> Catalog.offering("plant-1", "Rubber Plant", List.of(Catalog.action()), null)); + + assertEquals( + 500, + acting.handle(get(OFFERINGS + "/plant-1", query("representation", "terse"))) + .status()); + assertEquals( + 200, + acting.handle(get(OFFERINGS + "/plant-1", query("representation", "full"))) + .status()); + } + + /** Individual retrieval defaults to Full; list retrieval defaults to Terse. */ + @Test + void servesTheRepresentationThatWasAskedFor() { + OdpService acting = new OdpService( + Catalog.template(List.of("en")), + StaticCatalog.create( + List.of(Catalog.offering("plant-1", "Rubber Plant", List.of(Catalog.action()), null)), + List.of())); + + assertFalse(acting.handle(get(OFFERINGS + "/plant-1", query("representation", "terse"))) + .body() + .contains("actions")); + assertTrue(acting.handle(get(OFFERINGS + "/plant-1")).body().contains("actions")); + assertFalse(acting.handle(get(OFFERINGS)).body().contains("\"actions\":")); + assertTrue(acting.handle(get(OFFERINGS + "/plant-1", query("representation", "full"))) + .body() + .contains("actions")); + } + + /** ERR-19: a document this Service would refuse from anybody else is not one it sends. */ + @Test + void refusesToSendADocumentPastItsLimits() { + OdpService enormous = Catalog.handling(request -> Map.of("filler", "f".repeat(600_000))); + OdpHttpResponse response = enormous.handle(get(OFFERINGS + "/plant-1")); + + assertEquals(500, response.status()); + assertTrue(response.body().contains("ODP response exceeds its limits")); + assertFalse(response.body().contains("fff")); + } + + @Test + void refusesToSendADocumentNestedPastItsLimit() { + OdpService deep = Catalog.handling(request -> nest(20)); + assertEquals(500, deep.handle(get(OFFERINGS + "/plant-1")).status()); + assertEquals( + 500, + Catalog.handling(request -> nest(3)) + .handle(get(OFFERINGS + "/plant-1")) + .status(), + "shallow enough to measure, but still not an Offering"); + } + + /** PAG-06/07: a continuation is a bounded reference back to this Service. */ + @Test + void refusesAContinuationItCouldNotFollowItself() { + for (String next : + List.of("https://elsewhere.example/odp/offerings", "//elsewhere.example/x", "offerings?c=2")) { + OdpService wandering = Catalog.handling(request -> Catalog.page(List.of(), next)); + assertEquals(500, wandering.handle(get(OFFERINGS)).status(), next); + } + OdpService verbose = + Catalog.handling(request -> Catalog.page(List.of(), "/odp/offerings?cursor=" + "c".repeat(2_048))); + assertEquals(500, verbose.handle(get(OFFERINGS)).status()); + + OdpService sane = Catalog.handling(request -> Catalog.page(List.of(), "/odp/offerings?cursor=c2")); + assertEquals(200, sane.handle(get(OFFERINGS)).status()); + } + + @Test + void validatesContinuationsAgainstTheConfiguredOrigin() { + for (String next : List.of( + "/odp/offerings?cursor=c2", + "https://store.example/odp/offerings?cursor=c2", + "https://store.example:443/odp/offerings?cursor=c2")) { + OdpService configured = withOrigin(next); + assertEquals(200, configured.handle(get(OFFERINGS)).status(), next); + } + for (String next : List.of( + "https://elsewhere.example/odp/offerings", + "//store.example/odp/offerings", + "/odp/offerings#fragment", + "/odp/offerings?cursor=é", + "https://user@store.example/odp/offerings", + "https://store.example:444/odp/offerings", + "/bad%escape")) { + assertEquals(500, withOrigin(next).handle(get(OFFERINGS)).status(), next); + } + for (String origin : List.of( + "https://store.example/path", + "https://store.example?x=1", + "https://store.example#x", + "https://user@store.example", + "http://store.example")) { + assertThrows( + IllegalArgumentException.class, + () -> OdpService.builder("Store", "Catalog", "en", "/odp").origin(origin)); + } + } + + private static OdpService withOrigin(String next) { + OdpService.Endpoint endpoint = new OdpService.Endpoint( + org.offeringprotocol.odp.core.AuthenticationRequirement.NOT_REQUIRED, + request -> Catalog.page(List.of(), next)); + return OdpService.builder("Store", "Catalog", "en", "/odp") + .origin("https://STORE.example:443/") + .endpoints(Map.of( + org.offeringprotocol.odp.core.OdpOperation.LIST_OFFERINGS, + endpoint, + org.offeringprotocol.odp.core.OdpOperation.GET_OFFERING, + endpoint)) + .build(); + } + + /** PAG-11: a continuation that hands back the cursor it was given never ends. */ + @Test + void refusesAContinuationThatDoesNotAdvance() { + OdpService stuck = Catalog.handling(request -> Catalog.page(List.of(), "/odp/offerings?cursor=c1")); + + assertEquals(500, stuck.handle(get(OFFERINGS, query("cursor", "c1"))).status()); + assertEquals(200, stuck.handle(get(OFFERINGS, query("cursor", "c0"))).status()); + assertEquals(200, stuck.handle(get(OFFERINGS)).status()); + } + + /** A page whose next is not a reference at all is not a page this Service can send. */ + @Test + void refusesANextThatIsNotAReference() { + OdpService numbered = Catalog.handling(request -> new Page<>( + null, + Odp.VERSION, + List.of(), + null, + Map.of("next", org.offeringprotocol.odp.core.OdpJson.parseTree("7")))); + + assertEquals(500, numbered.handle(get(OFFERINGS)).status()); + } + + /** A handler with nothing to return is answered as an absent resource, not an empty one. */ + @Test + void answersAnAbsentResourceRatherThanSerializingNothing() { + OdpService empty = Catalog.handling(request -> null); + OdpHttpResponse response = empty.handle(get(OFFERINGS + "/plant-1")); + + assertEquals(404, response.status()); + assertTrue(response.body().contains("NOT_FOUND")); + } + + private OdpService withCollection(List detailFields) { + Map endpoints = + new java.util.EnumMap<>(StaticCatalog.create( + List.of(Catalog.offering("plant-1", "Rubber Plant")), List.of(Catalog.collection("plants")))); + endpoints.put( + org.offeringprotocol.odp.core.OdpOperation.GET_COLLECTION, + new OdpService.Endpoint( + org.offeringprotocol.odp.core.AuthenticationRequirement.NOT_REQUIRED, + request -> Catalog.collection("plants", detailFields))); + return new OdpService(Catalog.template(List.of("en")), endpoints); + } + + private static Map nest(int levels) { + Map value = Map.of("odp_version", Odp.VERSION, "id", "plant-1", "name", "Rubber Plant"); + for (int index = 0; index < levels; index++) { + value = Map.of("nested", value); + } + return value; + } + + private static int occurrences(String value, String token) { + return value.split(java.util.regex.Pattern.quote(token), -1).length - 1; + } +} diff --git a/odp-service/src/test/java/org/offeringprotocol/odp/service/SearchValidationTest.java b/odp-service/src/test/java/org/offeringprotocol/odp/service/SearchValidationTest.java new file mode 100644 index 0000000..c85751f --- /dev/null +++ b/odp-service/src/test/java/org/offeringprotocol/odp/service/SearchValidationTest.java @@ -0,0 +1,53 @@ +package org.offeringprotocol.odp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.offeringprotocol.odp.core.AuthenticationRequirement; +import org.offeringprotocol.odp.core.OdpJson; +import org.offeringprotocol.odp.core.OdpOperation; +import org.offeringprotocol.odp.core.SearchCatalog; + +class SearchValidationTest { + @Test + void validatesBeforeCallingHandlerAndChecksReturnedRefinements() { + SearchCatalog catalog = new SearchCatalog(List.of(OdpJson.parseFilterDefinition(""" + {"id":"value","title":"Value","description":"Search value","type":"integer", + "operators":["eq"],"refinable":true} + """)), List.of()); + AtomicInteger calls = new AtomicInteger(); + var endpoints = new EnumMap(StaticCatalog.create(List.of(), List.of())); + endpoints.put( + OdpOperation.SEARCH_OFFERINGS, + new OdpService.Endpoint(AuthenticationRequirement.NOT_REQUIRED, request -> { + calls.incrementAndGet(); + return OdpJson.parseTree( + "{\"odp_version\":\"1.0\",\"items\":[],\"refinements\":[{\"filter_id\":\"value\",\"values\":[{\"value\":\"wrong\",\"count\":1}]}]}"); + })); + var service = OdpService.builder("Service", "Description", "en", "/odp") + .endpoints(endpoints) + .searchCatalog(request -> catalog) + .build(); + String bad = "{\"odp_version\":\"1.0\",\"filters\":[{\"id\":\"value\",\"operator\":\"eq\",\"value\":1.5}]}"; + assertEquals(400, service.handle(post(bad)).status()); + assertEquals(0, calls.get()); + assertEquals( + 500, + service.handle(post("{\"odp_version\":\"1.0\",\"query\":\"test\",\"refinements\":[\"value\"]}")) + .status()); + assertEquals(1, calls.get()); + } + + private static OdpHttpRequest post(String body) { + return new OdpHttpRequest( + "POST", + "/odp/offerings/search", + Map.of(), + Map.of("Content-Type", List.of("application/odp+json")), + body); + } +} diff --git a/odp-service/src/test/java/org/offeringprotocol/odp/service/ServiceDocumentTest.java b/odp-service/src/test/java/org/offeringprotocol/odp/service/ServiceDocumentTest.java new file mode 100644 index 0000000..1bee8b0 --- /dev/null +++ b/odp-service/src/test/java/org/offeringprotocol/odp/service/ServiceDocumentTest.java @@ -0,0 +1,203 @@ +package org.offeringprotocol.odp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.offeringprotocol.odp.service.Catalog.get; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +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.SearchCapabilities; +import org.offeringprotocol.odp.core.ServiceDocument; + +class ServiceDocumentTest { + /** SVC-82: a Service that cannot list and retrieve Offerings is not a Service. */ + @Test + void refusesToStandUpWithoutTheBaselineOperations() { + assertEquals( + "ODP Services require list-offerings and get-offering handlers", + assertThrows( + IllegalArgumentException.class, + () -> new OdpService(Catalog.template(List.of("en")), Map.of())) + .getMessage()); + assertThrows(NullPointerException.class, () -> new OdpService(null, Map.of())); + assertThrows(NullPointerException.class, () -> new OdpService(Catalog.template(List.of("en")), null)); + assertEquals( + "endpoints must be configured", + assertThrows( + IllegalStateException.class, + () -> OdpService.builder("Plant Store", "Plants.", "en", "/odp") + .build()) + .getMessage()); + } + + /** ROLE-04/SVC-77: the advertised operations are exactly the handlers that were registered. */ + @Test + void advertisesExactlyTheOperationsItImplements() { + ServiceDocument document = Catalog.service().document(); + + assertEquals( + List.of( + OdpOperation.GET_COLLECTION, + OdpOperation.GET_OFFERING, + OdpOperation.LIST_COLLECTION_OFFERINGS, + OdpOperation.LIST_COLLECTIONS, + OdpOperation.LIST_OFFERINGS), + document.operations().stream() + .map(org.offeringprotocol.odp.core.OperationDescriptor::name) + .toList()); + assertEquals(Odp.VERSION, document.odpVersion()); + } + + /** SVC-80: an operation the Agent must authenticate for needs an enrollment protocol to do it. */ + @Test + void carriesThePerOperationAuthenticationItWasGiven() { + OdpService service = OdpService.builder("Plant Store", "Plants for agents.", "en", "/odp") + .endpoints(StaticCatalog.create(List.of(Catalog.offering("plant-1", "Rubber Plant")), List.of())) + .protocols(new ServiceDocument.Protocols(List.of(new ServiceDocument.EnrollmentProtocol("aep")), null)) + .operationAuthentication(Map.of(OdpOperation.GET_OFFERING, AuthenticationRequirement.REQUIRED)) + .build(); + + assertTrue(service.handle(get(Odp.SERVICE_DOCUMENT_PATH)).body().contains("\"authentication\":\"required\"")); + } + + @Test + void refusesAnAuthenticationRuleForAnOperationItDoesNotPublish() { + assertEquals( + "authentication requirement refers to an unconfigured operation: search-offerings", + assertThrows( + IllegalArgumentException.class, + () -> OdpService.builder("Plant Store", "Plants.", "en", "/odp") + .endpoints(StaticCatalog.create( + List.of(Catalog.offering("plant-1", "Rubber Plant")), List.of())) + .operationAuthentication(Map.of( + OdpOperation.SEARCH_OFFERINGS, AuthenticationRequirement.REQUIRED)) + .build()) + .getMessage()); + } + + /** SVC-83/84: the Service Document this builder produces is one the protocol would accept. */ + @Test + void producesADocumentTheProtocolAccepts() { + OdpService service = OdpService.builder("Plant Store", "Plants for agents.", "en", "/odp/") + .documentationUrl("https://plants.example/docs") + .localizations(List.of("en", "fr")) + .keywords(List.of("plants", "houseplants")) + .branding(new ServiceDocument.Branding( + new ServiceDocument.BrandingImage("https://plants.example/icon.png", "image/png"), + new ServiceDocument.BrandingImage("https://plants.example/logo.png", "image/png"))) + .mcp(List.of(new ServiceDocument.McpEndpoint( + null, "Plants", "streamable-http", "https://plants.example/mcp"))) + .openApi(new ServiceDocument.OpenApi("https://plants.example/openapi.json")) + .paymentOrigins(List.of("https://pay.example")) + .protocols(new ServiceDocument.Protocols(List.of(new ServiceDocument.EnrollmentProtocol("aep")), null)) + .statusUrl("https://plants.example/status") + .supportUrl("https://plants.example/support") + .websiteUrl("https://plants.example") + .additional(Map.of("x_region", OdpJson.parseTree("\"eu\""))) + .endpoints(StaticCatalog.create(List.of(Catalog.offering("plant-1", "Rubber Plant")), List.of())) + .build(); + + String body = service.handle(get(Odp.SERVICE_DOCUMENT_PATH)).body(); + ServiceDocument parsed = OdpJson.parseServiceDocument(body); + + assertEquals("Plant Store", parsed.name()); + assertEquals("/odp/", parsed.http().endpointBase()); + assertEquals(List.of("en", "fr"), parsed.localizations()); + assertEquals("eu", parsed.additional().get("x_region").asString()); + assertNull(parsed.searchCapabilities()); + assertEquals(200, service.handle(get("/odp/offerings")).status(), "a trailing slash in the base is trimmed"); + } + + /** FLT-49: search capabilities belong to a Service that advertises the search operation. */ + @Test + void refusesSearchCapabilitiesWithoutTheSearchOperation() { + assertThrows( + RuntimeException.class, + () -> OdpService.builder("Plant Store", "Plants.", "en", "/odp") + .searchCapabilities(new SearchCapabilities(null, null, Map.of())) + .endpoints( + StaticCatalog.create(List.of(Catalog.offering("plant-1", "Rubber Plant")), List.of())) + .build()); + } + + /** A caller cannot reach into the document this Service serves. */ + @Test + void handsOutACopyOfItsDocument() { + OdpService service = Catalog.service(); + + assertNotSame(service.document(), service.document()); + assertEquals(service.document(), service.document()); + } + + @Test + void refusesARequestItWasNotGiven() { + assertThrows(NullPointerException.class, () -> Catalog.service().handle(null)); + } + + /** SVC-02: the Service Document is served whatever the catalog operations require. */ + @Test + void servesTheDocumentWithoutTheAccessPolicyOfItsOperations() { + OdpService guarded = OdpService.builder("Plant Store", "Plants for agents.", "en", "/odp") + .endpoints(StaticCatalog.create(List.of(Catalog.offering("plant-1", "Rubber Plant")), List.of())) + .protocols(new ServiceDocument.Protocols(List.of(new ServiceDocument.EnrollmentProtocol("aep")), null)) + .operationAuthentication(Map.of( + OdpOperation.GET_OFFERING, + AuthenticationRequirement.REQUIRED, + OdpOperation.LIST_OFFERINGS, + AuthenticationRequirement.REQUIRED)) + .build(); + + assertEquals(200, guarded.handle(get(Odp.SERVICE_DOCUMENT_PATH)).status()); + } + + /** An endpoint is a requirement and a handler, and neither of them is optional. */ + @Test + void refusesAnIncompleteEndpoint() { + assertThrows( + NullPointerException.class, + () -> new OdpService.Endpoint(null, request -> Catalog.offering("plant-1", "Rubber Plant"))); + assertThrows( + NullPointerException.class, + () -> new OdpService.Endpoint(AuthenticationRequirement.NOT_REQUIRED, null)); + } + + /** A request record is a snapshot of what arrived, not a window onto the caller's maps. */ + @Test + void snapshotsTheRequestItWasGiven() { + Map> query = new java.util.HashMap<>(Map.of("limit", List.of("2"))); + OdpHttpRequest request = new OdpHttpRequest("GET", "/odp/offerings", query, null, null); + query.put("cursor", List.of("c1")); + + assertEquals(Map.of("limit", List.of("2")), request.query()); + assertEquals(Map.of(), request.headers()); + assertEquals("2", request.queryValue("limit")); + assertNull(request.queryValue("cursor")); + assertNull(request.headerValue("Accept")); + assertThrows(UnsupportedOperationException.class, () -> request.query().clear()); + } + + @Test + void readsAHeaderWithoutRegardToCase() { + OdpHttpRequest request = + new OdpHttpRequest("GET", "/odp/offerings", Map.of(), Map.of("aCCePt-LaNguaGe", List.of("fr")), null); + + assertEquals("fr", request.headerValue("Accept-Language")); + assertEquals("fr", request.headerValue("ACCEPT-LANGUAGE")); + } + + @Test + void readsAResponseWithNoHeadersOrBodyAsAnEmptyOne() { + OdpHttpResponse response = new OdpHttpResponse(204, null, null); + + assertEquals(Map.of(), response.headers()); + assertEquals("", response.body()); + } +} diff --git a/odp-service/src/test/java/org/offeringprotocol/odp/service/StaticCatalogTest.java b/odp-service/src/test/java/org/offeringprotocol/odp/service/StaticCatalogTest.java new file mode 100644 index 0000000..d821cd6 --- /dev/null +++ b/odp-service/src/test/java/org/offeringprotocol/odp/service/StaticCatalogTest.java @@ -0,0 +1,409 @@ +package org.offeringprotocol.odp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.offeringprotocol.odp.service.Catalog.COLLECTIONS; +import static org.offeringprotocol.odp.service.Catalog.OFFERINGS; +import static org.offeringprotocol.odp.service.Catalog.get; +import static org.offeringprotocol.odp.service.Catalog.query; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.offeringprotocol.odp.core.Collection; +import org.offeringprotocol.odp.core.Odp; +import org.offeringprotocol.odp.core.OdpJson; +import org.offeringprotocol.odp.core.OdpOperation; +import org.offeringprotocol.odp.core.Offering; +import org.offeringprotocol.odp.core.Page; + +class StaticCatalogTest { + private static final byte[] KEY = "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8); + + @Test + void validatesTheCompleteStaticHierarchyAndMembership() { + List chain = new ArrayList<>(); + for (int index = 0; index <= 32; index++) { + chain.add( + OdpJson.parseCollection("{\"odp_version\":\"1.0\",\"id\":\"c" + index + "\",\"name\":\"Collection\"" + + (index == 0 ? "" : ",\"parent_ids\":[\"c" + (index - 1) + "\"]") + "}")); + } + assertEquals(5, StaticCatalog.create(List.of(), chain).size()); + java.util.Collections.reverse(chain); + assertEquals(5, StaticCatalog.create(List.of(), chain).size()); + chain.add(OdpJson.parseCollection( + "{\"odp_version\":\"1.0\",\"id\":\"c33\",\"name\":\"Too deep\",\"parent_ids\":[\"c32\"]}")); + assertThrows(IllegalArgumentException.class, () -> StaticCatalog.create(List.of(), chain)); + Collection self = OdpJson.parseCollection( + "{\"odp_version\":\"1.0\",\"id\":\"self\",\"name\":\"Self\",\"parent_ids\":[\"self\"]}"); + assertThrows(IllegalArgumentException.class, () -> StaticCatalog.create(List.of(), List.of(self))); + Offering missing = OdpJson.parseOffering( + "{\"odp_version\":\"1.0\",\"id\":\"item\",\"name\":\"Item\",\"collection_ids\":[\"missing\"]}"); + assertThrows(IllegalArgumentException.class, () -> StaticCatalog.create(List.of(missing), List.of())); + } + + /** A catalog is a snapshot: a list the caller goes on editing is not the catalog it handed over. */ + @Test + void snapshotsWhatTheCallerHandedOver() { + List offerings = new ArrayList<>(List.of(Catalog.offering("plant-1", "Rubber Plant"))); + List collections = new ArrayList<>(List.of(Catalog.collection("plants"))); + OdpService service = new OdpService( + Catalog.template(List.of("en")), StaticCatalog.create(offerings, collections, KEY.clone())); + + offerings.add(Catalog.offering("plant-2", "Snake Plant")); + collections.add(Catalog.collection("trees")); + + assertTrue(service.handle(get(OFFERINGS)).body().contains("plant-1")); + assertFalse(service.handle(get(OFFERINGS)).body().contains("plant-2")); + assertFalse(service.handle(get(COLLECTIONS)).body().contains("trees")); + } + + @Test + void publishesCollectionHandlersOnlyWhenItHasCollections() { + assertEquals( + 2, + StaticCatalog.create(List.of(Catalog.offering("plant-1", "Rubber Plant")), List.of()) + .size()); + assertEquals( + 5, + StaticCatalog.create( + List.of(Catalog.offering("plant-1", "Rubber Plant")), + List.of(Catalog.collection("plants"))) + .size()); + } + + @Test + void refusesACatalogItCannotIndex() { + Offering duplicate = Catalog.offering("plant-1", "Snake Plant"); + assertEquals( + "Offering identifiers must be unique", + assertThrows( + IllegalArgumentException.class, + () -> StaticCatalog.create( + List.of(Catalog.offering("plant-1", "Rubber Plant"), duplicate), List.of())) + .getMessage()); + assertEquals( + "Collection identifiers must be unique", + assertThrows( + IllegalArgumentException.class, + () -> StaticCatalog.create( + List.of(), List.of(Catalog.collection("plants"), Catalog.collection("plants")))) + .getMessage()); + assertEquals( + "Offering identifiers must be present", + assertThrows( + IllegalArgumentException.class, + () -> StaticCatalog.create(List.of(Catalog.offering(null, "Nameless")), List.of())) + .getMessage()); + } + + /** PAG-23: a cursor a caller could forge is not integrity protected at all. */ + @Test + void refusesAContinuationKeyTooShortToSignWith() { + assertEquals( + "continuationKey must contain at least 32 bytes", + assertThrows( + IllegalArgumentException.class, + () -> StaticCatalog.create(List.of(), List.of(), new byte[31])) + .getMessage()); + assertThrows(NullPointerException.class, () -> StaticCatalog.create(List.of(), List.of(), null)); + assertThrows(NullPointerException.class, () -> StaticCatalog.create(null, List.of(), KEY.clone())); + } + + /** A key the caller goes on editing is not the key this catalog signs with. */ + @Test + void snapshotsTheContinuationKey() { + byte[] key = KEY.clone(); + OdpService service = new OdpService( + Catalog.template(List.of("en")), + StaticCatalog.create( + List.of(Catalog.offering("plant-1", "A"), Catalog.offering("plant-2", "B")), List.of(), key)); + java.util.Arrays.fill(key, (byte) 0); + + assertEquals(200, service.handle(get(OFFERINGS, query("limit", "1"))).status()); + } + + // -- pagination ------------------------------------------------------------------------- + + /** PAG-02/03: a page that has a successor says so, and the final one does not. */ + @Test + void walksTheCatalogOnePageAtATime() { + OdpService service = paged(5); + List seen = new ArrayList<>(); + String path = OFFERINGS; + Map> parameters = query("limit", "2"); + for (int page = 0; page < 5; page++) { + Page decoded = + OdpJson.parsePage(service.handle(get(path, parameters)).body(), Offering.class); + decoded.items().forEach(item -> seen.add(item.id())); + if (decoded.next() == null) { + break; + } + path = decoded.next().substring(0, decoded.next().indexOf('?')); + parameters = parse(decoded.next()); + } + + assertEquals(List.of("plant-0", "plant-1", "plant-2", "plant-3", "plant-4"), seen); + } + + @Test + void omitsTheContinuationFromTheFinalPage() { + OdpService service = paged(2); + + assertFalse(service.handle(get(OFFERINGS)).body().contains("\"next\"")); + assertTrue(service.handle(get(OFFERINGS, query("limit", "1"))).body().contains("\"next\"")); + } + + /** PAG-12: the continuation preserves every input needed to carry on the same operation. */ + @Test + void carriesTheRequestForwardInTheContinuation() { + String next = OdpJson.parsePage( + paged(3).handle(get( + OFFERINGS, Map.of("limit", List.of("1"), "representation", List.of("full")))) + .body(), + Offering.class) + .next(); + + assertTrue(next.startsWith(OFFERINGS + "?cursor=")); + assertTrue(next.contains("&representation=full")); + assertTrue(next.contains("&limit=1")); + } + + /** PAG-14: a Service chooses its own page size when the request does not. */ + @Test + void choosesItsOwnPageSizeWhenTheRequestDoesNot() { + Page page = OdpJson.parsePage(paged(60).handle(get(OFFERINGS)).body(), Offering.class); + + assertEquals(50, page.items().size()); + } + + @Test + void listsOnlyTheOfferingsOfTheCollectionThatWasAsked() { + Offering member = withCollections("plant-1", List.of("plants")); + Offering outsider = withCollections("plant-2", List.of("trees")); + OdpService service = new OdpService( + Catalog.template(List.of("en")), + StaticCatalog.create( + List.of(member, outsider), + List.of(Catalog.collection("plants"), Catalog.collection("trees")), + KEY.clone())); + + String body = service.handle(get(COLLECTIONS + "/plants/offerings")).body(); + assertTrue(body.contains("plant-1")); + assertFalse(body.contains("plant-2")); + assertEquals(404, service.handle(get(COLLECTIONS + "/absent/offerings")).status()); + } + + // -- cursors ---------------------------------------------------------------------------- + + /** PAG-26: a cursor is untrusted input, and possession of one authorizes nothing. */ + @ParameterizedTest + @ValueSource( + strings = { + "notacursor", + "one.two.three", + "!!!.!!!", + "aGVsbG8.aGVsbG8", + }) + void refusesACursorItDidNotIssue(String cursor) { + OdpHttpResponse response = paged(5).handle(get(OFFERINGS, Map.of("cursor", List.of(cursor)))); + + assertEquals(410, response.status(), cursor); + assertTrue(response.body().contains("CONTINUATION_EXPIRED")); + } + + @Test + void refusesACursorWhoseSignatureDoesNotCover() { + String payload = payload(System.currentTimeMillis() / 1000 + 3_600, 1, 50, "terse", OFFERINGS); + String forged = payload + "." + + encode(sign(payload, "another key that is also long enough!!".getBytes(StandardCharsets.UTF_8))); + + assertEquals( + 410, + paged(5).handle(get(OFFERINGS, Map.of("cursor", List.of(forged)))) + .status()); + } + + /** PAG-20: an expired continuation says so rather than silently restarting the traversal. */ + @Test + void refusesAnExpiredCursorRatherThanRestarting() { + String expired = signed(payload(System.currentTimeMillis() / 1000 - 1, 1, 50, "terse", OFFERINGS)); + OdpHttpResponse response = paged(5).handle(get(OFFERINGS, Map.of("cursor", List.of(expired)))); + + assertEquals(410, response.status()); + assertFalse(response.body().contains("plant-0")); + } + + /** A cursor is bound to the exact request it was issued for, not just to this Service. */ + @Test + void refusesACursorIssuedForAnotherRequest() { + long future = System.currentTimeMillis() / 1000 + 7_200; + OdpService service = paged(5); + + for (String cursor : List.of( + signed(payload(future, 1, 99, "terse", OFFERINGS)), + signed(payload(future, 1, 50, "full", OFFERINGS)), + signed(payload(future, 1, 50, "terse", COLLECTIONS)), + signed(payload(future, -1, 50, "terse", OFFERINGS)), + signed(payload(future, 1, 50, "terse", OFFERINGS) + "\nextra"), + signed("not\nfive\nnewline\nseparated"), + signed(payload(future, 1, 50, "terse", OFFERINGS).replace("\n1\n", "\nmany\n")))) { + assertEquals( + 410, + service.handle(get(OFFERINGS, Map.of("cursor", List.of(cursor)))) + .status(), + cursor); + } + assertEquals( + 200, + service.handle(get( + OFFERINGS, + Map.of("cursor", List.of(signed(payload(future, 1, 50, "terse", OFFERINGS)))))) + .status()); + } + + /** PAG-19: the lifetime is quantised, so a cursor never reveals the moment it was issued. */ + @Test + void issuesTheSameLifetimeToEveryCursorOfAnHour() { + OdpService service = paged(5); + String first = cursorOf(service); + String second = cursorOf(service); + + assertEquals(expiryOf(first), expiryOf(second)); + assertTrue(expiryOf(first) - System.currentTimeMillis() / 1000 >= 3_600, "at least the hour PAG-19 requires"); + assertTrue(expiryOf(first) - System.currentTimeMillis() / 1000 <= 7_200); + assertEquals(0, expiryOf(first) % 3_600, "an hour boundary, not a moment of issuance"); + } + + /** Two Services signing with different keys do not accept each other's continuations. */ + @Test + void keepsCursorsToTheServiceThatIssuedThem() { + String cursor = cursorOf(paged(5)); + OdpService other = new OdpService( + Catalog.template(List.of("en")), + StaticCatalog.create( + offerings(5), List.of(), "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz".getBytes(StandardCharsets.UTF_8))); + + assertEquals( + 410, + other.handle(get(OFFERINGS, Map.of("cursor", List.of(cursor), "limit", List.of("1")))) + .status()); + } + + @Test + void issuesADifferentCursorForEachPosition() { + OdpService service = paged(5); + Page first = OdpJson.parsePage( + service.handle(get(OFFERINGS, query("limit", "1"))).body(), Offering.class); + Page second = OdpJson.parsePage( + service.handle(get(first.next().substring(0, first.next().indexOf('?')), parse(first.next()))) + .body(), + Offering.class); + + assertNotEquals(first.next(), second.next()); + assertEquals("plant-1", second.items().get(0).id()); + } + + // -- helpers ---------------------------------------------------------------------------- + + private static OdpService paged(int count) { + return new OdpService( + Catalog.template(List.of("en")), StaticCatalog.create(offerings(count), List.of(), KEY.clone())); + } + + private static List offerings(int count) { + return java.util.stream.IntStream.range(0, count) + .mapToObj(index -> Catalog.offering("plant-" + index, "Plant " + index)) + .toList(); + } + + private static Offering withCollections(String id, List collectionIds) { + return new Offering( + null, + Odp.VERSION, + id, + "Plant", + null, + null, + null, + null, + null, + collectionIds, + null, + null, + null, + null, + null, + Map.of()); + } + + private static String cursorOf(OdpService service) { + String next = OdpJson.parsePage( + service.handle(get(OFFERINGS, query("limit", "1"))).body(), Offering.class) + .next(); + return parse(next).get("cursor").get(0); + } + + private static long expiryOf(String cursor) { + String decoded = new String(Base64.getUrlDecoder().decode(cursor.split("\\.")[0]), StandardCharsets.UTF_8); + return Long.parseLong(decoded.split("\n")[0]); + } + + private static String payload(long expiry, int offset, int limit, String representation, String path) { + return expiry + "\n" + offset + "\n" + limit + "\n" + representation + "\n" + path; + } + + private static String signed(String payload) { + String encoded = encode(payload.getBytes(StandardCharsets.UTF_8)); + return encoded + "." + encode(sign(encoded, KEY)); + } + + private static String encode(byte[] value) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(value); + } + + 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 (java.security.GeneralSecurityException exception) { + throw new IllegalStateException(exception); + } + } + + private static Map> parse(String reference) { + Map> parsed = new java.util.LinkedHashMap<>(); + for (String pair : reference.substring(reference.indexOf('?') + 1).split("&")) { + int equals = pair.indexOf('='); + parsed.put(pair.substring(0, equals), List.of(pair.substring(equals + 1))); + } + return parsed; + } + + /** Every handler the static catalog registers is reachable through the Service. */ + @Test + void registersAHandlerForEveryStaticOperation() { + Map handlers = StaticCatalog.create( + List.of(Catalog.offering("plant-1", "Rubber Plant")), List.of(Catalog.collection("plants"))); + + assertEquals( + java.util.Set.of( + OdpOperation.LIST_COLLECTIONS, + OdpOperation.GET_COLLECTION, + OdpOperation.LIST_COLLECTION_OFFERINGS, + OdpOperation.LIST_OFFERINGS, + OdpOperation.GET_OFFERING), + handlers.keySet()); + } +} diff --git a/odp-service/src/test/java/org/offeringprotocol/odp/service/TransportConformanceTest.java b/odp-service/src/test/java/org/offeringprotocol/odp/service/TransportConformanceTest.java new file mode 100644 index 0000000..1f0e0ea --- /dev/null +++ b/odp-service/src/test/java/org/offeringprotocol/odp/service/TransportConformanceTest.java @@ -0,0 +1,377 @@ +package org.offeringprotocol.odp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.offeringprotocol.odp.service.Catalog.BASE; +import static org.offeringprotocol.odp.service.Catalog.COLLECTIONS; +import static org.offeringprotocol.odp.service.Catalog.ODP_JSON; +import static org.offeringprotocol.odp.service.Catalog.OFFERINGS; +import static org.offeringprotocol.odp.service.Catalog.get; +import static org.offeringprotocol.odp.service.Catalog.post; +import static org.offeringprotocol.odp.service.Catalog.query; +import static org.offeringprotocol.odp.service.Catalog.with; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.offeringprotocol.odp.core.Odp; + +class TransportConformanceTest { + private final OdpService service = Catalog.service(); + + @Test + void acceptsCompatibleSearchRequestsAndEmitsTheImplementedVersion() { + OdpService searching = Catalog.handling(request -> Catalog.page(List.of(), null)); + for (String version : List.of("1.1", "1.7")) { + OdpHttpResponse response = searching.handle(post( + OFFERINGS + "/search", ODP_JSON, "{\"odp_version\":\"" + version + "\",\"query\":\"plants\"}")); + assertEquals(200, response.status()); + assertEquals( + "1.0", + org.offeringprotocol.odp.core.OdpJson.parseTree(response.body()) + .path("odp_version") + .asString()); + } + } + + /** MED-07: a successful ODP response says what it is carrying. */ + @Test + void servesEveryDocumentAsOdpJson() { + for (String path : List.of(Odp.SERVICE_DOCUMENT_PATH, OFFERINGS, OFFERINGS + "/plant-1", COLLECTIONS)) { + OdpHttpResponse response = service.handle(get(path)); + assertEquals(200, response.status(), path); + assertEquals(ODP_JSON, response.headers().get("Content-Type"), path); + } + } + + /** MED-03: an absent field, a wildcard, and a range that covers the type all take ODP. */ + @ParameterizedTest + @ValueSource( + strings = { + "*/*", + "application/*", + ODP_JSON, + "APPLICATION/ODP+JSON", + "text/html, application/odp+json;q=0.9", + "text/html;q=0.9, */*;q=0.1" + }) + void servesARequestWhoseAcceptCoversOdp(String accept) { + assertEquals(200, service.handle(with(OFFERINGS, "Accept", accept)).status()); + } + + /** MED-04: a field that leaves the ODP media type out, or scores it zero, is a refusal. */ + @ParameterizedTest + @ValueSource( + strings = { + "text/html", + "application/json", + "*/*;q=0", + "application/odp+json;q=0", + "*/*, application/odp+json;q=0", + "application/odp+json;q=nonsense", + "application/odp+json;q=NaN", + "application/odp+json;q=Infinity", + "application/odp+json;q=1.5", + "application/odp+json;q=-0.5" + }) + void refusesARequestThatWillNotTakeOdp(String accept) { + OdpHttpResponse response = service.handle(with(OFFERINGS, "Accept", accept)); + + assertEquals(406, response.status(), accept); + assertTrue(response.body().contains("NOT_ACCEPTABLE")); + } + + /** A more specific range settles the question, even when a broader one scores ODP at zero. */ + @Test + void readsTheMostSpecificRangeThatCoversTheType() { + assertEquals( + 200, + service.handle(with(OFFERINGS, "Accept", "*/*;q=0, application/odp+json")) + .status()); + } + + /** MED-06: a body carried into an ODP operation is an ODP document or it is not read. */ + @Test + void refusesARequestBodyThatIsNotOdpJson() { + OdpService searching = Catalog.handling(request -> Catalog.page(List.of(), null)); + + assertEquals( + 415, searching.handle(post(OFFERINGS + "/search", null, "{}")).status()); + assertEquals( + 415, + searching + .handle(post(OFFERINGS + "/search", "application/json", "{}")) + .status()); + assertEquals( + 200, + searching + .handle(post( + OFFERINGS + "/search", + "application/odp+json; charset=utf-8", + "{\"odp_version\":\"1.0\",\"query\":\"plants\"}")) + .status()); + assertEquals( + 400, + searching.handle(post(OFFERINGS + "/search", null, "")).status(), + "a search still requires a valid request document"); + } + + @Test + void validatesSearchBeforeCallingTheHandler() { + var calls = new java.util.concurrent.atomic.AtomicInteger(); + OdpService searching = Catalog.handling(request -> { + calls.incrementAndGet(); + org.offeringprotocol.odp.core.OdpJson.parseOfferingSearchRequest(request.body()); + return Catalog.page(List.of(), null); + }); + for (String body : List.of("{", "{}", "null", "{\"odp_version\":\"1.0\"}", "")) { + assertEquals( + 400, + searching + .handle(post(OFFERINGS + "/search", ODP_JSON, body)) + .status(), + body); + } + assertEquals( + 400, + searching.handle(post(OFFERINGS + "/search", ODP_JSON, null)).status()); + assertEquals(0, calls.get()); + assertEquals( + 200, + searching + .handle(post(OFFERINGS + "/search", ODP_JSON, "{\"odp_version\":\"1.0\",\"query\":\"plants\"}")) + .status()); + assertEquals(1, calls.get()); + assertEquals( + 415, + searching + .handle(post( + OFFERINGS + "/search", + ODP_JSON + "; broken", + "{\"odp_version\":\"1.0\",\"query\":\"plants\"}")) + .status()); + assertEquals(1, calls.get()); + } + + @Test + void validatesCollectionSearchRequests() { + var endpoints = new java.util.EnumMap<>(StaticCatalog.create( + List.of(Catalog.offering("plant-1", "Plant")), List.of(Catalog.collection("plants")))); + endpoints.put( + org.offeringprotocol.odp.core.OdpOperation.SEARCH_COLLECTIONS, + new OdpService.Endpoint( + org.offeringprotocol.odp.core.AuthenticationRequirement.NOT_REQUIRED, + request -> Catalog.page(List.of(), null))); + OdpService searching = new OdpService(Catalog.template(List.of("en")), endpoints); + assertEquals( + 400, + searching + .handle(post(COLLECTIONS + "/search", ODP_JSON, "{\"odp_version\":\"1.0\"}")) + .status()); + assertEquals( + 200, + searching + .handle(post( + COLLECTIONS + "/search", ODP_JSON, "{\"odp_version\":\"1.0\",\"query\":\"plants\"}")) + .status()); + } + + @Test + void routesSearchPostAndContinuationGetToTheSameHandler() { + OdpService searching = Catalog.handling(request -> Catalog.page( + List.of(Catalog.item("result", request.cursor() == null ? "Initial result" : "Continued result")), + request.cursor() == null ? "/odp/offerings/search?cursor=opaque" : null)); + OdpHttpResponse initial = searching.handle( + post(OFFERINGS + "/search", ODP_JSON, "{\"odp_version\":\"1.0\",\"query\":\"plants\"}")); + assertEquals(200, initial.status()); + assertTrue(initial.body().contains("Initial result")); + OdpHttpResponse continued = searching.handle(get(OFFERINGS + "/search", query("cursor", "opaque"))); + assertEquals(200, continued.status()); + assertTrue(continued.body().contains("Continued result")); + assertFalse(continued.body().contains("\"next\"")); + } + + @Test + void acceptsTheExactRequestByteBoundary() { + assertEquals( + 200, + service.handle(new OdpHttpRequest( + "GET", Odp.SERVICE_DOCUMENT_PATH, Map.of(), Map.of(), "a".repeat(65_536))) + .status()); + assertEquals( + 413, + service.handle(new OdpHttpRequest( + "GET", Odp.SERVICE_DOCUMENT_PATH, Map.of(), Map.of(), "a".repeat(65_537))) + .status()); + } + + /** SVC-73: a value this Service cannot act on is refused, repeated or simply unsupported. */ + @Test + void refusesAQueryValueItCannotActOn() { + assertEquals( + 400, + service.handle(get(OFFERINGS, query("representation", "brief"))).status()); + assertEquals( + 400, + service.handle(get(OFFERINGS, query("representation", "terse", "full"))) + .status()); + assertEquals(400, service.handle(get(OFFERINGS, query("limit", "0"))).status()); + assertEquals(400, service.handle(get(OFFERINGS, query("limit", "101"))).status()); + assertEquals( + 400, service.handle(get(OFFERINGS, query("limit", "plenty"))).status()); + assertEquals( + 400, service.handle(get(OFFERINGS, query("limit", "2", "3"))).status()); + assertEquals( + 400, service.handle(get(OFFERINGS, query("cursor", "a", "b"))).status()); + assertEquals(200, service.handle(get(OFFERINGS, query("limit", "100"))).status()); + } + + /** A path this Service publishes but by another method says which methods it does publish. */ + @Test + void namesTheMethodsAResourceAllows() { + OdpHttpResponse response = service.handle(post(OFFERINGS, ODP_JSON, null)); + + assertEquals(405, response.status()); + assertEquals("GET, HEAD", response.headers().get("Allow")); + assertTrue(response.body().contains("METHOD_NOT_ALLOWED")); + + OdpService searching = Catalog.handling(request -> Catalog.page(List.of(), null)); + assertEquals( + "GET, HEAD, POST", + searching + .handle(new OdpHttpRequest("DELETE", OFFERINGS + "/search", Map.of(), Map.of(), null)) + .headers() + .get("Allow")); + assertEquals( + "GET, HEAD", + service.handle(new OdpHttpRequest("PUT", Odp.SERVICE_DOCUMENT_PATH, Map.of(), Map.of(), null)) + .headers() + .get("Allow")); + } + + /** RFC 9110 9.3.2: a HEAD is a GET whose body is left out, with the same fields. */ + @Test + void answersHeadWithTheFieldsOfTheGetItStandsFor() { + OdpHttpResponse body = service.handle(get(Odp.SERVICE_DOCUMENT_PATH)); + OdpHttpResponse head = + service.handle(new OdpHttpRequest("HEAD", Odp.SERVICE_DOCUMENT_PATH, Map.of(), Map.of(), null)); + + assertEquals(200, head.status()); + assertEquals("", head.body()); + assertEquals(body.headers().get("ETag"), head.headers().get("ETag")); + assertEquals(ODP_JSON, head.headers().get("Content-Type")); + assertEquals( + Integer.toString(body.body().getBytes(java.nio.charset.StandardCharsets.UTF_8).length), + head.headers().get("Content-Length")); + } + + /** ERR-31: a request past the byte limit is refused before anything reads it. */ + @Test + void refusesARequestPastItsByteLimit() { + OdpService searching = Catalog.handling(request -> Catalog.page(List.of(), null)); + + assertEquals( + 200, + searching + .handle(post( + OFFERINGS + "/search", + ODP_JSON, + "{\"odp_version\":\"1.0\",\"query\":\"plants\"}" + " ".repeat(65_490))) + .status()); + OdpHttpResponse refused = searching.handle(post(OFFERINGS + "/search", ODP_JSON, "a".repeat(65_537))); + assertEquals(413, refused.status()); + assertTrue(refused.body().contains("REQUEST_TOO_LARGE")); + } + + // -- routing ---------------------------------------------------------------------------- + + @Test + void routesEveryOperationItPublishes() { + assertEquals(200, service.handle(get(Odp.SERVICE_DOCUMENT_PATH)).status()); + assertEquals(200, service.handle(get(OFFERINGS)).status()); + assertEquals(200, service.handle(get(OFFERINGS + "/plant-1")).status()); + assertEquals(200, service.handle(get(COLLECTIONS)).status()); + assertEquals(200, service.handle(get(COLLECTIONS + "/plants")).status()); + assertEquals(200, service.handle(get(COLLECTIONS + "/plants/offerings")).status()); + } + + /** A path that is not a resource of this Service is not a resource of this Service. */ + @ParameterizedTest + @ValueSource( + strings = { + "/offerings", + "/odpx/offerings", + BASE, + BASE + "/", + OFFERINGS + "/plant-1/", + OFFERINGS + "/plant-1/actions", + COLLECTIONS + "//offerings", + COLLECTIONS + "/plants/offerings/extra", + COLLECTIONS + "/plants/collections", + "/.well-known/odp/", + "/.well-known/other" + }) + void refusesAPathItDoesNotPublish(String path) { + assertEquals(404, service.handle(get(path)).status(), path); + } + + /** IDN-08: a segment standing where an identifier belongs is an identifier or it names nothing. */ + @ParameterizedTest + @ValueSource(strings = {"a?b", "a b", "a/b", ".", "..", "pl%61nt", "plant!"}) + void refusesAPathSegmentThatIsNotAnIdentifier(String identifier) { + assertEquals(404, service.handle(get(OFFERINGS + "/" + identifier)).status(), identifier); + } + + @Test + void refusesAnIdentifierLongerThanAnIdentifierCanBe() { + assertEquals(404, service.handle(get(OFFERINGS + "/" + "a".repeat(129))).status()); + assertEquals( + 404, service.handle(get(OFFERINGS + "/" + "a".repeat(128))).status(), "in range, but no such Offering"); + } + + @Test + void readsAnIdentifierOffThePathAndHandsItToTheHandler() { + OdpService echoing = Catalog.handling(request -> Catalog.offering(request.identifier(), "Echoed")); + + assertTrue(echoing.handle(get(OFFERINGS + "/plant.9_x~y-z")).body().contains("plant.9_x~y-z")); + } + + /** A route whose operation this Service does not publish is absent, not refused by method. */ + @Test + void refusesAnOperationItDoesNotPublish() { + OdpService withoutCollections = new OdpService( + Catalog.template(List.of("en")), + StaticCatalog.create(List.of(Catalog.offering("plant-1", "Rubber Plant")), List.of())); + + assertEquals(404, withoutCollections.handle(get(COLLECTIONS)).status()); + assertEquals( + 404, withoutCollections.handle(get(COLLECTIONS + "/plants")).status()); + assertEquals(404, withoutCollections.handle(get(OFFERINGS + "/search")).status()); + } + + /** An endpoint base of "/" publishes the operations at the root. */ + @Test + void servesFromTheRootWhenThatIsTheEndpointBase() { + OdpService rooted = OdpService.builder("Plant Store", "Plants for agents.", "en", "/") + .endpoints(StaticCatalog.create(List.of(Catalog.offering("plant-1", "Rubber Plant")), List.of())) + .build(); + + assertEquals(200, rooted.handle(get("/offerings")).status()); + assertEquals(200, rooted.handle(get("/offerings/plant-1")).status()); + assertEquals(200, rooted.handle(get(Odp.SERVICE_DOCUMENT_PATH)).status()); + } + + @Test + void answersAResourceTheCatalogDoesNotHaveWithNotFound() { + OdpHttpResponse response = service.handle(get(OFFERINGS + "/absent")); + + assertEquals(404, response.status()); + assertTrue(response.body().contains("ODP resource not found")); + assertNull(response.headers().get("ETag")); + assertFalse(response.body().contains("plant-1")); + } +} diff --git a/pom.xml b/pom.xml index bb765f3..e8d04eb 100644 --- a/pom.xml +++ b/pom.xml @@ -50,34 +50,44 @@ - 17 - UTF-8 - UTF-8 - - 6.1.3 + 0.11.0 + 3.6.4 + 5.6.4 2.22 2.22.2 3.2.2 + 0.8.15 + 17 2.0.7 3.0.7 - 0.8.15 - 3.6.4 - 0.11.0 - 3.2.8 - 3.12.0 - 3.4.0 - 2.97.0 - + 6.1.3 3.6.0 3.16.0 3.6.3 + 3.2.8 3.5.1 + 3.12.0 3.28.0 + 3.4.0 3.6.0 + 2.97.0 + UTF-8 + UTF-8 + 2.0.17 4.10.4.1 3.10.2 + + + + org.slf4j + slf4j-api + ${slf4j.version} + + + + org.junit.jupiter diff --git a/scripts/run-conformance.sh b/scripts/run-conformance.sh index 45c0a65..8aa02e6 100755 --- a/scripts/run-conformance.sh +++ b/scripts/run-conformance.sh @@ -4,7 +4,7 @@ set -eu specs_dir=${ODP_SPECS_DIR:-../odp-specs} output_dir=${ODP_CONFORMANCE_OUTPUT:-.conformance/reports} implementation_version=${ODP_JAVA_VERSION:-$(./mvnw --quiet --batch-mode --no-transfer-progress \ - help:evaluate -Dexpression=revision -DforceStdout)} + help:evaluate -Dexpression=project.version -DforceStdout)} implementation_version=${implementation_version#v} ./mvnw --quiet --batch-mode --no-transfer-progress -DskipTests install diff --git a/tools/odp-conformance/src/main/java/org/offeringprotocol/odp/conformance/ConformanceAdapter.java b/tools/odp-conformance/src/main/java/org/offeringprotocol/odp/conformance/ConformanceAdapter.java index d5d9133..e93c560 100644 --- a/tools/odp-conformance/src/main/java/org/offeringprotocol/odp/conformance/ConformanceAdapter.java +++ b/tools/odp-conformance/src/main/java/org/offeringprotocol/odp/conformance/ConformanceAdapter.java @@ -18,6 +18,7 @@ import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import javax.net.ssl.SSLSession; +import org.offeringprotocol.odp.agent.OdpRequestException; import org.offeringprotocol.odp.agent.OdpServiceClient; import org.offeringprotocol.odp.agent.OdpTransport; import org.offeringprotocol.odp.agent.OfferingDetails; @@ -26,6 +27,7 @@ import org.offeringprotocol.odp.core.Odp; import org.offeringprotocol.odp.core.OdpJson; import org.offeringprotocol.odp.core.OdpOperation; +import org.offeringprotocol.odp.core.OdpPagination; import org.offeringprotocol.odp.core.OdpUris; import org.offeringprotocol.odp.core.Offering; import org.offeringprotocol.odp.core.OperationDescriptor; @@ -33,9 +35,11 @@ import org.offeringprotocol.odp.core.ResourceIdentity; import org.offeringprotocol.odp.core.SearchCapabilities.FilterDefinition; import org.offeringprotocol.odp.core.SearchCapabilities.SortDefinition; +import org.offeringprotocol.odp.core.SearchCatalog; import org.offeringprotocol.odp.core.ServiceDocument; import org.offeringprotocol.odp.service.OdpHttpRequest; import org.offeringprotocol.odp.service.OdpService; +import org.offeringprotocol.odp.service.StaticCatalog; import tools.jackson.core.JacksonException; import tools.jackson.databind.JsonNode; import tools.jackson.databind.json.JsonMapper; @@ -43,6 +47,39 @@ /** Process adapter for the language-neutral ODP conformance harness. */ public final class ConformanceAdapter { + private static final String REQUEST_FIELD = "request"; + private static final String RETRY_OPERATION = "retry"; + private static final String LIMIT_FAILURE_SCOPE = "limit-failure-scope"; + private static final String FILTERS_FIELD = "filters"; + private static final String OPERATIONS_FIELD = "operations"; + private static final String AUTHENTICATION_FIELD = "authentication"; + private static final String NOT_REQUIRED_AUTH = "not-required"; + private static final String SEARCH_OFFERINGS_OPERATION = "search-offerings"; + private static final String INLINE_FIELD = "inline"; + private static final String COMPUTE_COUNTS = "compute-counts"; + private static final String REPRESENTATION_FIELD = "representation"; + private static final String ODP_MEDIA_TYPE = "application/odp+json"; + private static final String SKIPPED_STATUS = "skipped"; + private static final String EXPECTED_FIELD = "expected"; + private static final String ITEM_ID = "item"; + private static final String OFFERINGS_PATH = "/odp/offerings"; + private static final String GET_METHOD = "GET"; + private static final String TERSE_REPRESENTATION = "terse"; + private static final String VERSION_FIELD = "odp_version"; + private static final String ITEMS_FIELD = "items"; + private static final String NAME_FIELD = "name"; + private static final String OPENAPI_FIELD = "openapi"; + private static final String SERVICE_ORIGIN = "https://service.example"; + private static final String VALIDATE_ACTIONS = "validate-actions"; + private static final String RESOLVE_OPENAPI = "resolve-openapi"; + private static final String STATUS_FIELD = "status"; + private static final String ITEM_NAME = "Item"; + private static final String SCHEMA_MEDIA_TYPE = "application/schema+json"; + private static final String SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema"; + private static final String SCHEMA_DIALECT_FIELD = "$schema"; + private static final String DESCRIPTION_FIELD = "description"; + private static final String FULL_REPRESENTATION = "full"; + private static final String OFFERING_PATH = "/odp/offerings/item"; private static final int MAXIMUM_MESSAGE_LENGTH = 1024; private static final String AGENT_ROLE = "agent"; private static final String DOCUMENT_FIELD = "document"; @@ -52,17 +89,6 @@ public final class ConformanceAdapter { private static final String VALIDATE_ADVERTISEMENT = "validate-advertisement"; private static final String VALIDATE_PROBLEM = "validate-problem"; private static final JsonMapper JSON = JsonMapper.builder().build(); - private static final Set AGENT_BASELINE = Set.of( - "enforce-compatibility", - "enforce-redirect-and-security", - "follow-pagination", - "get-offering", - "handle-errors-and-limits", - "honor-caching", - "inspect-service", - "list-offerings", - "process-localization", - "process-representations"); private ConformanceAdapter() {} @@ -85,14 +111,19 @@ private static Map evaluate(JsonNode request) { response.put("protocol_version", "1"); response.put("sequence", sequence); try { - Evaluation evaluation = evaluateCase( - text(required(request, "vector"), "subject"), required(request, "case"), text(request, "role")); - response.put("status", evaluation.status()); + String subject = text(required(request, "vector"), "subject"); + JsonNode test = required(request, "case"); + String role = text(request, "role"); + Evaluation evaluation = evaluateCase(subject, test, role); + if (SKIPPED_STATUS.equals(evaluation.status()) && evaluation.message() == null) { + evaluation = new Evaluation(SKIPPED_STATUS, skipReason(subject, test)); + } + response.put(STATUS_FIELD, evaluation.status()); if (evaluation.message() != null) { response.put("message", evaluation.message()); } } catch (RuntimeException exception) { - response.put("status", "failed"); + response.put(STATUS_FIELD, "failed"); response.put("message", truncate(exception.getMessage())); } return response; @@ -106,27 +137,656 @@ private static Evaluation evaluateCase(String subject, JsonNode test, String rol case "resource-reference" -> evaluateReference(test); case "service-document" -> parse(test, DOCUMENT_FIELD, OdpJson::parseServiceDocument); case "collection-envelope" -> parse(test, DOCUMENT_FIELD, OdpJson::parseCollection); + case "auth-expands" -> parse(test, DOCUMENT_FIELD, value -> OdpJson.parsePage(value, Offering.class)); + case "representation-selection" -> evaluateSelection(test, role); + case "detail-fields" -> evaluateDetailFields(test, role); + case "media-negotiation" -> evaluateMedia(test, role); + case "version-placement" -> evaluateVersionPlacement(test, role); + case "protocol-version" -> evaluateProtocolVersion(test); + case "security-contract" -> evaluateSecurity(test, role); case "offering-contract" -> - "full".equals(optionalText(test, "representation")) + FULL_REPRESENTATION.equals(optionalText(test, REPRESENTATION_FIELD)) ? parse(test, DOCUMENT_FIELD, OdpJson::parseOffering) - : skipped(); + : evaluateTerseOffering(test, role); + case "action-contract" -> evaluateActions(test, role); + case "filter-sort-contract" -> evaluateFilter(test); + case "search-capability-contract" -> evaluateSearchCapabilities(test, role); + case "refinement-contract" -> evaluateRefinement(test); + case "collection-hierarchy", "collection-membership" -> evaluateCatalog(test, role); case "collection-search-contract" -> "validate-request".equals(operation(test)) - ? parse(test, "request", OdpJson::parseCollectionSearchRequest) + ? parse(test, REQUEST_FIELD, OdpJson::parseCollectionSearchRequest) : skipped(); case "composition-contract" -> evaluateComposition(test, role); case "offering-search-contract" -> "validate-request".equals(operation(test)) - ? parse(test, "request", OdpJson::parseOfferingSearchRequest) + ? parse(test, REQUEST_FIELD, OdpJson::parseOfferingSearchRequest) : skipped(); - case "attribute-schema-retrieval" -> evaluateAttributeSchema(test); - case "pagination-contract" -> evaluatePagination(test); - case "errors-limits-contract" -> evaluateErrorsAndLimits(test); + case "attribute-schema-retrieval" -> + !AGENT_ROLE.equals(role) && !"validate-reference".equals(operation(test)) + ? new Evaluation( + SKIPPED_STATUS, + "Service publishes schema references; it does not retrieve or validate remote Attribute Schema graphs") + : evaluateAttributeSchema(test); + case "pagination-contract" -> evaluatePagination(test, role); + case "errors-limits-contract" -> evaluateErrorsAndLimits(test, role); case "role-baseline" -> evaluateBaseline(test, role); default -> skipped(); }; } + private static ServiceDocument baselineDocument() { + return document(List.of( + new OperationDescriptor(AuthenticationRequirement.NOT_REQUIRED, OdpOperation.LIST_OFFERINGS), + new OperationDescriptor(AuthenticationRequirement.NOT_REQUIRED, OdpOperation.GET_OFFERING))); + } + + private static Evaluation evaluateSecurity(JsonNode test, String role) { + if (!"validate-redirect".equals(operation(test))) return skipped(); + if (!AGENT_ROLE.equals(role)) + return new Evaluation(SKIPPED_STATUS, "Service does not retrieve supporting resources"); + String source = text(test, "from"); + String target = text(test, "to"); + AtomicInteger calls = new AtomicInteger(); + ObjectNode offering = JSON.createObjectNode() + .put(VERSION_FIELD, Odp.VERSION) + .put("id", ITEM_ID) + .put(NAME_FIELD, ITEM_NAME); + offering.putObject("schema").put("url", source); + OdpServiceClient client = OdpServiceClient.create( + URI.create(SERVICE_ORIGIN), + request -> response( + request, + Odp.SERVICE_DOCUMENT_PATH.equals(request.uri().getPath()) + ? OdpJson.write(baselineDocument()) + : offering.toString(), + ODP_MEDIA_TYPE, + 200), + request -> { + calls.incrementAndGet(); + return request.uri().toString().equals(source) + ? response(request, "", Map.of("Location", List.of(target)), 302) + : response( + request, + "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"}", + SCHEMA_MEDIA_TYPE, + 200); + }); + OfferingDetails details = client.getOfferingDetails(ITEM_ID, null); + return result((details.attributeSchema() != null) == valid(test) && calls.get() == (valid(test) ? 2 : 1)); + } + + private static Evaluation evaluateProtocolVersion(JsonNode test) { + if (!text(test, "supported").split("\\.")[0].equals(Odp.VERSION.split("\\.")[0])) + return new Evaluation( + SKIPPED_STATUS, + "The vector configures a different supported version; this SDK supports " + Odp.VERSION); + ObjectNode offering = JSON.createObjectNode() + .put(VERSION_FIELD, text(test, "received")) + .put("id", ITEM_ID) + .put(NAME_FIELD, ITEM_NAME); + return parseValue( + offering.toString(), + OdpJson::parseOffering, + valid(test) && required(test, "compatible").asBoolean()); + } + + private static Evaluation evaluateCatalog(JsonNode test, String role) { + if (AGENT_ROLE.equals(role)) + return new Evaluation( + SKIPPED_STATUS, + "Agent exposes catalog operations but does not traverse hierarchy or compute inverse membership"); + List collections = new ArrayList<>(); + List offerings = new ArrayList<>(); + if (test.has("chain_length")) { + int edges = required(test, "chain_length").asInt(); + for (int index = 0; index <= edges; index++) { + ObjectNode collection = JSON.createObjectNode() + .put(VERSION_FIELD, Odp.VERSION) + .put("id", "collection-" + index) + .put(NAME_FIELD, "Collection"); + if (index > 0) collection.putArray("parent_ids").add("collection-" + (index - 1)); + collections.add(OdpJson.parseCollection(collection.toString())); + } + } else { + for (JsonNode value : required(test, "collections")) { + ObjectNode collection = value.isString() + ? JSON.createObjectNode().put("id", textValue(value)) + : (ObjectNode) value.deepCopy(); + collection.put(VERSION_FIELD, Odp.VERSION).put(NAME_FIELD, "Collection"); + collections.add(OdpJson.parseCollection(collection.toString())); + } + } + if (test.has("offerings")) { + for (JsonNode value : required(test, "offerings")) { + ObjectNode offering = (ObjectNode) value.deepCopy(); + offering.put(VERSION_FIELD, Odp.VERSION).put(NAME_FIELD, "Offering"); + offerings.add(OdpJson.parseOffering(offering.toString())); + } + } + try { + var endpoints = StaticCatalog.create(offerings, collections); + if (test.has("offering_ids")) { + var response = new OdpService(baselineDocument(), endpoints) + .handle(new OdpHttpRequest( + GET_METHOD, + "/odp/collections/" + text(test, "query_collection_id") + "/offerings", + Map.of(), + Map.of(), + null)); + var page = OdpJson.parsePage(response.body(), Offering.class); + return result( + JSON.valueToTree(page.items().stream().map(Offering::id).toList()) + .equals(required(test, "offering_ids"))); + } + return result(valid(test)); + } catch (IllegalArgumentException exception) { + return result(test.has("valid") && !valid(test)); + } + } + + private static Evaluation evaluateTerseOffering(JsonNode test, String role) { + ObjectNode page = JSON.createObjectNode().put(VERSION_FIELD, Odp.VERSION); + page.putArray(ITEMS_FIELD).add(required(test, DOCUMENT_FIELD)); + boolean accepted; + if (AGENT_ROLE.equals(role)) { + try { + reading(page.toString(), ODP_MEDIA_TYPE).listOfferings(TERSE_REPRESENTATION, null, null); + accepted = true; + } catch (IllegalArgumentException | IllegalStateException exception) { + accepted = false; + } + } else { + accepted = serving(OdpJson.parseTree(page.toString())) + .handle(new OdpHttpRequest(GET_METHOD, OFFERINGS_PATH, Map.of(), Map.of(), null)) + .status() + == 200; + } + return result(accepted == valid(test)); + } + + private static Evaluation evaluateFilter(JsonNode test) { + if ("validate-expression".equals(operation(test)) || "validate-sort".equals(operation(test))) { + try { + List filters = new ArrayList<>(); + if (test.has("definition")) + filters.add(OdpJson.parseFilterDefinition( + required(test, "definition").toString())); + if (test.has("definitions")) { + for (JsonNode definition : required(test, "definitions")) + filters.add(OdpJson.parseFilterDefinition( + completeDefinition(definition, false).toString())); + } + List sorts = test.has("sort") + ? List.of(OdpJson.parseSortDefinition( + required(test, "sort").toString())) + : List.of(); + SearchCatalog catalog = new SearchCatalog(filters, sorts); + if (test.has("expression")) { + ObjectNode request = JSON.createObjectNode().put(VERSION_FIELD, Odp.VERSION); + request.putArray(FILTERS_FIELD).add(required(test, "expression")); + catalog.validateRequest(OdpJson.parseOfferingSearchRequest(request.toString())); + } + return result(valid(test)); + } catch (IllegalArgumentException exception) { + return result(!valid(test)); + } + } + if (!"validate-definition".equals(operation(test))) return skipped(); + ObjectNode document = (ObjectNode) JSON.readTree(OdpJson.write(baselineDocument())); + document.withArray(OPERATIONS_FIELD) + .addObject() + .put(AUTHENTICATION_FIELD, NOT_REQUIRED_AUTH) + .put(NAME_FIELD, SEARCH_OFFERINGS_OPERATION); + document.putObject("search_capabilities") + .putObject(FILTERS_FIELD) + .putArray(INLINE_FIELD) + .add(required(test, "definition")); + return parseValue(document.toString(), OdpJson::parseServiceDocument, valid(test)); + } + + private static Evaluation evaluateRefinement(JsonNode test) { + if (COMPUTE_COUNTS.equals(operation(test))) + return new Evaluation( + SKIPPED_STATUS, + "Application catalog handlers compute contextual bucket counts; SDK validates the request and response"); + try { + List filters = new ArrayList<>(); + for (JsonNode definition : required(test, "definitions")) + filters.add(OdpJson.parseFilterDefinition( + completeDefinition(definition, false).toString())); + SearchCatalog catalog = new SearchCatalog(filters, List.of()); + ObjectNode request = (ObjectNode) required(test, REQUEST_FIELD).deepCopy(); + request.put(VERSION_FIELD, Odp.VERSION).put("query", "conformance"); + var parsed = OdpJson.parseOfferingSearchRequest(request.toString()); + catalog.validateRequest(parsed); + if (test.has("response")) { + ObjectNode response = (ObjectNode) required(test, "response").deepCopy(); + response.put(VERSION_FIELD, Odp.VERSION).putArray(ITEMS_FIELD); + catalog.validateRefinements( + OdpJson.parseOfferingSearchResponse(response.toString()), + parsed, + test.path("continuation").asBoolean(false)); + } + return result(valid(test)); + } catch (IllegalArgumentException exception) { + return result(!valid(test)); + } + } + + private static ObjectNode completeDefinition(JsonNode input, boolean sort) { + ObjectNode definition = (ObjectNode) input.deepCopy(); + if (!definition.has("title")) definition.put("title", "Conformance definition"); + if (!definition.has(DESCRIPTION_FIELD)) definition.put(DESCRIPTION_FIELD, "Conformance definition"); + if (sort) { + if (!definition.has("keys")) definition.putArray("keys").addObject().put("filter_id", "material"); + for (JsonNode key : definition.get("keys")) { + ObjectNode object = (ObjectNode) key; + if (!object.has("direction")) object.put("direction", "ascending"); + if (!object.has("missing")) object.put("missing", "last"); + } + } else { + if (!definition.has("type")) definition.put("type", "string"); + if (!definition.has("operators")) definition.putArray("operators").add("eq"); + } + return definition; + } + + private static ObjectNode completeAdvertisement(JsonNode advertisement) { + ObjectNode result = (ObjectNode) advertisement.deepCopy(); + for (String kind : List.of(FILTERS_FIELD, "sorts")) { + JsonNode source = result.get(kind); + if (source == null) continue; + JsonNode inline = source.isArray() ? source : source.get(INLINE_FIELD); + if (inline != null && inline.isArray()) { + var definitions = JSON.createArrayNode(); + for (JsonNode definition : inline) + definitions.add(completeDefinition(definition, "sorts".equals(kind))); + if (source.isArray()) { + if (definitions.isEmpty()) result.remove(kind); + else result.putObject(kind).set(INLINE_FIELD, definitions); + } else ((ObjectNode) source).set(INLINE_FIELD, definitions); + } + } + return result; + } + + private static Evaluation evaluateSearchCapabilities(JsonNode test, String role) { + String operation = operation(test); + String origin = test.has("service_origin") ? text(test, "service_origin") : SERVICE_ORIGIN; + if (VALIDATE_ADVERTISEMENT.equals(operation)) { + try { + var capabilities = OdpJson.parseSearchCapabilities( + completeAdvertisement(required(test, "advertisement")).toString()); + boolean supported = false; + for (JsonNode name : required(test, OPERATIONS_FIELD)) + if (SEARCH_OFFERINGS_OPERATION.equals(name.asString())) supported = true; + SearchCatalog.validateAdvertisement(capabilities, supported, origin); + return result(valid(test)); + } catch (IllegalArgumentException exception) { + return result(!valid(test)); + } + } + if (!AGENT_ROLE.equals(role)) + return new Evaluation( + SKIPPED_STATUS, + "Linked-source retrieval and tolerant capability merging are Agent operations; Services publish validated definitions"); + Map pages = new LinkedHashMap<>(); + ObjectNode capabilities; + boolean merge = "merge-capabilities".equals(operation); + if (merge) capabilities = completeAdvertisement(required(test, "service")); + else { + String href = test.has("href") ? text(test, "href") : "/filters"; + capabilities = JSON.createObjectNode(); + capabilities.putObject(FILTERS_FIELD).putObject("linked").put("href", href); + if (test.has("pages")) { + for (JsonNode page : required(test, "pages")) { + ObjectNode expanded = (ObjectNode) page.deepCopy(); + var definitions = expanded.putArray(ITEMS_FIELD); + for (JsonNode definition : page.path(ITEMS_FIELD)) + definitions.add(completeDefinition(definition, false)); + pages.put(URI.create(origin).resolve(href).getPath(), expanded.toString()); + href = optionalText(page, "next"); + } + } else { + int count = required(test, "page_count").asInt(); + for (int index = 0; index < count; index++) { + ObjectNode page = JSON.createObjectNode().put(VERSION_FIELD, Odp.VERSION); + page.putArray(ITEMS_FIELD) + .add(completeDefinition(JSON.createObjectNode().put("id", "f" + index), false)); + if (index + 1 < count) page.put("next", "/filters/" + (index + 1)); + pages.put(index == 0 ? href : "/filters/" + index, page.toString()); + } + } + } + ObjectNode document = (ObjectNode) JSON.readTree(OdpJson.write(baselineDocument())); + document.withArray(OPERATIONS_FIELD) + .addObject() + .put(NAME_FIELD, SEARCH_OFFERINGS_OPERATION) + .put(AUTHENTICATION_FIELD, NOT_REQUIRED_AUTH); + document.withArray(OPERATIONS_FIELD) + .addObject() + .put(NAME_FIELD, "get-collection") + .put(AUTHENTICATION_FIELD, NOT_REQUIRED_AUTH); + if (!capabilities.isEmpty()) document.set("search_capabilities", capabilities); + String collectionId = optionalText(test, "collection_id"); + if (collectionId != null) { + ObjectNode collection = JSON.createObjectNode() + .put(VERSION_FIELD, Odp.VERSION) + .put("id", collectionId) + .put(NAME_FIELD, "Collection"); + collection.set("search_capabilities", completeAdvertisement(required(test, "selected_collection"))); + pages.put("/odp/collections/" + collectionId, collection.toString()); + } + AtomicInteger calls = new AtomicInteger(); + OdpServiceClient client = OdpServiceClient.create(URI.create(origin), request -> { + if (Odp.SERVICE_DOCUMENT_PATH.equals(request.uri().getPath())) + return response(request, document.toString(), ODP_MEDIA_TYPE, 200); + calls.incrementAndGet(); + String body = pages.get(request.uri().getPath()); + return response(request, body == null ? "{}" : body, ODP_MEDIA_TYPE, body == null ? 404 : 200); + }); + var resolved = client.resolveSearchCapabilities(collectionId, null); + if (merge) { + JsonNode expected = required(test, EXPECTED_FIELD); + return result(JSON.valueToTree( + new ArrayList<>(resolved.catalog().filters().keySet())) + .equals(expected.get("filter_ids")) + && JSON.valueToTree( + new ArrayList<>(resolved.catalog().sorts().keySet())) + .equals(expected.get("sort_ids")) + && resolved.issues().size() == expected.path("issues").size()); + } + return result(resolved.issues().isEmpty() == valid(test) && calls.get() <= 16); + } + + private static Evaluation evaluateActions(JsonNode test, String role) { + if (!AGENT_ROLE.equals(role)) + return new Evaluation( + SKIPPED_STATUS, + "Action quarantine and OpenAPI retrieval are Agent operations; Service validates advertised descriptors"); + ObjectNode offering = JSON.createObjectNode() + .put(VERSION_FIELD, Odp.VERSION) + .put("id", ITEM_ID) + .put(NAME_FIELD, ITEM_NAME); + ObjectNode document = (ObjectNode) JSON.readTree(OdpJson.write(baselineDocument())); + ObjectNode openapi = JSON.createObjectNode(); + if (VALIDATE_ACTIONS.equals(operation(test))) { + offering.set("actions", required(test, "actions")); + } else if (RESOLVE_OPENAPI.equals(operation(test))) { + offering.putArray("actions") + .addObject() + .put("id", "invoke") + .put("rel", "invoke") + .put(AUTHENTICATION_FIELD, NOT_REQUIRED_AUTH) + .set(OPENAPI_FIELD, required(test, "target")); + if (test.has("service_openapi")) { + ((ObjectNode) document.get("http")).putObject(OPENAPI_FIELD).put("url", text(test, "service_openapi")); + } + JsonNode description = required(test, DOCUMENT_FIELD); + openapi.put(OPENAPI_FIELD, text(description, OPENAPI_FIELD)); + openapi.putObject("info").put("title", "Conformance").put("version", "1"); + ObjectNode paths = openapi.putObject("paths"); + int index = 0; + for (JsonNode id : required(description, "operation_ids")) { + ObjectNode operation = paths.putObject("/operation-" + index).putObject("post"); + index++; + operation.put("operationId", textValue(id)); + operation.putObject("responses").putObject("200").put(DESCRIPTION_FIELD, "Success"); + } + } else { + return skipped(); + } + OdpServiceClient client = OdpServiceClient.create( + URI.create(SERVICE_ORIGIN), + request -> response( + request, + Odp.SERVICE_DOCUMENT_PATH.equals(request.uri().getPath()) + ? document.toString() + : offering.toString(), + ODP_MEDIA_TYPE, + 200), + request -> response(request, openapi.toString(), "application/json", 200)); + if (VALIDATE_ACTIONS.equals(operation(test))) { + OfferingDetails details = client.getOfferingDetails(ITEM_ID, null); + List usable = + details.actions().stream().map(action -> action.id()).toList(); + List issues = details.issues().stream() + .filter(issue -> issue.scope() == OfferingIssue.Scope.ACTION) + .map(OfferingIssue::actionId) + .toList(); + return result(JSON.valueToTree(usable).equals(required(test, "expected_usable")) + && new java.util.HashSet<>(issues) + .equals(JSON.convertValue( + required(test, "expected_issues"), + new tools.jackson.core.type.TypeReference>() {}))); + } + boolean resolved; + try { + client.resolveAction(ITEM_ID, "invoke", null); + resolved = true; + } catch (IllegalArgumentException | IllegalStateException exception) { + resolved = false; + } + return result(resolved == valid(test)); + } + + private static OdpService serving(Object value) { + Map endpoints = new EnumMap<>(OdpOperation.class); + for (OdpOperation operation : OdpOperation.values()) { + endpoints.put(operation, new OdpService.Endpoint(AuthenticationRequirement.NOT_REQUIRED, request -> value)); + } + return new OdpService(baselineDocument(), endpoints); + } + + private static OdpServiceClient reading(String value, String mediaType) { + return OdpServiceClient.create( + URI.create(SERVICE_ORIGIN), + request -> response( + request, + Odp.SERVICE_DOCUMENT_PATH.equals(request.uri().getPath()) + ? OdpJson.write(baselineDocument()) + : value, + Odp.SERVICE_DOCUMENT_PATH.equals(request.uri().getPath()) ? ODP_MEDIA_TYPE : mediaType, + 200)); + } + + private static Evaluation evaluateSelection(JsonNode test, String role) { + if (AGENT_ROLE.equals(role)) { + return evaluateAgentSelection(test); + } + AtomicInteger calls = new AtomicInteger(); + List selections = new ArrayList<>(); + Map endpoints = new EnumMap<>(OdpOperation.class); + for (OdpOperation operation : OdpOperation.values()) { + endpoints.put(operation, new OdpService.Endpoint(AuthenticationRequirement.NOT_REQUIRED, request -> { + calls.incrementAndGet(); + selections.add(request.representation()); + return request.identifier() == null || operation == OdpOperation.LIST_COLLECTION_OFFERINGS + ? new Page<>(null, Odp.VERSION, List.of(), null, Map.of()) + : OdpJson.parseTree("{\"odp_version\":\"1.0\",\"id\":\"item\",\"name\":\"Item\"}"); + })); + } + String path = + switch (operation(test)) { + case "get-offering" -> OFFERING_PATH; + case "get-collection" -> "/odp/collections/item"; + case "list-collections" -> "/odp/collections"; + case "list-collection-offerings" -> "/odp/collections/item/offerings"; + case "search-collections" -> "/odp/collections/search"; + case SEARCH_OFFERINGS_OPERATION -> "/odp/offerings/search"; + case "list-offerings" -> OFFERINGS_PATH; + default -> throw new IllegalArgumentException("Unknown representation operation"); + }; + Map> query = new LinkedHashMap<>(); + if (test.has(REPRESENTATION_FIELD)) { + List values = new ArrayList<>(); + test.get(REPRESENTATION_FIELD).forEach(value -> values.add(textValue(value))); + query.put(REPRESENTATION_FIELD, values); + } + var response = new OdpService(baselineDocument(), endpoints) + .handle(new OdpHttpRequest(GET_METHOD, path, query, Map.of(), null)); + String expected = text(test, EXPECTED_FIELD); + return result( + "400".equals(expected) + ? response.status() == 400 && calls.get() == 0 + : response.status() == 200 && selections.equals(List.of(expected))); + } + + private static Evaluation evaluateAgentSelection(JsonNode test) { + JsonNode representations = test.get(REPRESENTATION_FIELD); + if (representations != null && representations.size() > 1) + return new Evaluation( + SKIPPED_STATUS, + "Repeated query parameters are Service input; Agent APIs accept one representation"); + String representation = representations == null ? null : textValue(representations.get(0)); + List queries = new ArrayList<>(); + ServiceDocument advertised = document(java.util.Arrays.stream(OdpOperation.values()) + .map(operation -> new OperationDescriptor(AuthenticationRequirement.NOT_REQUIRED, operation)) + .toList()); + OdpServiceClient client = OdpServiceClient.create(URI.create(SERVICE_ORIGIN), request -> { + if (Odp.SERVICE_DOCUMENT_PATH.equals(request.uri().getPath())) + return response(request, OdpJson.write(advertised), ODP_MEDIA_TYPE, 200); + queries.add(request.uri().getRawQuery()); + String body = operation(test).startsWith("get-") + ? "{\"odp_version\":\"1.0\",\"id\":\"item\",\"name\":\"Item\"}" + : "{\"odp_version\":\"1.0\",\"items\":[]}"; + return response(request, body, ODP_MEDIA_TYPE, 200); + }); + String expected = text(test, EXPECTED_FIELD); + try { + switch (operation(test)) { + case "get-offering" -> client.getOffering(ITEM_ID, representation, null); + case "get-collection" -> client.getCollection(ITEM_ID, representation, null); + case "list-offerings" -> client.listOfferings(representation, null, null); + case "list-collections" -> client.listCollections(representation, null, null); + case "list-collection-offerings" -> client.listCollectionOfferings(ITEM_ID, representation, null, null); + case "search-collections" -> + client.searchCollections( + OdpJson.parseCollectionSearchRequest("{\"odp_version\":\"1.0\",\"query\":\"example\"}"), + representation, + null); + case SEARCH_OFFERINGS_OPERATION -> + client.searchOfferings( + OdpJson.parseOfferingSearchRequest("{\"odp_version\":\"1.0\",\"query\":\"example\"}"), + representation, + null); + default -> throw new IllegalArgumentException("Unknown representation operation"); + } + } catch (IllegalArgumentException exception) { + return result("400".equals(expected) && queries.isEmpty()); + } + return result(!"400".equals(expected) && queries.equals(List.of("representation=" + expected))); + } + + private static Evaluation evaluateDetailFields(JsonNode test, String role) { + String name = text(test, "name"); + if (!"detail-fields-prohibited-in-full".equals(name)) + return new Evaluation( + SKIPPED_STATUS, + "Only full-response prohibition is enforced; optional detail hint validation and paired-response completeness are not provided"); + JsonNode body = required(test, FULL_REPRESENTATION); + boolean accepted; + if (AGENT_ROLE.equals(role)) { + try { + OdpServiceClient client = reading(body.toString(), ODP_MEDIA_TYPE); + client.getOffering(ITEM_ID, FULL_REPRESENTATION, null); + accepted = true; + } catch (IllegalArgumentException exception) { + accepted = false; + } + } else { + var response = serving(OdpJson.parseTree(body.toString())) + .handle(new OdpHttpRequest( + GET_METHOD, + OFFERING_PATH, + Map.of(REPRESENTATION_FIELD, List.of(FULL_REPRESENTATION)), + Map.of(), + null)); + accepted = response.status() == 200; + if (!accepted && response.status() != 500) return result(false); + } + return result(accepted == valid(test)); + } + + private static Evaluation evaluateVersionPlacement(JsonNode test, String role) { + ObjectNode item = JSON.createObjectNode().put("id", ITEM_ID).put(NAME_FIELD, ITEM_NAME); + if (required(test, "odp_version_present").asBoolean()) item.put(VERSION_FIELD, Odp.VERSION); + boolean topLevel = required(test, "top_level").asBoolean(); + ObjectNode payload = item; + if (!topLevel || "search-response".equals(text(test, NAME_FIELD))) { + payload = JSON.createObjectNode().put(VERSION_FIELD, Odp.VERSION); + payload.putArray(ITEMS_FIELD).add(item); + if (topLevel) payload.putArray(ITEMS_FIELD); + } + boolean page = payload.has(ITEMS_FIELD); + boolean accepted; + if (AGENT_ROLE.equals(role)) { + try { + var client = reading(payload.toString(), ODP_MEDIA_TYPE); + if (page) client.listOfferings(TERSE_REPRESENTATION, null, null); + else client.getOffering(ITEM_ID, FULL_REPRESENTATION, null); + accepted = true; + } catch (IllegalArgumentException | IllegalStateException exception) { + accepted = false; + } + } else { + accepted = serving(OdpJson.parseTree(payload.toString())) + .handle(new OdpHttpRequest( + GET_METHOD, page ? OFFERINGS_PATH : OFFERING_PATH, Map.of(), Map.of(), null)) + .status() + == 200; + } + return result(accepted == valid(test)); + } + + private static Evaluation evaluateMedia(JsonNode test, String role) { + if (test.has(AGENT_ROLE)) { + if (!AGENT_ROLE.equals(role)) return new Evaluation(SKIPPED_STATUS, "Agent response-consumption case"); + String mediaType = text(test, "response_content_type_essence"); + if (test.has("parameters")) { + for (var entry : test.get("parameters").properties()) { + mediaType += "; " + entry.getKey() + "=" + textValue(entry.getValue()); + } + } + ObjectNode payload = JSON.createObjectNode() + .put(VERSION_FIELD, test.has("body_odp_version") ? text(test, "body_odp_version") : Odp.VERSION); + payload.put("id", ITEM_ID).put(NAME_FIELD, ITEM_NAME); + try { + Offering offering = + reading(payload.toString(), mediaType).getOffering(ITEM_ID, FULL_REPRESENTATION, null); + return result("process".equals(text(test, AGENT_ROLE)) + && (!test.has("effective_odp_version") + || text(test, "effective_odp_version").equals(offering.odpVersion()))); + } catch (IllegalArgumentException | IllegalStateException exception) { + return result("reject".equals(text(test, AGENT_ROLE))); + } + } + if (AGENT_ROLE.equals(role)) return new Evaluation(SKIPPED_STATUS, "Service request-negotiation case"); + Map> headers = new LinkedHashMap<>(); + boolean post = test.has("request_content_type_essence") || test.has("request_content_type_missing"); + if (test.has("request_content_type_essence")) + headers.put("Content-Type", List.of(text(test, "request_content_type_essence"))); + if (!post && !test.path("accept_missing").asBoolean()) { + headers.put( + "Accept", + List.of( + test.path("accepts_odp").asBoolean() + ? ODP_MEDIA_TYPE + : test.path("accepts_wildcard").asBoolean() ? "*/*" : "text/html")); + } + var response = serving(new Page<>(null, Odp.VERSION, List.of(), null, Map.of())) + .handle(new OdpHttpRequest( + post ? "POST" : GET_METHOD, + post ? "/odp/offerings/search" : OFFERINGS_PATH, + Map.of(), + headers, + post ? "{\"odp_version\":\"1.0\",\"query\":\"item\"}" : null)); + String expected = text(test, "response"); + return result(response.status() + == (Set.of("process", "serve-odp").contains(expected) ? 200 : Integer.parseInt(expected))); + } + private static Evaluation evaluateComposition(JsonNode test, String role) { if (NORMALIZE_AGENT_RESPONSE.equals(operation(test)) && AGENT_ROLE.equals(role)) { try { @@ -134,7 +794,7 @@ private static Evaluation evaluateComposition(JsonNode test, String role) { required(test, DOCUMENT_FIELD).toString(), required(test, "kind").asString())); validateAgentResponse(actual.toString(), required(test, "kind").asString()); - return result(actual.equals(required(test, "expected"))); + return result(actual.equals(required(test, EXPECTED_FIELD))); } catch (JacksonException exception) { throw new IllegalArgumentException("Unable to decode normalized Agent response", exception); } @@ -158,7 +818,7 @@ private static Evaluation evaluateComposition(JsonNode test, String role) { JsonNode actual = parsed.protocols() == null ? JSON.createObjectNode() : JSON.valueToTree(parsed.protocols()); removeNullProperties(actual); - return result(actual.equals(required(test, "expected"))); + return result(actual.equals(required(test, EXPECTED_FIELD))); } catch (JacksonException exception) { throw new IllegalArgumentException("Unable to encode Agent protocol projection", exception); } @@ -208,7 +868,7 @@ private static Evaluation evaluateAttributeSchema(JsonNode test) { new SchemaResponse( required(test, DOCUMENT_FIELD).toString(), text(test, "content_type"), - required(test, "status").asInt())), + required(test, STATUS_FIELD).asInt())), ROOT_SCHEMA_URL, "{\"name\":\"root\"}", false); @@ -225,7 +885,7 @@ private static Evaluation evaluateAttributeSchema(JsonNode test) { if (rootUrl == null) { rootUrl = url; } - documents.put(url, new SchemaResponse(document.toString(), "application/schema+json", 200)); + documents.put(url, new SchemaResponse(document.toString(), SCHEMA_MEDIA_TYPE, 200)); index++; } var details = attributeSchemaDetails( @@ -233,12 +893,12 @@ private static Evaluation evaluateAttributeSchema(JsonNode test) { yield result((details.details().attributeSchema() != null) == valid(test)); } case "validation-scope" -> { - boolean terse = "terse".equals(text(test, "representation")); + boolean terse = TERSE_REPRESENTATION.equals(text(test, REPRESENTATION_FIELD)); var details = attributeSchemaDetails( Map.of(ROOT_SCHEMA_URL, new SchemaResponse(""" {"$schema":"https://json-schema.org/draft/2020-12/schema", "properties":{"memory":{"type":"number"}},"type":"object"} - """, "application/schema+json", 200)), + """, SCHEMA_MEDIA_TYPE, 200)), ROOT_SCHEMA_URL, "{\"memory\":\"invalid\"}", terse); @@ -259,13 +919,13 @@ yield result(complete false); Map actual = Map.of( "offering_usable", - "item".equals(details.details().offering().id()), + ITEM_ID.equals(details.details().offering().id()), "attributes_usable", details.details().offering().attributes() != null, "report_issue", details.details().issues().stream() .anyMatch(issue -> issue.scope() == OfferingIssue.Scope.ATTRIBUTE_SCHEMA)); Map expected = new LinkedHashMap<>(); - required(test, "expected") + required(test, EXPECTED_FIELD) .properties() .forEach(entry -> expected.put(entry.getKey(), entry.getValue().asBoolean())); @@ -291,7 +951,7 @@ private static AttributeSchemaEvaluation attributeSchemaDetails( "/.well-known/odp".equals(request.uri().getPath()) ? serviceDocument : terse ? "{\"id\":\"item\",\"name\":\"Item\",\"odp_version\":\"1.0\"}" : offering, - "application/odp+json", + ODP_MEDIA_TYPE, 200); AtomicInteger supportingRequests = new AtomicInteger(); OdpTransport supporting = request -> { @@ -301,14 +961,20 @@ private static AttributeSchemaEvaluation attributeSchemaDetails( new SchemaResponse("{\"title\":\"Not Found\"}", "application/problem+json", 404)); return response(request, document.body(), document.contentType(), document.status()); }; - OdpServiceClient client = OdpServiceClient.create(URI.create("https://service.example"), service, supporting); + OdpServiceClient client = OdpServiceClient.create(URI.create(SERVICE_ORIGIN), service, supporting); OfferingDetails details = terse - ? new OfferingDetails(client.getOffering("item", "terse", null), null, List.of(), List.of()) - : client.getOfferingDetails("item", null); + ? new OfferingDetails( + client.getOffering(ITEM_ID, TERSE_REPRESENTATION, null), null, List.of(), List.of()) + : client.getOfferingDetails(ITEM_ID, null); return new AttributeSchemaEvaluation(details, supportingRequests.get()); } private static HttpResponse response(HttpRequest request, String body, String contentType, int status) { + return response(request, body, Map.of("Content-Type", List.of(contentType)), status); + } + + private static HttpResponse response( + HttpRequest request, String body, Map> headers, int status) { return new HttpResponse<>() { @Override public int statusCode() { @@ -327,7 +993,7 @@ public Optional> previousResponse() { @Override public HttpHeaders headers() { - return HttpHeaders.of(Map.of("Content-Type", List.of(contentType)), (name, value) -> true); + return HttpHeaders.of(headers, (name, value) -> true); } @Override @@ -387,7 +1053,7 @@ private static Evaluation evaluateServiceOrigin(JsonNode test) { private static Evaluation evaluateReference(JsonNode test) { boolean actual; try { - OdpUris.resolveResourceReference(text(test, "value"), "https://service.example"); + OdpUris.resolveResourceReference(text(test, "value"), SERVICE_ORIGIN); actual = true; } catch (IllegalArgumentException exception) { actual = false; @@ -395,12 +1061,44 @@ private static Evaluation evaluateReference(JsonNode test) { return result(actual == valid(test)); } - private static Evaluation evaluatePagination(JsonNode test) { + private static Evaluation evaluatePagination(JsonNode test, String role) { return switch (operation(test)) { + case "conditional-get" -> { + if (AGENT_ROLE.equals(role)) + yield new Evaluation(SKIPPED_STATUS, "Agent has no conditional-response cache API"); + OdpService service = serving(new Page<>(null, Odp.VERSION, List.of(), null, Map.of())); + var first = service.handle(new OdpHttpRequest(GET_METHOD, OFFERINGS_PATH, Map.of(), Map.of(), null)); + String etag = first.headers().get("ETag"); + var conditional = service.handle(new OdpHttpRequest( + GET_METHOD, OFFERINGS_PATH, Map.of(), Map.of("If-None-Match", List.of(etag)), null)); + yield result( + conditional.status() == required(test, STATUS_FIELD).asInt() + && conditional.body().isEmpty()); + } case "validate-page" -> parse(test, "page", value -> OdpJson.parsePage(value, JsonNode.class)); case "validate-limit" -> { int limit = required(test, "limit").asInt(); - yield result((limit >= 1 && limit <= 100) == valid(test)); + boolean accepted; + if (AGENT_ROLE.equals(role)) { + try { + reading("{\"odp_version\":\"1.0\",\"items\":[]}", ODP_MEDIA_TYPE) + .listOfferings(TERSE_REPRESENTATION, limit, null); + accepted = true; + } catch (IllegalArgumentException exception) { + accepted = false; + } + } else { + accepted = serving(new Page<>(null, Odp.VERSION, List.of(), null, Map.of())) + .handle(new OdpHttpRequest( + GET_METHOD, + OFFERINGS_PATH, + Map.of("limit", List.of(Integer.toString(limit))), + Map.of(), + null)) + .status() + == 200; + } + yield result(accepted == valid(test)); } case "validate-next" -> { boolean actual; @@ -416,7 +1114,75 @@ private static Evaluation evaluatePagination(JsonNode test) { }; } - private static Evaluation evaluateErrorsAndLimits(JsonNode test) { + private static Evaluation evaluateRetry(JsonNode test) { + int attempt = required(test, "attempt").asInt(); + int status = required(test, STATUS_FIELD).asInt(); + String delay = required(test, "retry_after_seconds").asText(); + AtomicInteger calls = new AtomicInteger(); + OdpServiceClient client = OdpServiceClient.create(URI.create(SERVICE_ORIGIN), request -> { + if (Odp.SERVICE_DOCUMENT_PATH.equals(request.uri().getPath())) { + return response(request, OdpJson.write(baselineDocument()), ODP_MEDIA_TYPE, 200); + } + if (calls.incrementAndGet() <= attempt) { + return response(request, "{}", Map.of("Retry-After", List.of(delay)), status); + } + return response(request, "{\"odp_version\":\"1.0\",\"items\":[]}", ODP_MEDIA_TYPE, 200); + }); + try { + client.listOfferings(TERSE_REPRESENTATION, null, null); + } catch (OdpRequestException exception) { + if (exception.status() != status) { + return result(false); + } + } + return result((calls.get() > attempt) == required(test, RETRY_OPERATION).asBoolean()); + } + + private static Evaluation evaluateErrorsAndLimits(JsonNode test, String role) { + if (LIMIT_FAILURE_SCOPE.equals(operation(test))) { + if (!AGENT_ROLE.equals(role)) { + return new Evaluation(SKIPPED_STATUS, "Item iteration is Agent behavior, not Service behavior"); + } + int count = required(test, "prior_items").asInt(); + AtomicInteger requests = new AtomicInteger(); + var iterator = OdpPagination.iterate( + () -> new Page<>( + null, + Odp.VERSION, + java.util.stream.IntStream.range(0, count).boxed().toList(), + "/next", + Map.of()), + next -> { + requests.incrementAndGet(); + reading(" ".repeat(524_289), ODP_MEDIA_TYPE).listOfferings(TERSE_REPRESENTATION, null, null); + return new Page<>(null, Odp.VERSION, List.of(), null, Map.of()); + }, + Long.MAX_VALUE); + List delivered = new ArrayList<>(); + try { + iterator.forEachRemaining(delivered::add); + return result(false); + } catch (org.offeringprotocol.odp.core.OdpResponseLimitException exception) { + try { + iterator.hasNext(); + return result(false); + } catch (org.offeringprotocol.odp.core.OdpResponseLimitException repeated) { + return result(exception.equals(repeated) + && "RESPONSE_LIMIT_EXCEEDED".equals(exception.code()) + && !exception.retryable() + && requests.get() == 1 + && delivered.size() + == required(required(test, EXPECTED_FIELD), "preserved_items") + .asInt()); + } + } + } + if (RETRY_OPERATION.equals(operation(test))) { + return AGENT_ROLE.equals(role) + ? evaluateRetry(test) + : new Evaluation( + SKIPPED_STATUS, "Automatic retrieval retry is Agent behavior, not Service behavior"); + } if (VALIDATE_PROBLEM.equals(operation(test))) { boolean actual; try { @@ -428,10 +1194,61 @@ private static Evaluation evaluateErrorsAndLimits(JsonNode test) { } return result(actual == valid(test)); } - if (!"validate-limit".equals(operation(test)) || !"request".equals(optionalText(test, "resource"))) { + if (!"validate-limit".equals(operation(test))) { return skipped(); } - return result((serviceRequestStatus(required(test, "bytes").asInt()) == 200) == valid(test)); + int bytes = required(test, "bytes").asInt(); + return switch (text(test, "resource")) { + case REQUEST_FIELD -> + AGENT_ROLE.equals(role) + ? new Evaluation( + SKIPPED_STATUS, + "Vector checks the receiving Service request-byte limit, not an Agent response") + : result((serviceRequestStatus(bytes) == 200) == valid(test)); + case "offering" -> { + String prefix = "{\"odp_version\":\"1.0\",\"id\":\"item\",\"name\":\"Item\",\"padding\":\""; + String body = prefix + "x".repeat(bytes - prefix.length() - 2) + "\"}"; + boolean accepted; + if (AGENT_ROLE.equals(role)) { + try { + reading(body, ODP_MEDIA_TYPE).getOffering(ITEM_ID, FULL_REPRESENTATION, null); + accepted = true; + } catch (IllegalArgumentException | IllegalStateException exception) { + accepted = false; + } + } else { + accepted = serving(OdpJson.parseTree(body)) + .handle(new OdpHttpRequest(GET_METHOD, OFFERING_PATH, Map.of(), Map.of(), null)) + .status() + == 200; + } + yield result(accepted == valid(test)); + } + case "schema_graph" -> { + if (!AGENT_ROLE.equals(role)) + yield new Evaluation(SKIPPED_STATUS, "Service does not retrieve Attribute Schema graphs"); + Map documents = new LinkedHashMap<>(); + int documentCount = 5; + for (int index = 0; index < documentCount; index++) { + ObjectNode schema = JSON.createObjectNode().put(SCHEMA_DIALECT_FIELD, SCHEMA_DIALECT); + if (index + 1 < documentCount) + schema.put("$ref", "https://schemas.example/" + (index + 1) + ".json"); + schema.put(DESCRIPTION_FIELD, ""); + int budget = bytes / documentCount + (index == 0 ? bytes % documentCount : 0); + schema.put( + DESCRIPTION_FIELD, + "x".repeat(budget - schema.toString().length())); + documents.put( + "https://schemas.example/" + index + ".json", + new SchemaResponse(schema.toString(), SCHEMA_MEDIA_TYPE, 200)); + } + var details = attributeSchemaDetails( + documents, "https://schemas.example/0.json", "{\"name\":\"root\"}", false); + yield result((details.details().attributeSchema() != null) == valid(test) + && details.supportingRequests() == documentCount); + } + default -> throw new IllegalArgumentException("Unmapped resource limit"); + }; } private static int serviceRequestStatus(int byteCount) { @@ -456,7 +1273,7 @@ private static int serviceRequestStatus(int byteCount) { "POST", "/odp/offerings/search", Map.of(), - Map.of("Content-Type", List.of("application/odp+json")), + Map.of("Content-Type", List.of(ODP_MEDIA_TYPE)), body)) .status(); } @@ -466,12 +1283,12 @@ private static Evaluation evaluateBaseline(JsonNode test, String role) { return skipped(); } if (AGENT_ROLE.equals(role)) { - Set behaviors = new java.util.HashSet<>(); - required(test, "behaviors").forEach(value -> behaviors.add(textValue(value))); - return result(behaviors.containsAll(AGENT_BASELINE) == valid(test)); + return new Evaluation( + SKIPPED_STATUS, + "An abstract behavior declaration does not execute the Agent implementation; verify individual behavior vectors"); } List operations = new ArrayList<>(); - required(test, "operations") + required(test, OPERATIONS_FIELD) .forEach(value -> operations.add(new OperationDescriptor( AuthenticationRequirement.NOT_REQUIRED, OdpOperation.fromValue(textValue(value))))); boolean actual; @@ -555,7 +1372,54 @@ private static Evaluation result(boolean matches) { } private static Evaluation skipped() { - return new Evaluation("skipped", "No public Java operation maps this vector case"); + return new Evaluation(SKIPPED_STATUS, null); + } + + private static String skipReason(String subject, JsonNode test) { + return switch (subject) { + case "collection-search-contract", "offering-search-contract" -> + "Application-owned search: StaticCatalog does not implement search and custom handlers own matching and ordering"; + case "filter-sort-contract" -> + "Application handlers evaluate expressions against mapped Offering values; SDK validates expressions, not queries"; + case "invalid-edge-handling" -> + "Not implemented: Agent Collection graph traversal; individual Collection retrieval does not traverse edges"; + case "detail-fields" -> + "No paired-representation validator: APIs validate individual responses; custom producers own exhaustive cross-representation projections"; + case "composition-contract" -> + switch (operation(test)) { + case "classify-live-response", "validate-sequence" -> + "Application-owned authentication/payment orchestration; SDK exposes HTTP status and headers without executing those protocols"; + default -> "Vector describes Agent response normalization, not Service publication"; + }; + case "pagination-contract" -> + switch (operation(test)) { + case "validate-sequence" -> + "No arbitrary sequence validator: StaticCatalog generates unique snapshot pages; custom handlers own stable sequences"; + case "validate-lifetime" -> + "StaticCatalog has a fixed continuation lifetime, not a configurable arbitrary-lifetime validator"; + case "validate-storage-model" -> + "A storage-model label is not executable SDK behavior; callers follow opaque next references"; + default -> throw new IllegalArgumentException("Unmapped pagination operation " + operation(test)); + }; + case "errors-limits-contract" -> + "Adapter coverage missing for resource-specific byte budgets; network and schema tests cover the implementation separately"; + case "security-contract" -> + switch (operation(test)) { + case "validate-destination" -> + "Vector requires DNS/peer injection unavailable through the public transport; Apache transport tests exercise the actual connector"; + case "forwarded-sensitive-fields" -> + "Caller transports own credential attachment; SDK has no Action execution or arbitrary sensitive-field forwarding API; SupportingResourceTest checks anonymous supporting requests"; + case "cache-reusable" -> + "Not implemented: HTTP response cache and authentication-context partitioning"; + case "payment-authorized", "action-invocable" -> + "Application-owned execution policy: this SDK resolves Actions but does not execute Actions or authorize payments"; + default -> throw new IllegalArgumentException("Unmapped security operation " + operation(test)); + }; + case "role-baseline" -> "Vector describes the other protocol role"; + default -> + throw new IllegalArgumentException( + "Unmapped conformance subject or operation: " + subject + "/" + operation(test)); + }; } private static String truncate(String value) { diff --git a/tools/odp-conformance/src/main/java/org/offeringprotocol/odp/conformance/InteroperabilityAgent.java b/tools/odp-conformance/src/main/java/org/offeringprotocol/odp/conformance/InteroperabilityAgent.java index 8ec25e1..c9b2c18 100644 --- a/tools/odp-conformance/src/main/java/org/offeringprotocol/odp/conformance/InteroperabilityAgent.java +++ b/tools/odp-conformance/src/main/java/org/offeringprotocol/odp/conformance/InteroperabilityAgent.java @@ -14,7 +14,10 @@ public static void main(String[] arguments) { if (arguments.length != REQUIRED_ARGUMENTS) { throw new IllegalArgumentException("Usage: InteroperabilityAgent SERVICE_URL"); } - OdpServiceClient client = OdpServiceClient.create(URI.create(arguments[0])); + OdpServiceClient client = OdpServiceClient.create( + URI.create(arguments[0]), + OdpServiceClient.localDevelopmentTransport(), + OdpServiceClient.localDevelopmentTransport()); if (client.inspection().document().name().isBlank()) { throw new IllegalStateException("Service name is empty"); } diff --git a/tools/odp-conformance/src/main/java/org/offeringprotocol/odp/conformance/NodeInterop.java b/tools/odp-conformance/src/main/java/org/offeringprotocol/odp/conformance/NodeInterop.java index f61147b..db2a3b1 100644 --- a/tools/odp-conformance/src/main/java/org/offeringprotocol/odp/conformance/NodeInterop.java +++ b/tools/odp-conformance/src/main/java/org/offeringprotocol/odp/conformance/NodeInterop.java @@ -17,7 +17,10 @@ public static void main(String[] arguments) { throw new IllegalArgumentException("Usage: NodeInterop SERVICE_URL"); } String serviceUrl = arguments[0]; - OdpServiceClient client = OdpServiceClient.create(URI.create(serviceUrl)); + OdpServiceClient client = OdpServiceClient.create( + URI.create(serviceUrl), + OdpServiceClient.localDevelopmentTransport(), + OdpServiceClient.localDevelopmentTransport()); if (!"Small Example Store".equals(client.inspection().document().name())) { throw new IllegalStateException("Java Agent inspected an unexpected Node.js Service"); }