Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}));
Expand Down Expand Up @@ -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());
Expand All @@ -104,6 +109,31 @@ private static Map<String, List<String>> 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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<OdpOperation, OdpService.Endpoint>(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<String, List<String>> 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);
}
}
}
115 changes: 99 additions & 16 deletions odp-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -96,7 +106,8 @@ Page<Offering> 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,
Expand Down Expand Up @@ -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");
Expand All @@ -165,51 +179,115 @@ 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<Offering> 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<Offering> 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"),
transport);
```

The transport receives the complete ODP `HttpRequest` and must return an
`HttpResponse<byte[]>`. An application that supports AEP, MPP, or x402 replaces the lambda with its
`HttpResponse<byte[]>`. 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
Expand All @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions odp-agent/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
</properties>

<dependencies>
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
<version>${httpclient5.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>odp-core</artifactId>
Expand Down
Loading
Loading