diff --git a/README.md b/README.md
index 5014a41..8cc29cf 100644
--- a/README.md
+++ b/README.md
@@ -5,41 +5,43 @@
[](https://openjdk.org/)
[](./LICENSE)
-Official Java software development kits for the
+Official Java software development kit for the
[Offering Discovery Protocol](https://www.offeringprotocol.org/), the open protocol for discovering
Services and navigating their Offerings.
ODP separates Service discovery from catalog discovery. An Agent searches the canonical directory
for candidate Services, inspects each Service's live ODP document, and then navigates or searches
-that Service's Collections and Offerings.
+that Service's Collections and Offerings. Full Offering details can describe structured attributes,
+price previews, images, and executable Actions without forcing every industry into one catalog
+schema.
-## Modules
+## Start here
-| Module | Responsibility |
-| --------------------------------------------- | --------------------------------------------------------------------- |
-| [`odp-core`](./odp-core/README.md) | Protocol models, validation, identity, references, and pagination |
-| [`odp-directory`](./odp-directory/README.md) | Canonical production and sandbox directory client |
-| [`odp-agent`](./odp-agent/README.md) | Directory-to-Service discovery and Agent-oriented catalog workflows |
-| [`odp-service`](./odp-service/README.md) | Service document, catalog operations, and integration helpers |
+Choose the module that matches the role your application implements:
+
+| I am building... | Start with | Responsibility |
+| -------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------- |
+| An Agent, command-line tool, or automation | [`odp-agent`](./odp-agent/README.md) | Directory-to-Service discovery and live catalog navigation |
+| An ODP Service | [`odp-service`](./odp-service/README.md) | Service document, fixed routes, static or storage-backed operations |
+| A directory-only integration | [`odp-directory`](./odp-directory/README.md) | Canonical production or sandbox Service search |
+| An ODP validator or protocol implementation | [`odp-core`](./odp-core/README.md) | Models, bundled schemas, identity, references, and pagination |
+
+All artifacts use Maven group `org.offeringprotocol`, require Java 17 or newer, and are available
+from Maven Central without adding a repository.
Dependencies flow from role modules toward `odp-core`; `odp-agent` composes `odp-directory`.
`odp-core` does not depend on another ODP module, and `odp-service` does not depend on Agent or
directory behavior.
-All artifacts use the Maven group `org.offeringprotocol` and require Java 17 or newer.
-
## Installation
-Applications should depend on the module matching their role. Maven resolves its required ODP
-modules transitively.
-
For an Agent application:
```xml
org.offeringprotocol
odp-agent
- 0.1.0
+ 0.1.1
```
@@ -49,17 +51,110 @@ For a Service integration:
org.offeringprotocol
odp-service
- 0.1.0
+ 0.1.1
```
-Gradle applications use the same coordinates:
+Gradle uses the same coordinates:
```kotlin
-implementation("org.offeringprotocol:odp-agent:0.1.0")
+implementation("org.offeringprotocol:odp-agent:0.1.1")
+```
+
+Maven resolves the required Core and Directory modules transitively. Applications should not add
+every ODP module to one project unless they actually implement multiple roles.
+
+## Agent quick start
+
+`OdpAgent` performs two-stage discovery: it searches the canonical directory and then searches the
+live catalogs of matching Services. A Service failure becomes an `IssueEvent` without discarding
+Offerings returned by other Services.
+
+```java
+import org.offeringprotocol.odp.agent.OdpAgent;
+import org.offeringprotocol.odp.directory.DirectoryClient;
+
+DirectoryClient directory = DirectoryClient.create();
+OdpAgent agent = new OdpAgent(directory);
+
+for (OdpAgent.DiscoveryEvent event : agent.searchOfferings("plants", 10, 10)) {
+ if (event instanceof OdpAgent.OfferingEvent offering) {
+ System.out.printf("%s: %s%n", offering.service().name(), offering.offering().name());
+ } else if (event instanceof OdpAgent.IssueEvent issue) {
+ System.err.printf("%s: %s%n", issue.service().serviceOrigin(), issue.message());
+ }
+}
```
-## Examples
+For a known Service, `OdpServiceClient` inspects `/.well-known/odp`, exposes the advertised
+operations, and provides Collection and Offering list, search, get, and continuation methods. See
+the [Agent integration guide](./odp-agent/README.md) for direct navigation, sandbox selection,
+localization, pagination, and authenticated transport composition.
+
+## Service quick start
+
+The minimum Service integration publishes `/.well-known/odp`, lists Offerings, and retrieves one
+Offering. `StaticCatalog` provides those operations for a small in-memory catalog.
+
+```java
+import java.util.List;
+import org.offeringprotocol.odp.core.OdpJson;
+import org.offeringprotocol.odp.core.Offering;
+import org.offeringprotocol.odp.service.OdpService;
+import org.offeringprotocol.odp.service.StaticCatalog;
+
+Offering offering = OdpJson.parseOffering("""
+ {
+ "odp_version": "1.0",
+ "id": "rubber-plant",
+ "name": "Rubber Plant",
+ "description": "A resilient indoor plant."
+ }
+ """);
+
+OdpService service = OdpService.builder(
+ "Example Plant Store",
+ "Indoor plants selected for homes and offices.",
+ "en",
+ "/odp")
+ .keywords(List.of("plants", "indoor-plants"))
+ .endpoints(StaticCatalog.create(List.of(offering), List.of()))
+ .build();
+```
+
+Adapt the framework's incoming request to `OdpHttpRequest`, pass it to `service.handle(...)`, and
+write the returned `OdpHttpResponse`. Large catalogs provide handlers backed by their own storage
+and indexes instead of materializing the catalog in memory. See the
+[Service integration guide](./odp-service/README.md) and the
+[runnable small Service](./examples/README.md#small-service).
+
+## Protocol composition
+
+ODP advertises AEP enrollment, operation authentication requirements, MPP and x402 payment
+support, and Offering Actions. It does not duplicate those protocols' credential or payment
+semantics.
+
+The default Java Agent transport performs anonymous HTTP requests. Applications inject an
+`OdpTransport` when catalog requests need AEP credentials, MPP, x402, or application-specific
+network policy. The Service runtime advertises authentication requirements but expects the hosting
+application to enforce authentication and payment before or around the ODP handler.
+
+An Offering may describe an Action, but the Java SDK never invokes an Action implicitly. The
+application selects the Action and remains responsible for user approval, authentication, payment,
+and state-changing requests.
+
+## Runtime boundaries
+
+Applications own persistent 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.
+
+## Runnable examples
Run the small Service and Agent examples in separate terminals:
@@ -70,7 +165,7 @@ Run the small Service and Agent examples in separate terminals:
The Agent example explicitly uses a mock directory assembled from reachable Service origins. It
then performs live Service inspection, Offering listing, and full Offering retrieval. See
-[examples/README.md](./examples/README.md) for the complete walkthrough.
+[examples/README.md](./examples/README.md) for the walkthrough and source map.
## Development
@@ -112,7 +207,8 @@ examples, and test vectors.
## Security
-See [SECURITY.md](./SECURITY.md) for vulnerability reporting.
+See [SECURITY.md](./SECURITY.md) for vulnerability reporting. The examples use illustrative
+in-memory catalogs and an explicitly labeled mock directory.
## License
diff --git a/examples/README.md b/examples/README.md
index dd41926..59e7530 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -1,24 +1,50 @@
# Runnable examples
-The examples demonstrate the two ODP integration roles with the public Java modules.
+The examples demonstrate the two ODP integration roles with the same public Java modules published
+to Maven Central. Run all commands from the repository root with Java 17 or newer.
+
+## Source map
+
+| Source | Purpose |
+| ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
+| [`SmallService.java`](./src/main/java/org/offeringprotocol/odp/examples/SmallService.java) | Framework adapter, Service builder, static catalog, and search |
+| [`AgentDiscovery.java`](./src/main/java/org/offeringprotocol/odp/examples/AgentDiscovery.java) | Inspection, terse listing, and full Offering retrieval |
+| [`MockDirectory.java`](./src/main/java/org/offeringprotocol/odp/examples/MockDirectory.java) | Clearly isolated local stand-in for candidate Service discovery |
## Small Service
-The small Service keeps a Collection and two Offerings in memory. It publishes a Service Document,
-supports listing and searching Offerings, returns full Offering details, and exposes a free download
-Action.
+The small Service keeps one Collection and two Offerings in memory. It publishes a Service
+Document, provides the required Offering list and detail operations, adds Offering search, and
+exposes a free download Action.
```sh
./scripts/run-small-service.sh
```
-The Service listens on `http://127.0.0.1:4103` by default. Set `PORT` to use another port.
+The Service listens on `http://127.0.0.1:4103` by default. Set `PORT` to use another port:
+
+```sh
+PORT=4203 ./scripts/run-small-service.sh
+```
+
+At startup it prints the URLs an integrator needs:
+
+```text
+Small ODP Service listening at http://127.0.0.1:4103
+Service Document: http://127.0.0.1:4103/.well-known/odp
+Offerings: http://127.0.0.1:4103/odp/offerings
+```
+
+Every request prints its method, path, and response status. Stop the process with Control-C.
+
+The example uses the JDK `HttpServer` only as a transparent adapter. A Spring, Jakarta, Netty, or
+other integration performs the same conversion into `OdpHttpRequest` and writes the returned
+`OdpHttpResponse` through its own response API.
## Agent discovery
The Agent example composes a mock directory from reachable local Services, prints every inspected
-Service Document, lists terse Offerings, and retrieves each full Offering. The mock directory is
-example infrastructure; it does not call the canonical ODP directory.
+Service Document, lists terse Offerings, and retrieves each full Offering.
Start the small Service in another terminal, then run:
@@ -26,9 +52,50 @@ Start the small Service in another terminal, then run:
./scripts/run-agent-example.sh
```
-Without arguments, the mock directory checks ports 4101, 4102, and 4103. Pass one or more Service
-origins to use a different set:
+Without arguments, the mock directory checks ports 4101, 4102, and 4103 and includes only reachable
+ODP Services. Pass one or more Service origins to use an explicit set:
```sh
./scripts/run-agent-example.sh https://service.example
```
+
+The mock directory is example infrastructure. It does not call or imitate the canonical directory
+API. Production Agent applications use `DirectoryClient.create()` or construct `OdpAgent` with a
+production or sandbox Directory client.
+
+The output separates each discovery stage:
+
+```text
+Mock directory contains 1 reachable ODP Service(s).
+
+ODP Service Document:
+{...}
+
+Terse Offering list:
+{...}
+
+Full Offering agent-guide:
+{...}
+```
+
+## From example to application
+
+For an Agent integration:
+
+1. Replace `MockDirectory` with the canonical `DirectoryClient`.
+2. Choose production or sandbox explicitly when constructing the directory client.
+3. Use `OdpServiceClient` for each selected Service and check its advertised operations.
+4. Supply an `OdpTransport` when ODP requests require AEP, MPP, x402, or application credentials.
+5. Apply application-specific caching, result ceilings, retries, and observability.
+
+For a Service integration:
+
+1. Map the application's catalog data into ODP `Offering` and optional `Collection` records.
+2. Use `StaticCatalog` only when the complete catalog is appropriate for an immutable memory
+ snapshot; otherwise provide storage-backed handlers.
+3. Mount `OdpService` at the well-known path and configured endpoint base.
+4. Enforce authentication, payment, authorization, and rate limits in the hosting HTTP stack.
+5. Publish appropriate cache metadata and operational telemetry through the host application.
+
+See the [Agent guide](../odp-agent/README.md) and
+[Service guide](../odp-service/README.md) for the complete public API boundaries.
diff --git a/examples/pom.xml b/examples/pom.xml
index d31d5f8..532ec0d 100644
--- a/examples/pom.xml
+++ b/examples/pom.xml
@@ -7,7 +7,7 @@
org.offeringprotocol
odp-java
- 0.1.0
+ 0.1.1
odp-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 487286d..80e8075 100644
--- a/examples/src/main/java/org/offeringprotocol/odp/examples/SmallService.java
+++ b/examples/src/main/java/org/offeringprotocol/odp/examples/SmallService.java
@@ -19,7 +19,6 @@
import org.offeringprotocol.odp.core.OdpOperation;
import org.offeringprotocol.odp.core.Offering;
import org.offeringprotocol.odp.core.Page;
-import org.offeringprotocol.odp.core.ServiceDocument;
import org.offeringprotocol.odp.service.OdpHttpRequest;
import org.offeringprotocol.odp.service.OdpHttpResponse;
import org.offeringprotocol.odp.service.OdpService;
@@ -47,7 +46,11 @@ public static void main(String[] arguments) throws IOException {
.toList();
return new Page<>(null, Odp.VERSION, matches, null, Map.of());
}));
- OdpService service = new OdpService(document(), endpoints);
+ OdpService service = OdpService.builder(
+ "ODP Developer Resources", "Free resources for ODP integrators", "en", "/odp")
+ .keywords(List.of("agent", "developer", "documentation"))
+ .endpoints(endpoints)
+ .build();
HttpServer server = HttpServer.create(
new InetSocketAddress("127.0.0.1", port), // NOPMD - The example must remain local-only.
0);
@@ -101,28 +104,6 @@ private static Map> query(String rawQuery) {
return result;
}
- private static ServiceDocument document() {
- return new ServiceDocument(
- Odp.VERSION,
- "ODP Developer Resources",
- "Free resources for ODP integrators",
- null,
- "en",
- List.of("en"),
- null,
- List.of("agent", "developer", "documentation"),
- null,
- null,
- new ServiceDocument.Http("/odp", null),
- null,
- null,
- null,
- null,
- null,
- null,
- Map.of());
- }
-
private static Collection collection() {
return new Collection(
null,
diff --git a/odp-agent/README.md b/odp-agent/README.md
index de717eb..ec12994 100644
--- a/odp-agent/README.md
+++ b/odp-agent/README.md
@@ -1,6 +1,185 @@
# ODP Agent
-Agent-oriented directory-to-Service discovery, catalog navigation, Offering enrichment, and Action
-discovery.
+Agent-oriented directory-to-Service discovery, live Service inspection, catalog navigation, and
+Offering discovery.
-`odp-agent` composes `odp-directory` and the transport-independent contracts in `odp-core`.
+Use `OdpAgent` for a bounded convenience search across multiple Services. Use `OdpServiceClient`
+when the application already knows a Service origin or needs explicit control over inspection,
+capability checks, Collections, Offerings, localization, and continuations.
+
+## Install
+
+```xml
+
+ org.offeringprotocol
+ odp-agent
+ 0.1.1
+
+```
+
+```kotlin
+implementation("org.offeringprotocol:odp-agent:0.1.1")
+```
+
+The Agent module brings in `odp-directory` and `odp-core` transitively.
+
+## Discover Offerings across Services
+
+`OdpAgent` searches the canonical directory, inspects each selected Service, and searches Services
+that advertise `search-offerings`.
+
+```java
+DirectoryClient directory = DirectoryClient.create();
+OdpAgent agent = new OdpAgent(directory);
+
+for (OdpAgent.DiscoveryEvent event : agent.searchOfferings("plants", 10, 10)) {
+ if (event instanceof OdpAgent.OfferingEvent offering) {
+ consume(offering.service(), offering.offering());
+ } else if (event instanceof OdpAgent.IssueEvent issue) {
+ report(issue.service(), issue.message());
+ }
+}
+```
+
+The second argument limits directory Services and the third limits Offerings retained from each
+Service. Both must be from 1 through 100. Results preserve directory order. A failed Service emits
+an `IssueEvent`; it does not discard successful results from other Services.
+
+This convenience method does not reinterpret a Service that lacks `search-offerings` as a listing
+request. Applications that want that fallback should use `DirectoryClient`, inspect each Service,
+and explicitly choose search or list from the advertised operations.
+
+Use the sandbox directory by constructing the Agent with an explicitly selected client:
+
+```java
+OdpAgent agent = new OdpAgent(
+ DirectoryClient.create(DirectoryEnvironment.SANDBOX));
+```
+
+## Inspect one Service
+
+Creating a Service client retrieves `/.well-known/odp`, validates the document, and records the
+Service's advertised operations.
+
+```java
+OdpServiceClient service = OdpServiceClient.create(
+ URI.create("https://service.example"));
+
+ServiceInspection inspection = service.inspection();
+System.out.println(inspection.document().name());
+System.out.println(inspection.document().protocols());
+
+if (inspection.supports(OdpOperation.LIST_OFFERINGS)) {
+ Page page = service.listOfferings("terse", 25, "en");
+ consume(page.items());
+}
+```
+
+The input may be the Service origin or another URL on that origin. Production Services must use
+HTTPS; loopback HTTP is accepted for local development. The client accepts at most five
+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.
+
+## Navigate Collections and Offerings
+
+The client exposes only explicit network operations; it never calls an unadvertised operation.
+
+```java
+Page collections = service.listCollections("terse", 25, "en");
+Collection collection = service.getCollection("indoor-plants", "full", "en");
+Page members = service.listCollectionOfferings(
+ collection.id(), "terse", 25, "en");
+
+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
+`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,
+and refinements:
+
+```java
+SearchRequests.Offerings request = new SearchRequests.Offerings(
+ Odp.VERSION,
+ "indoor plant",
+ null,
+ "indoor-plants",
+ null,
+ null,
+ null,
+ 10);
+
+OfferingPage matches = service.searchOfferings(request, "terse", "en");
+```
+
+Call `searchCollections(...)` with `SearchRequests.Collections` for Collection search. Check
+`inspection.supports(...)` before invoking optional Collection or search operations; the client
+also rejects an unsupported call locally.
+
+## Continue a response
+
+Continuation values are opaque. Pass `next` unchanged to the matching continuation method:
+
+```java
+Page page = service.listOfferings("terse", 25, "en");
+while (page.next() != null) {
+ page = service.continueOfferings(page.next(), "en");
+ consume(page.items());
+}
+```
+
+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.
+
+## 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:
+
+```java
+HttpClient httpClient = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(10))
+ .followRedirects(HttpClient.Redirect.NEVER)
+ .build();
+
+OdpTransport transport = request -> httpClient.send(
+ request,
+ HttpResponse.BodyHandlers.ofByteArray());
+
+OdpServiceClient service = OdpServiceClient.create(
+ URI.create("https://service.example"),
+ transport);
+```
+
+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
+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.
+
+Full Offerings expose their advertised Actions. The Java SDK does not invoke Actions or retrieve
+their supporting JSON Schema or OpenAPI documents. The application remains responsible for Action
+selection, user approval, authentication, payment, and invocation.
+
+## Errors
+
+Non-success Service responses throw `OdpRequestException`, which preserves the HTTP status,
+headers, and parsed ODP Problem Details when supplied. Invalid protocol documents throw
+`OdpValidationException`. Invalid local arguments use `IllegalArgumentException`; unsupported
+operations and transport-boundary failures use `IllegalStateException`.
+
+## Related documentation
+
+- [Directory integration](../odp-directory/README.md)
+- [Core models and validation](../odp-core/README.md)
+- [Runnable Agent example](../examples/README.md#agent-discovery)
+- [Maven Central artifact](https://central.sonatype.com/artifact/org.offeringprotocol/odp-agent)
diff --git a/odp-agent/pom.xml b/odp-agent/pom.xml
index cdb904f..7dfd12d 100644
--- a/odp-agent/pom.xml
+++ b/odp-agent/pom.xml
@@ -7,7 +7,7 @@
org.offeringprotocol
odp-java
- 0.1.0
+ 0.1.1
odp-agent
diff --git a/odp-core/README.md b/odp-core/README.md
index 38cb5e6..92ed97e 100644
--- a/odp-core/README.md
+++ b/odp-core/README.md
@@ -1,6 +1,115 @@
# ODP Core
Transport-independent Offering Discovery Protocol models, validation, identity, resource-reference,
-error, and pagination primitives.
+Problem Details, and pagination primitives.
-`odp-core` does not depend on another ODP module or an application framework.
+Most Agent and Service applications receive `odp-core` transitively through their role module.
+Depend on Core directly when implementing protocol tooling, validating stored documents, or using
+ODP models without Agent or Service HTTP behavior.
+
+## Install
+
+```xml
+
+ org.offeringprotocol
+ odp-core
+ 0.1.1
+
+```
+
+```kotlin
+implementation("org.offeringprotocol:odp-core:0.1.1")
+```
+
+`odp-core` requires Java 17 or newer and does not depend on another ODP module or an application
+framework.
+
+## Validate and decode documents
+
+`OdpJson` validates incoming JSON against the exact ODP schemas bundled in the published JAR before
+decoding it into immutable Java models.
+
+```java
+try {
+ ServiceDocument document = OdpJson.parseServiceDocument(responseBody);
+ use(document);
+} catch (OdpValidationException exception) {
+ for (ValidationIssue issue : exception.issues()) {
+ System.err.printf("%s: %s%n", issue.path(), issue.message());
+ }
+}
+```
+
+Typed parsers are available for Service Documents, Collections, Offerings, Offering search
+responses, search requests, page envelopes, and ODP Problem Details. `OdpJson.write(value)` encodes
+the corresponding Java records while omitting absent optional members. Unknown additive members
+permitted by the protocol are retained in each model's `additional` map.
+
+Validation failures are reported as `OdpValidationException` with a document type and structured
+issues. Invalid local method arguments use `IllegalArgumentException`.
+
+## Build a Service Document
+
+Use `ServiceDocument.builder(...)` when protocol tooling needs to construct a document directly.
+The builder sets the current ODP version and defaults `localizations` to the selected language:
+
+```java
+ServiceDocument document = ServiceDocument.builder(
+ "Example Plant Store",
+ "Indoor plants selected for homes and offices.",
+ "en",
+ new ServiceDocument.Http("/odp", null))
+ .keywords(List.of("plants", "indoor-plants"))
+ .operations(operations)
+ .build();
+```
+
+Service applications should normally use the higher-level `OdpService.builder(...)`, which derives
+the operation descriptors from the handlers the application configures.
+
+## Resource identity and references
+
+`ResourceIdentity` composes the Service origin, resource type, and Service-owned identifier into a
+stable identity suitable for application storage:
+
+```java
+ResourceIdentity identity = ResourceIdentity.create(
+ URI.create("https://service.example/.well-known/odp"),
+ "offering",
+ "gpu-h100");
+```
+
+`OdpUris` derives a Service origin, resolves Service-owned resource references, validates opaque
+continuations, and builds the fixed URL for an advertised operation. Resource references accept
+root-relative paths or secure absolute URLs. Continuations must remain on the Service origin, and
+operation identifiers must be safe local path segments.
+
+## Pagination
+
+ODP continuation values are opaque. Pass each `next` value unchanged to the appropriate page
+loader:
+
+```java
+Page first = client.listOfferings("terse", 25, "en");
+List offerings = OdpPagination.items(
+ first,
+ next -> client.continueOfferings(next, "en"));
+```
+
+`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.
+
+## Payment option vocabulary
+
+`PaymentOption` contains the closed human-facing option vocabulary that a Service can advertise for
+MPP or x402, such as `INFLOW`, `SOLANA`, or `BASE`. These values summarize compatibility for
+discovery and filtering. Live MPP and x402 responses remain authoritative for exact payment terms.
+
+## Related documentation
+
+- [Agent integration](../odp-agent/README.md)
+- [Directory integration](../odp-directory/README.md)
+- [Service integration](../odp-service/README.md)
+- [Normative ODP specifications](https://www.offeringprotocol.org/)
+- [Maven Central artifact](https://central.sonatype.com/artifact/org.offeringprotocol/odp-core)
diff --git a/odp-core/pom.xml b/odp-core/pom.xml
index bde7cb8..3eaaa83 100644
--- a/odp-core/pom.xml
+++ b/odp-core/pom.xml
@@ -7,7 +7,7 @@
org.offeringprotocol
odp-java
- 0.1.0
+ 0.1.1
odp-core
diff --git a/odp-core/src/main/java/org/offeringprotocol/odp/core/ServiceDocument.java b/odp-core/src/main/java/org/offeringprotocol/odp/core/ServiceDocument.java
index aef0641..60e3309 100644
--- a/odp-core/src/main/java/org/offeringprotocol/odp/core/ServiceDocument.java
+++ b/odp-core/src/main/java/org/offeringprotocol/odp/core/ServiceDocument.java
@@ -5,35 +5,361 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.annotation.JsonDeserialize;
+import tools.jackson.databind.annotation.JsonPOJOBuilder;
-public record ServiceDocument(
- @JsonProperty("odp_version") String odpVersion,
- String name,
- String description,
- @JsonProperty("documentation_url") String documentationUrl,
- String language,
- List localizations,
- List mcp,
- List keywords,
- Branding branding,
- List operations,
- Http http,
- Protocols protocols,
- @JsonProperty("payment_origins") List paymentOrigins,
- @JsonProperty("search_capabilities") SearchCapabilities searchCapabilities,
- @JsonProperty("status_url") String statusUrl,
- @JsonProperty("support_url") String supportUrl,
- @JsonProperty("website_url") String websiteUrl,
- @JsonAnySetter @JsonAnyGetter Map additional) {
-
- public ServiceDocument {
- localizations = Copies.list(localizations);
- mcp = Copies.list(mcp);
- keywords = Copies.list(keywords);
- operations = Copies.list(operations);
- paymentOrigins = Copies.list(paymentOrigins);
- additional = Copies.nodes(additional);
+@JsonDeserialize(builder = ServiceDocument.Builder.class)
+public final class ServiceDocument {
+ private final String documentOdpVersion;
+ private final String documentName;
+ private final String documentDescription;
+ private final String documentDocumentationUrl;
+ private final String documentLanguage;
+ private final List documentLocalizations;
+ private final List documentMcp;
+ private final List documentKeywords;
+ private final Branding documentBranding;
+ private final List documentOperations;
+ private final Http documentHttp;
+ private final Protocols documentProtocols;
+ private final List documentPaymentOrigins;
+ private final SearchCapabilities documentSearchCapabilities;
+ private final String documentStatusUrl;
+ private final String documentSupportUrl;
+ private final String documentWebsiteUrl;
+ private final Map documentAdditional;
+
+ private ServiceDocument(Builder builder) {
+ this.documentOdpVersion = builder.configuredOdpVersion;
+ this.documentName = builder.configuredName;
+ this.documentDescription = builder.configuredDescription;
+ this.documentDocumentationUrl = builder.configuredDocumentationUrl;
+ this.documentLanguage = builder.configuredLanguage;
+ this.documentLocalizations = Copies.list(builder.configuredLocalizations);
+ this.documentMcp = Copies.list(builder.configuredMcp);
+ this.documentKeywords = Copies.list(builder.configuredKeywords);
+ this.documentBranding = builder.configuredBranding;
+ this.documentOperations = Copies.list(builder.configuredOperations);
+ this.documentHttp = builder.configuredHttp;
+ this.documentProtocols = builder.configuredProtocols;
+ this.documentPaymentOrigins = Copies.list(builder.configuredPaymentOrigins);
+ this.documentSearchCapabilities = builder.configuredSearchCapabilities;
+ this.documentStatusUrl = builder.configuredStatusUrl;
+ this.documentSupportUrl = builder.configuredSupportUrl;
+ this.documentWebsiteUrl = builder.configuredWebsiteUrl;
+ this.documentAdditional = Copies.nodes(builder.configuredAdditional);
+ }
+
+ public static Builder builder(String name, String description, String language, Http http) {
+ return new Builder()
+ .odpVersion(Odp.VERSION)
+ .name(name)
+ .description(description)
+ .language(language)
+ .localizations(List.of(language))
+ .http(http);
+ }
+
+ public Builder toBuilder() {
+ return new Builder()
+ .odpVersion(documentOdpVersion)
+ .name(documentName)
+ .description(documentDescription)
+ .documentationUrl(documentDocumentationUrl)
+ .language(documentLanguage)
+ .localizations(documentLocalizations)
+ .mcp(documentMcp)
+ .keywords(documentKeywords)
+ .branding(documentBranding)
+ .operations(documentOperations)
+ .http(documentHttp)
+ .protocols(documentProtocols)
+ .paymentOrigins(documentPaymentOrigins)
+ .searchCapabilities(documentSearchCapabilities)
+ .statusUrl(documentStatusUrl)
+ .supportUrl(documentSupportUrl)
+ .websiteUrl(documentWebsiteUrl)
+ .additional(documentAdditional);
+ }
+
+ @JsonProperty("odp_version")
+ public String odpVersion() {
+ return documentOdpVersion;
+ }
+
+ @JsonProperty("name")
+ public String name() {
+ return documentName;
+ }
+
+ @JsonProperty("description")
+ public String description() {
+ return documentDescription;
+ }
+
+ @JsonProperty("documentation_url")
+ public String documentationUrl() {
+ return documentDocumentationUrl;
+ }
+
+ @JsonProperty("language")
+ public String language() {
+ return documentLanguage;
+ }
+
+ @JsonProperty("localizations")
+ public List localizations() {
+ return documentLocalizations;
+ }
+
+ @JsonProperty("mcp")
+ public List mcp() {
+ return documentMcp;
+ }
+
+ @JsonProperty("keywords")
+ public List keywords() {
+ return documentKeywords;
+ }
+
+ @JsonProperty("branding")
+ public Branding branding() {
+ return documentBranding;
+ }
+
+ @JsonProperty("operations")
+ public List operations() {
+ return documentOperations;
+ }
+
+ @JsonProperty("http")
+ public Http http() {
+ return documentHttp;
+ }
+
+ @JsonProperty("protocols")
+ public Protocols protocols() {
+ return documentProtocols;
+ }
+
+ @JsonProperty("payment_origins")
+ public List paymentOrigins() {
+ return documentPaymentOrigins;
+ }
+
+ @JsonProperty("search_capabilities")
+ public SearchCapabilities searchCapabilities() {
+ return documentSearchCapabilities;
+ }
+
+ @JsonProperty("status_url")
+ public String statusUrl() {
+ return documentStatusUrl;
+ }
+
+ @JsonProperty("support_url")
+ public String supportUrl() {
+ return documentSupportUrl;
+ }
+
+ @JsonProperty("website_url")
+ public String websiteUrl() {
+ return documentWebsiteUrl;
+ }
+
+ @JsonAnyGetter
+ public Map additional() {
+ return documentAdditional;
+ }
+
+ @Override
+ public boolean equals(Object value) {
+ if (this == value) {
+ return true;
+ }
+ if (!(value instanceof ServiceDocument other)) {
+ return false;
+ }
+ return Objects.equals(documentOdpVersion, other.documentOdpVersion)
+ && Objects.equals(documentName, other.documentName)
+ && Objects.equals(documentDescription, other.documentDescription)
+ && Objects.equals(documentDocumentationUrl, other.documentDocumentationUrl)
+ && Objects.equals(documentLanguage, other.documentLanguage)
+ && Objects.equals(documentLocalizations, other.documentLocalizations)
+ && Objects.equals(documentMcp, other.documentMcp)
+ && Objects.equals(documentKeywords, other.documentKeywords)
+ && Objects.equals(documentBranding, other.documentBranding)
+ && Objects.equals(documentOperations, other.documentOperations)
+ && Objects.equals(documentHttp, other.documentHttp)
+ && Objects.equals(documentProtocols, other.documentProtocols)
+ && Objects.equals(documentPaymentOrigins, other.documentPaymentOrigins)
+ && Objects.equals(documentSearchCapabilities, other.documentSearchCapabilities)
+ && Objects.equals(documentStatusUrl, other.documentStatusUrl)
+ && Objects.equals(documentSupportUrl, other.documentSupportUrl)
+ && Objects.equals(documentWebsiteUrl, other.documentWebsiteUrl)
+ && Objects.equals(documentAdditional, other.documentAdditional);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(
+ documentOdpVersion,
+ documentName,
+ documentDescription,
+ documentDocumentationUrl,
+ documentLanguage,
+ documentLocalizations,
+ documentMcp,
+ documentKeywords,
+ documentBranding,
+ documentOperations,
+ documentHttp,
+ documentProtocols,
+ documentPaymentOrigins,
+ documentSearchCapabilities,
+ documentStatusUrl,
+ documentSupportUrl,
+ documentWebsiteUrl,
+ documentAdditional);
+ }
+
+ @JsonPOJOBuilder(withPrefix = "")
+ public static final class Builder {
+ private String configuredOdpVersion;
+ private String configuredName;
+ private String configuredDescription;
+ private String configuredDocumentationUrl;
+ private String configuredLanguage;
+ private List configuredLocalizations;
+ private List configuredMcp;
+ private List configuredKeywords;
+ private Branding configuredBranding;
+ private List configuredOperations;
+ private Http configuredHttp;
+ private Protocols configuredProtocols;
+ private List configuredPaymentOrigins;
+ private SearchCapabilities configuredSearchCapabilities;
+ private String configuredStatusUrl;
+ private String configuredSupportUrl;
+ private String configuredWebsiteUrl;
+ private Map configuredAdditional = Map.of();
+
+ private Builder() {}
+
+ @JsonProperty("odp_version")
+ public Builder odpVersion(String value) {
+ this.configuredOdpVersion = value;
+ return this;
+ }
+
+ public Builder name(String value) {
+ this.configuredName = value;
+ return this;
+ }
+
+ public Builder description(String value) {
+ this.configuredDescription = value;
+ return this;
+ }
+
+ @JsonProperty("documentation_url")
+ public Builder documentationUrl(String value) {
+ this.configuredDocumentationUrl = value;
+ return this;
+ }
+
+ public Builder language(String value) {
+ this.configuredLanguage = value;
+ return this;
+ }
+
+ public Builder localizations(List values) {
+ this.configuredLocalizations = copy(values);
+ return this;
+ }
+
+ public Builder mcp(List values) {
+ this.configuredMcp = copy(values);
+ return this;
+ }
+
+ public Builder keywords(List values) {
+ this.configuredKeywords = copy(values);
+ return this;
+ }
+
+ public Builder branding(Branding value) {
+ this.configuredBranding = value;
+ return this;
+ }
+
+ public Builder operations(List values) {
+ this.configuredOperations = copy(values);
+ return this;
+ }
+
+ public Builder http(Http value) {
+ this.configuredHttp = value;
+ return this;
+ }
+
+ public Builder protocols(Protocols value) {
+ this.configuredProtocols = value;
+ return this;
+ }
+
+ @JsonProperty("payment_origins")
+ public Builder paymentOrigins(List values) {
+ this.configuredPaymentOrigins = copy(values);
+ return this;
+ }
+
+ @JsonProperty("search_capabilities")
+ public Builder searchCapabilities(SearchCapabilities value) {
+ this.configuredSearchCapabilities = value;
+ return this;
+ }
+
+ @JsonProperty("status_url")
+ public Builder statusUrl(String value) {
+ this.configuredStatusUrl = value;
+ return this;
+ }
+
+ @JsonProperty("support_url")
+ public Builder supportUrl(String value) {
+ this.configuredSupportUrl = value;
+ return this;
+ }
+
+ @JsonProperty("website_url")
+ public Builder websiteUrl(String value) {
+ this.configuredWebsiteUrl = value;
+ return this;
+ }
+
+ @JsonAnySetter
+ public Builder additional(String name, JsonNode value) {
+ Map values = new java.util.LinkedHashMap<>(configuredAdditional);
+ values.put(name, value);
+ this.configuredAdditional = Map.copyOf(values);
+ return this;
+ }
+
+ public Builder additional(Map values) {
+ this.configuredAdditional = values == null ? Map.of() : Map.copyOf(values);
+ return this;
+ }
+
+ public ServiceDocument build() {
+ return new ServiceDocument(this);
+ }
+
+ private static List copy(List values) {
+ return values == null ? null : List.copyOf(values);
+ }
}
public record Http(@JsonProperty("endpoint_base") String endpointBase, OpenApi openapi) {}
diff --git a/odp-core/src/test/java/org/offeringprotocol/odp/core/OdpJsonTest.java b/odp-core/src/test/java/org/offeringprotocol/odp/core/OdpJsonTest.java
index eba6f3a..d8a247d 100644
--- a/odp-core/src/test/java/org/offeringprotocol/odp/core/OdpJsonTest.java
+++ b/odp-core/src/test/java/org/offeringprotocol/odp/core/OdpJsonTest.java
@@ -6,6 +6,7 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.net.URI;
+import java.util.List;
import org.junit.jupiter.api.Test;
class OdpJsonTest {
@@ -34,6 +35,26 @@ void parsesAndPreservesServiceDocumentExtensions() {
assertTrue(OdpJson.write(document).contains("example_extension"));
}
+ @Test
+ void buildsAndRoundTripsServiceDocuments() {
+ List operations = List.of(
+ new OperationDescriptor(AuthenticationRequirement.NOT_REQUIRED, OdpOperation.GET_OFFERING),
+ new OperationDescriptor(AuthenticationRequirement.NOT_REQUIRED, OdpOperation.LIST_OFFERINGS));
+ ServiceDocument document = ServiceDocument.builder(
+ "Example Service",
+ "An ODP Service built by the Java API.",
+ "en",
+ new ServiceDocument.Http("/odp", null))
+ .keywords(List.of("example"))
+ .operations(operations)
+ .build();
+
+ ServiceDocument decoded = OdpJson.parseServiceDocument(OdpJson.write(document));
+
+ assertEquals(document, decoded);
+ assertEquals(List.of("en"), decoded.localizations());
+ }
+
@Test
void rejectsInvalidServiceDocuments() {
OdpValidationException exception = assertThrows(
diff --git a/odp-directory/README.md b/odp-directory/README.md
index 7c24ae4..4697db9 100644
--- a/odp-directory/README.md
+++ b/odp-directory/README.md
@@ -1,5 +1,123 @@
# ODP Directory
-Canonical production and sandbox directory access for discovering candidate ODP Services.
+The official Java client for discovering candidate Services through the one canonical ODP
+directory. It searches cached Service metadata; it does not search the complete catalogs owned by
+those Services.
-`odp-directory` depends on `odp-core` and does not contain Agent or Service behavior.
+After directory discovery, an Agent inspects each candidate's live ODP document and queries the
+Service's Collections and Offerings with [`odp-agent`](../odp-agent/README.md).
+
+## Install
+
+```xml
+
+ org.offeringprotocol
+ odp-directory
+ 0.1.1
+
+```
+
+```kotlin
+implementation("org.offeringprotocol:odp-directory:0.1.1")
+```
+
+## Search Services
+
+`DirectoryClient.create()` uses the fixed production directory. Search accepts natural-language
+text, deterministic filters, or both.
+
+```java
+import java.util.List;
+import org.offeringprotocol.odp.core.PaymentOption;
+import org.offeringprotocol.odp.directory.DirectoryClient;
+import org.offeringprotocol.odp.directory.DirectoryModels;
+
+DirectoryClient directory = DirectoryClient.create();
+
+DirectoryModels.ServiceFilters filters = new DirectoryModels.ServiceFilters(
+ null,
+ List.of("gpu", "accelerator"),
+ null,
+ List.of(new DirectoryModels.PaymentFilter(
+ null,
+ "mpp",
+ List.of(PaymentOption.INFLOW, PaymentOption.SOLANA))));
+
+DirectoryModels.SearchPage page = directory.searchServices(
+ new DirectoryModels.SearchRequest("compute", filters, 25));
+
+for (DirectoryModels.Service service : page.items()) {
+ System.out.printf("%s: %s%n", service.name(), service.serviceOrigin());
+}
+```
+
+Options within one payment filter are alternatives. The example matches Services that accept
+either InFlow or Solana through MPP. A payment filter with no options matches any Service that
+advertises that payment protocol.
+
+The response includes structured facets for enrollment protocols, keywords, operations, payment
+protocols, and payment options. Use them to refine a user or Agent query without downloading a
+global vocabulary.
+
+## Continue a search
+
+One call returns one page. When `page.next()` is non-null, submit that opaque value unchanged:
+
+```java
+while (page.next() != null) {
+ page = directory.continueSearchServices(page.next());
+ consume(page.items());
+}
+```
+
+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
+item limit when following multiple pages.
+
+## Keyword suggestions
+
+Suggestions let an Agent discover useful keyword vocabulary by prefix:
+
+```java
+List suggestions = directory.suggestServices("gp", 5);
+```
+
+The prefix must contain from 1 through 128 characters; the optional limit must be from 1 through
+25.
+
+## Sandbox and HTTP policy
+
+Select the fixed sandbox directory explicitly:
+
+```java
+DirectoryClient directory = DirectoryClient.create(DirectoryEnvironment.SANDBOX);
+```
+
+| Environment | Canonical origin |
+| ----------- | ------------------------------ |
+| Production | `https://api.inflowpay.ai` |
+| Sandbox | `https://sandbox.inflowpay.ai` |
+
+Callers cannot configure another directory origin. The overload accepting `HttpClient` supports
+application transport policy and testing while preserving the selected canonical origin:
+
+```java
+DirectoryClient directory = DirectoryClient.create(
+ DirectoryEnvironment.PRODUCTION,
+ applicationHttpClient);
+```
+
+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`.
+
+## Related documentation
+
+- [Agent integration](../odp-agent/README.md)
+- [Core models and validation](../odp-core/README.md)
+- [Maven Central artifact](https://central.sonatype.com/artifact/org.offeringprotocol/odp-directory)
diff --git a/odp-directory/pom.xml b/odp-directory/pom.xml
index 50adbe0..d38c725 100644
--- a/odp-directory/pom.xml
+++ b/odp-directory/pom.xml
@@ -7,7 +7,7 @@
org.offeringprotocol
odp-java
- 0.1.0
+ 0.1.1
odp-directory
diff --git a/odp-service/README.md b/odp-service/README.md
index 59db4cf..58c2549 100644
--- a/odp-service/README.md
+++ b/odp-service/README.md
@@ -1,5 +1,199 @@
# ODP Service
-Service document, catalog operation, validation, and HTTP integration helpers for ODP Services.
+Framework-neutral Service document, fixed-route catalog operations, request handling, and Problem
+Details for ODP Services.
-`odp-service` depends on `odp-core` and does not depend on Agent or directory behavior.
+Small Services can expose an immutable in-memory catalog. Large Services provide handlers backed by
+their existing storage and indexes. The runtime invokes one configured operation for each request;
+it does not load, copy, sort, or index a storage-backed catalog.
+
+## Install
+
+```xml
+
+ org.offeringprotocol
+ odp-service
+ 0.1.1
+
+```
+
+```kotlin
+implementation("org.offeringprotocol:odp-service:0.1.1")
+```
+
+The Service module brings in `odp-core` transitively and does not depend on Agent or directory
+behavior.
+
+## Minimum integration
+
+Every ODP Service must list Offerings and retrieve one Offering. `StaticCatalog` supplies those
+required handlers from a small in-memory catalog.
+
+```java
+Offering offering = OdpJson.parseOffering("""
+ {
+ "odp_version": "1.0",
+ "id": "rubber-plant",
+ "name": "Rubber Plant",
+ "description": "A resilient indoor plant."
+ }
+ """);
+
+Map endpoints =
+ StaticCatalog.create(List.of(offering), List.of());
+
+OdpService service = OdpService.builder(
+ "Example Plant Store",
+ "Indoor plants selected for homes and offices.",
+ "en",
+ "/odp")
+ .keywords(List.of("plants", "indoor-plants"))
+ .websiteUrl("https://store.example")
+ .endpoints(endpoints)
+ .build();
+```
+
+The builder sets `odp_version` from this SDK and derives the advertised operations from the
+configured endpoints. The default localization list contains the selected language. Optional
+builder methods configure additional localizations, branding, MCP endpoints, enrollment and payment
+protocols, payment origins, search capabilities, OpenAPI, documentation, support, status, and
+website metadata.
+
+Construction validates the final Service Document. Building without `list-offerings` and
+`get-offering` handlers fails immediately.
+
+## Small catalogs
+
+Adding Collections enables Collection listing, retrieval, and direct Offering membership:
+
+```java
+Map endpoints =
+ StaticCatalog.create(offerings, collections);
+```
+
+The static catalog:
+
+- takes immutable snapshots of the supplied lists;
+- verifies unique Offering and Collection identifiers;
+- 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
+- advertises only the operations supplied by its resources.
+
+The simple overload generates a new continuation signing key when the catalog is created. For
+continuations that must survive a process restart or work across multiple instances, supply the
+same secret key of at least 32 bytes to each instance:
+
+```java
+Map endpoints =
+ StaticCatalog.create(offerings, collections, continuationKey);
+```
+
+Store that key as an application secret. Rotating it intentionally invalidates outstanding
+continuations.
+
+`StaticCatalog` does not implement search. Add a search endpoint when the application has an index
+or another deterministic search implementation:
+
+```java
+Map endpoints =
+ new EnumMap<>(StaticCatalog.create(offerings, collections));
+
+endpoints.put(
+ OdpOperation.SEARCH_OFFERINGS,
+ new OdpService.Endpoint(AuthenticationRequirement.NOT_REQUIRED, request -> {
+ SearchRequests.Offerings search =
+ OdpJson.parseOfferingSearchRequest(request.body());
+ return catalogRepository.searchOfferings(search);
+ }));
+```
+
+The handler returns an ODP-compatible page model. Initial search arrives through POST with the
+validated representation, language, and body available on `CatalogRequest`. A continuation is a GET
+chosen and interpreted by the Service implementation.
+
+## Storage-backed operations
+
+Start with an `EnumMap` and configure only operations the Service
+actually supports. `CatalogRequest` exposes:
+
+| Value | Meaning |
+| ---------------- | ------------------------------------------------------------- |
+| `identifier` | Offering or Collection identifier for detail/member routes |
+| `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 |
+| `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.
+
+## HTTP framework adapter
+
+Mount the same `OdpService` handler so it receives both `/.well-known/odp` and the configured
+endpoint base. Adapt the hosting framework's request and response at the boundary:
+
+```java
+OdpHttpRequest request = new OdpHttpRequest(
+ method,
+ path,
+ queryParameters,
+ requestHeaders,
+ requestBody);
+
+OdpHttpResponse response = service.handle(request);
+
+setStatus(response.status());
+response.headers().forEach(this::setHeader);
+writeBody(response.body());
+```
+
+`queryParameters` and `requestHeaders` are maps from a String to all supplied values. The complete
+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.
+
+## Authentication and payment
+
+Operation authentication defaults to `not-required`. Override advertised requirements without
+rewriting a catalog handler:
+
+```java
+OdpService service = OdpService.builder(name, description, "en", "/odp")
+ .endpoints(endpoints)
+ .protocols(new ServiceDocument.Protocols(
+ List.of(new ServiceDocument.EnrollmentProtocol("aep")), null))
+ .operationAuthentication(Map.of(
+ OdpOperation.GET_OFFERING,
+ AuthenticationRequirement.REQUIRED))
+ .build();
+```
+
+Every overridden operation must have a configured endpoint. The requirement changes the Service
+Document advertisement; it does not authenticate the caller. An `optional` or `required` operation
+also requires the Service Document to advertise an enrollment protocol, as shown above. Enforce API
+keys, AEP credentials, MPP, x402, authorization, and application policy in HTTP middleware before
+invoking `handle(...)`. A live authentication or payment challenge remains authoritative.
+
+## Concurrency and lifecycle
+
+`OdpService` copies its endpoint map and generated Service Document at construction. `StaticCatalog`
+copies the supplied catalog lists and its continuation key. These objects can be shared across
+request threads when custom handlers and their dependencies are themselves thread-safe.
+
+Rebuild the Service when its advertised document or operation set changes. Storage-backed handlers
+can return current catalog data without rebuilding the Service.
+
+## Related documentation
+
+- [Core models and validation](../odp-core/README.md)
+- [Runnable Service example](../examples/README.md#small-service)
+- [Normative ODP specifications](https://www.offeringprotocol.org/)
+- [Maven Central artifact](https://central.sonatype.com/artifact/org.offeringprotocol/odp-service)
diff --git a/odp-service/pom.xml b/odp-service/pom.xml
index ddca995..d5c89eb 100644
--- a/odp-service/pom.xml
+++ b/odp-service/pom.xml
@@ -7,7 +7,7 @@
org.offeringprotocol
odp-java
- 0.1.0
+ 0.1.1
odp-service
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 e76fe86..06a41bf 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,5 +1,6 @@
package org.offeringprotocol.odp.service;
+import java.util.EnumMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -10,7 +11,9 @@
import org.offeringprotocol.odp.core.OdpOperation;
import org.offeringprotocol.odp.core.OperationDescriptor;
import org.offeringprotocol.odp.core.ProblemDetails;
+import org.offeringprotocol.odp.core.SearchCapabilities;
import org.offeringprotocol.odp.core.ServiceDocument;
+import tools.jackson.databind.JsonNode;
/** Framework-neutral ODP Service request handler. */
public final class OdpService {
@@ -23,6 +26,10 @@ public final class OdpService {
private final Map endpoints;
private final String endpointBase;
+ 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) {
Objects.requireNonNull(template, "template");
if (!endpoints.containsKey(OdpOperation.LIST_OFFERINGS) || !endpoints.containsKey(OdpOperation.GET_OFFERING)) {
@@ -34,30 +41,12 @@ public OdpService(ServiceDocument template, Map endpoint
.sorted(Map.Entry.comparingByKey())
.map(entry -> new OperationDescriptor(entry.getValue().authentication(), entry.getKey()))
.toList();
- this.serviceDocument = new ServiceDocument(
- template.odpVersion(),
- template.name(),
- template.description(),
- template.documentationUrl(),
- template.language(),
- template.localizations(),
- template.mcp(),
- template.keywords(),
- template.branding(),
- operations,
- template.http(),
- template.protocols(),
- template.paymentOrigins(),
- template.searchCapabilities(),
- template.statusUrl(),
- template.supportUrl(),
- template.websiteUrl(),
- template.additional());
+ this.serviceDocument = template.toBuilder().operations(operations).build();
OdpJson.parseServiceDocument(OdpJson.write(this.serviceDocument));
}
public ServiceDocument document() {
- return serviceDocument;
+ return serviceDocument.toBuilder().build();
}
public OdpHttpResponse handle(OdpHttpRequest request) {
@@ -112,7 +101,7 @@ private Route route(OdpHttpRequest request) {
if (GET.equals(request.method()) && "/collections".equals(path)) {
return new Route(OdpOperation.LIST_COLLECTIONS, null);
}
- if ("POST".equals(request.method()) && "/collections/search".equals(path)) {
+ if (("POST".equals(request.method()) || GET.equals(request.method())) && "/collections/search".equals(path)) {
return new Route(OdpOperation.SEARCH_COLLECTIONS, null);
}
if (GET.equals(request.method()) && parts.length == 3 && "collections".equals(parts[1])) {
@@ -127,7 +116,7 @@ private Route route(OdpHttpRequest request) {
if (GET.equals(request.method()) && "/offerings".equals(path)) {
return new Route(OdpOperation.LIST_OFFERINGS, null);
}
- if ("POST".equals(request.method()) && "/offerings/search".equals(path)) {
+ if (("POST".equals(request.method()) || GET.equals(request.method())) && "/offerings/search".equals(path)) {
return new Route(OdpOperation.SEARCH_OFFERINGS, null);
}
if (GET.equals(request.method()) && parts.length == 3 && "offerings".equals(parts[1])) {
@@ -177,5 +166,143 @@ public record Endpoint(AuthenticationRequirement authentication, CatalogHandler
}
}
+ public static final class Builder {
+ private final String name;
+ private final String description;
+ private final String language;
+ private final String endpointBase;
+ private String configuredDocumentationUrl;
+ private List configuredLocalizations;
+ private List configuredMcp;
+ private List configuredKeywords;
+ private ServiceDocument.Branding configuredBranding;
+ private ServiceDocument.OpenApi configuredOpenApi;
+ private ServiceDocument.Protocols configuredProtocols;
+ private List configuredPaymentOrigins;
+ private SearchCapabilities configuredSearchCapabilities;
+ private String configuredStatusUrl;
+ private String configuredSupportUrl;
+ private String configuredWebsiteUrl;
+ private Map configuredAdditional = Map.of();
+ private Map configuredEndpoints;
+ private Map configuredOperationAuthentication = Map.of();
+
+ private Builder(String name, String description, String language, String endpointBase) {
+ this.name = Objects.requireNonNull(name, "name");
+ this.description = Objects.requireNonNull(description, "description");
+ this.language = Objects.requireNonNull(language, "language");
+ this.endpointBase = Objects.requireNonNull(endpointBase, "endpointBase");
+ this.configuredLocalizations = List.of(language);
+ }
+
+ public Builder documentationUrl(String value) {
+ this.configuredDocumentationUrl = value;
+ return this;
+ }
+
+ public Builder localizations(List values) {
+ this.configuredLocalizations = List.copyOf(values);
+ return this;
+ }
+
+ public Builder mcp(List values) {
+ this.configuredMcp = List.copyOf(values);
+ return this;
+ }
+
+ public Builder keywords(List values) {
+ this.configuredKeywords = List.copyOf(values);
+ return this;
+ }
+
+ public Builder branding(ServiceDocument.Branding value) {
+ this.configuredBranding = value;
+ return this;
+ }
+
+ public Builder openApi(ServiceDocument.OpenApi value) {
+ this.configuredOpenApi = value;
+ return this;
+ }
+
+ public Builder protocols(ServiceDocument.Protocols value) {
+ this.configuredProtocols = value;
+ return this;
+ }
+
+ public Builder paymentOrigins(List values) {
+ this.configuredPaymentOrigins = List.copyOf(values);
+ return this;
+ }
+
+ public Builder searchCapabilities(SearchCapabilities value) {
+ this.configuredSearchCapabilities = value;
+ return this;
+ }
+
+ public Builder statusUrl(String value) {
+ this.configuredStatusUrl = value;
+ return this;
+ }
+
+ public Builder supportUrl(String value) {
+ this.configuredSupportUrl = value;
+ return this;
+ }
+
+ public Builder websiteUrl(String value) {
+ this.configuredWebsiteUrl = value;
+ return this;
+ }
+
+ public Builder additional(Map values) {
+ this.configuredAdditional = Map.copyOf(values);
+ return this;
+ }
+
+ public Builder endpoints(Map values) {
+ this.configuredEndpoints = Map.copyOf(values);
+ return this;
+ }
+
+ public Builder operationAuthentication(Map values) {
+ this.configuredOperationAuthentication = Map.copyOf(values);
+ return this;
+ }
+
+ public OdpService build() {
+ if (configuredEndpoints == null) {
+ throw new IllegalStateException("endpoints must be configured");
+ }
+ ServiceDocument template = ServiceDocument.builder(
+ name, description, language, new ServiceDocument.Http(endpointBase, configuredOpenApi))
+ .documentationUrl(configuredDocumentationUrl)
+ .localizations(configuredLocalizations)
+ .mcp(configuredMcp)
+ .keywords(configuredKeywords)
+ .branding(configuredBranding)
+ .operations(List.of())
+ .protocols(configuredProtocols)
+ .paymentOrigins(configuredPaymentOrigins)
+ .searchCapabilities(configuredSearchCapabilities)
+ .statusUrl(configuredStatusUrl)
+ .supportUrl(configuredSupportUrl)
+ .websiteUrl(configuredWebsiteUrl)
+ .additional(configuredAdditional)
+ .build();
+ Map configuredEndpoints = new EnumMap<>(OdpOperation.class);
+ configuredEndpoints.putAll(this.configuredEndpoints);
+ configuredOperationAuthentication.forEach((operation, requirement) -> {
+ Endpoint endpoint = configuredEndpoints.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()));
+ });
+ return new OdpService(template, configuredEndpoints);
+ }
+ }
+
private record Route(OdpOperation operation, String identifier) {}
}
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 f3bd563..4d7d0bc 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
@@ -40,21 +40,23 @@ public static Map create(
if (continuationKey.length < MINIMUM_KEY_BYTES) {
throw new IllegalArgumentException("continuationKey must contain at least 32 bytes");
}
+ List catalogOfferings = List.copyOf(offerings);
+ List catalogCollections = List.copyOf(collections);
byte[] key = continuationKey.clone();
- Map offeringsById = unique(offerings, Offering::id, "Offering");
- Map collectionsById = unique(collections, Collection::id, "Collection");
+ Map offeringsById = unique(catalogOfferings, Offering::id, "Offering");
+ Map collectionsById = unique(catalogCollections, Collection::id, "Collection");
Map handlers = new LinkedHashMap<>();
handlers.put(
OdpOperation.LIST_OFFERINGS,
- endpoint(request -> page(offerings, request, StaticCatalog::terseOffering, key)));
+ endpoint(request -> page(catalogOfferings, request, StaticCatalog::terseOffering, key)));
handlers.put(
OdpOperation.GET_OFFERING,
endpoint(request ->
represent(offeringsById.get(request.identifier()), request, StaticCatalog::terseOffering)));
- if (!collections.isEmpty()) {
+ if (!catalogCollections.isEmpty()) {
handlers.put(
OdpOperation.LIST_COLLECTIONS,
- endpoint(request -> page(collections, request, StaticCatalog::terseCollection, key)));
+ endpoint(request -> page(catalogCollections, request, StaticCatalog::terseCollection, key)));
handlers.put(
OdpOperation.GET_COLLECTION,
endpoint(request -> represent(
@@ -63,7 +65,7 @@ public static Map create(
if (!collectionsById.containsKey(request.identifier())) {
return null;
}
- List matches = offerings.stream()
+ List matches = catalogOfferings.stream()
.filter(offering -> offering.collectionIds() != null
&& offering.collectionIds().contains(request.identifier()))
.toList();
diff --git a/odp-service/src/test/java/org/offeringprotocol/odp/service/OdpServiceTest.java b/odp-service/src/test/java/org/offeringprotocol/odp/service/OdpServiceTest.java
index b315a70..8364d01 100644
--- a/odp-service/src/test/java/org/offeringprotocol/odp/service/OdpServiceTest.java
+++ b/odp-service/src/test/java/org/offeringprotocol/odp/service/OdpServiceTest.java
@@ -1,13 +1,18 @@
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.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 {
@@ -30,7 +35,12 @@ void servesTheMinimumStaticCatalog() {
null,
null,
Map.of());
- OdpService service = new OdpService(template(), StaticCatalog.create(List.of(offering), List.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 =
@@ -39,10 +49,20 @@ void servesTheMinimumStaticCatalog() {
assertEquals(200, document.status());
assertTrue(document.body().contains("list-offerings"));
+ assertTrue(document.body().contains("\"authentication\":\"required\""));
assertTrue(list.body().contains("Rubber Plant"));
assertTrue(detail.body().contains("plant-1"));
}
+ @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(
@@ -74,19 +94,51 @@ void boundsRequestBodies() {
assertTrue(exceeded.body().contains("REQUEST_TOO_LARGE"));
}
- private static ServiceDocument template() {
- return new ServiceDocument(
+ @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"));
+ }
+
+ private static Offering offering(String id, String name) {
+ return new Offering(
+ null,
Odp.VERSION,
- "Plant Store",
- "Plants for agents.",
+ id,
+ name,
+ null,
+ null,
null,
- "en",
- List.of("en"),
null,
- List.of("plants"),
null,
- List.of(),
- new ServiceDocument.Http("/odp", null),
null,
null,
null,
@@ -96,6 +148,14 @@ private static ServiceDocument template() {
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);
}
diff --git a/pom.xml b/pom.xml
index 337126c..5bc65b6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
org.offeringprotocol
odp-java
- 0.1.0
+ 0.1.1
pom
Offering Discovery Protocol for Java
diff --git a/scripts/run-conformance.sh b/scripts/run-conformance.sh
index c7a480d..45c0a65 100755
--- a/scripts/run-conformance.sh
+++ b/scripts/run-conformance.sh
@@ -3,7 +3,8 @@ set -eu
specs_dir=${ODP_SPECS_DIR:-../odp-specs}
output_dir=${ODP_CONFORMANCE_OUTPUT:-.conformance/reports}
-implementation_version=${ODP_JAVA_VERSION:-0.1.0}
+implementation_version=${ODP_JAVA_VERSION:-$(./mvnw --quiet --batch-mode --no-transfer-progress \
+ help:evaluate -Dexpression=revision -DforceStdout)}
implementation_version=${implementation_version#v}
./mvnw --quiet --batch-mode --no-transfer-progress -DskipTests install
diff --git a/testdata/consumer/src/main/java/org/offeringprotocol/example/Consumer.java b/testdata/consumer/src/main/java/org/offeringprotocol/example/Consumer.java
index 98da8fe..02ea005 100644
--- a/testdata/consumer/src/main/java/org/offeringprotocol/example/Consumer.java
+++ b/testdata/consumer/src/main/java/org/offeringprotocol/example/Consumer.java
@@ -3,13 +3,31 @@
import java.util.List;
import org.offeringprotocol.odp.agent.OdpAgent;
import org.offeringprotocol.odp.core.Odp;
+import org.offeringprotocol.odp.core.OdpJson;
+import org.offeringprotocol.odp.core.Offering;
import org.offeringprotocol.odp.directory.DirectoryClient;
+import org.offeringprotocol.odp.directory.DirectoryModels;
import org.offeringprotocol.odp.service.OdpService;
+import org.offeringprotocol.odp.service.StaticCatalog;
public final class Consumer {
private Consumer() {}
public static void main(String[] args) {
- System.out.println(List.of(Odp.class, DirectoryClient.class, OdpAgent.class, OdpService.class));
+ DirectoryClient directory = DirectoryClient.create();
+ OdpAgent agent = new OdpAgent(directory);
+ DirectoryModels.SearchRequest request = new DirectoryModels.SearchRequest("plants", null, 10);
+ Offering offering = OdpJson.parseOffering("""
+ {
+ "odp_version": "1.0",
+ "id": "rubber-plant",
+ "name": "Rubber Plant"
+ }
+ """);
+ OdpService service = OdpService.builder(
+ "Example Plant Store", "Indoor plants.", "en", "/odp")
+ .endpoints(StaticCatalog.create(List.of(offering), List.of()))
+ .build();
+ System.out.println(List.of(Odp.VERSION, agent, request, service.document()));
}
}
diff --git a/tools/odp-conformance/pom.xml b/tools/odp-conformance/pom.xml
index 780d1dd..bc041b3 100644
--- a/tools/odp-conformance/pom.xml
+++ b/tools/odp-conformance/pom.xml
@@ -7,7 +7,7 @@
org.offeringprotocol
odp-java
- 0.1.0
+ 0.1.1
../../pom.xml
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 8233367..40afbfa 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
@@ -231,25 +231,10 @@ private static Evaluation evaluateBaseline(JsonNode test, String role) {
}
private static ServiceDocument document(List operations) {
- return new ServiceDocument(
- Odp.VERSION,
- "Conformance Service",
- "ODP conformance Service",
- null,
- "en",
- List.of("en"),
- null,
- null,
- null,
- operations,
- new ServiceDocument.Http("/odp", null),
- null,
- null,
- null,
- null,
- null,
- null,
- Map.of());
+ return ServiceDocument.builder(
+ "Conformance Service", "ODP conformance Service", "en", new ServiceDocument.Http("/odp", null))
+ .operations(operations)
+ .build();
}
private static Evaluation parse(JsonNode test, String field, Parser parser) {