From 0d3ee26e24c9f58920d6d1af9b9f6676de7488bc Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 28 Apr 2026 22:48:48 -0700 Subject: [PATCH] clients/java: initial Java SDK for clawdforge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Java 17, Maven, JDK java.net.http.HttpClient, Jackson 2.x - ForgeClient (builder), records for RunResult / FileToken / AppToken / HealthStatus - ApiException / AuthException / TransportException all extend ForgeException (RuntimeException) — checked exceptions feel un-modern in Java 17 - Multipart upload streams from disk via BodyPublishers.ofByteArrays - 14 JUnit 5 tests against in-process com.sun.net.httpserver — zero test deps beyond JUnit - mvn package / mvn test / mvn javadoc:javadoc clean - snake_case wire format mapped to camelCase Java accessors via @JsonProperty --- .gitignore | 3 + clients/java/README.md | 194 +++++++ clients/java/examples/Basic.java | 79 +++ clients/java/pom.xml | 99 ++++ .../main/java/com/clawdforge/AppToken.java | 35 ++ .../main/java/com/clawdforge/FileToken.java | 29 ++ .../main/java/com/clawdforge/ForgeClient.java | 472 ++++++++++++++++++ .../java/com/clawdforge/HealthStatus.java | 29 ++ .../main/java/com/clawdforge/RunRequest.java | 124 +++++ .../main/java/com/clawdforge/RunResult.java | 42 ++ .../clawdforge/exception/ApiException.java | 54 ++ .../clawdforge/exception/AuthException.java | 19 + .../clawdforge/exception/ForgeException.java | 45 ++ .../exception/TransportException.java | 31 ++ .../java/com/clawdforge/ForgeClientTest.java | 314 ++++++++++++ 15 files changed, 1569 insertions(+) create mode 100644 clients/java/README.md create mode 100644 clients/java/examples/Basic.java create mode 100644 clients/java/pom.xml create mode 100644 clients/java/src/main/java/com/clawdforge/AppToken.java create mode 100644 clients/java/src/main/java/com/clawdforge/FileToken.java create mode 100644 clients/java/src/main/java/com/clawdforge/ForgeClient.java create mode 100644 clients/java/src/main/java/com/clawdforge/HealthStatus.java create mode 100644 clients/java/src/main/java/com/clawdforge/RunRequest.java create mode 100644 clients/java/src/main/java/com/clawdforge/RunResult.java create mode 100644 clients/java/src/main/java/com/clawdforge/exception/ApiException.java create mode 100644 clients/java/src/main/java/com/clawdforge/exception/AuthException.java create mode 100644 clients/java/src/main/java/com/clawdforge/exception/ForgeException.java create mode 100644 clients/java/src/main/java/com/clawdforge/exception/TransportException.java create mode 100644 clients/java/src/test/java/com/clawdforge/ForgeClientTest.java diff --git a/.gitignore b/.gitignore index 26906c7..8208609 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ runs/ # Rust clients/rust/target/ clients/rust/Cargo.lock + +# Java +clients/java/target/ diff --git a/clients/java/README.md b/clients/java/README.md new file mode 100644 index 0000000..6cebac3 --- /dev/null +++ b/clients/java/README.md @@ -0,0 +1,194 @@ +# clawdforge-client (Java) + +Java SDK for [clawdforge](../../README.md) — a LAN-only HTTP service that wraps +`claude -p` subprocess calls behind a bearer-token-gated REST API. + +- **Java 17+** (records, sealed-friendly, switch expressions) +- **HTTP**: JDK built-in `java.net.http.HttpClient` — no Apache HttpClient, no OkHttp +- **JSON**: Jackson (`jackson-databind` 2.x) +- **Build**: Maven +- **License**: MIT + +## Install + +This SDK isn't published to Maven Central. Build from source and install +to your local Maven repo: + +```sh +git clone http://192.168.0.5:3001/Sulkta-Coop/clawdforge.git +cd clawdforge/clients/java +mvn install +``` + +Then depend on it: + +```xml + + com.clawdforge + clawdforge-client + 0.1.0 + +``` + +## Quickstart + +```java +import com.clawdforge.ForgeClient; +import com.clawdforge.RunRequest; +import com.clawdforge.RunResult; +import com.fasterxml.jackson.databind.JsonNode; + +ForgeClient client = ForgeClient.builder() + .baseUrl("http://192.168.0.5:8800") + .token(System.getenv("CLAWDFORGE_TOKEN")) + .build(); + +RunResult res = client.run(RunRequest.builder() + .prompt("Reply with JSON: {\"hello\": \"world\"}") + .model("sonnet") + .timeoutSecs(60) + .build()); + +System.out.println(res.durationMs() + "ms"); + +JsonNode r = res.result(); +if (r.isObject()) { + System.out.println(r.get("hello").asText()); +} +``` + +## Naming & wire format + +The clawdforge HTTP API uses **`snake_case`** on the wire +(`timeout_secs`, `file_token`, `created_at`, ...). This SDK uses idiomatic +Java **`camelCase`** everywhere; Jackson `@JsonProperty` annotations on each +record handle the mapping. Examples: + +| Java accessor | Wire field | +|---------------------------|--------------------| +| `RunRequest#timeoutSecs` | `timeout_secs` | +| `RunResult#durationMs` | `duration_ms` | +| `RunResult#stopReason` | `stop_reason` | +| `FileToken#fileToken` | `file_token` | +| `FileToken#ttlSecs` | `ttl_secs` | +| `HealthStatus#claudePresent` | `claude_present` | +| `HealthStatus#claudeVersion` | `claude_version` | +| `AppToken#ipCidrs` | `ip_cidrs` | +| `AppToken#createdAt` | `created_at` | + +## Public surface + +### `ForgeClient` + +Build via `ForgeClient.builder()`: + +```java +ForgeClient client = ForgeClient.builder() + .baseUrl("http://192.168.0.5:8800") // trailing slash trimmed + .token("cf_...") // required bearer token + .defaultTimeout(Duration.ofSeconds(120)) // optional, default 120s + .httpClient(myCustomHttpClient) // optional, default is JDK default + .build(); +``` + +Methods (all may throw subclasses of `ForgeException`): + +| Method | Endpoint | Returns | +|---|---|---| +| `healthz()` | `GET /healthz` | `HealthStatus` | +| `run(RunRequest)` | `POST /run` | `RunResult` | +| `uploadFile(Path, int ttlSecs)` | `POST /files` | `FileToken` | +| `createToken(String name, List ipCidrs)` | `POST /admin/tokens` | `AppToken` | +| `listTokens()` | `GET /admin/tokens` | `List` | +| `revokeToken(String name)` | `DELETE /admin/tokens/` | `void` | + +### `RunRequest` + +Build via `RunRequest.builder()`: + +```java +RunRequest req = RunRequest.builder() + .prompt("...") // required + .model("sonnet") // optional, server default = "sonnet" + .system("You are ...") // optional system prompt + .files(List.of("ff_...")) // optional file tokens from uploadFile() + .timeoutSecs(60) // optional, server clamps to [5, 600] + .build(); +``` + +### Records + +Public state is exposed via Java 17 records: + +- `HealthStatus(boolean ok, boolean claudePresent, String claudeVersion)` +- `RunResult(boolean ok, JsonNode result, long durationMs, String stopReason)` +- `FileToken(String fileToken, int ttlSecs, long size)` +- `AppToken(String name, String token, List ipCidrs, long createdAt)` + +`RunResult.result()` is a Jackson `JsonNode` because the server may return +either a structured JSON value (when the prompt asked for JSON) or a plain +JSON string. Narrow with `isObject()` / `isTextual()` / etc. + +## Error model — why unchecked? + +All SDK errors extend **`ForgeException`**, which extends `RuntimeException`. +Checked exceptions feel un-modern in Java 17 — they bleed across API +boundaries, force `throws` chains in lambdas, and don't pair well with the +records / sealed-interface idioms the rest of this SDK leans on. + +``` +ForgeException (RuntimeException) +├── ApiException (any non-2xx HTTP) +│ └── AuthException (401 / 403) +└── TransportException (DNS, connect, IO, decode, timeout) +``` + +Catch order: + +```java +try { + client.run(req); +} catch (AuthException e) { + // token revoked, IP not allowed +} catch (ApiException e) { + // 502 from /run failure, 404 from missing token, etc. + // e.statusCode() and e.body() are both available +} catch (TransportException e) { + // network / IO / JSON-decode failures +} catch (ForgeException e) { + // catch-all for anything else SDK-level +} +``` + +## File uploads + +`uploadFile` streams the file from disk in 1 MiB chunks via +`HttpRequest.BodyPublishers.ofByteArrays` rather than slurping the whole +file into memory: + +```java +FileToken ft = client.uploadFile(Path.of("./recipe.png"), 3600); +client.run(RunRequest.builder() + .prompt("Extract recipe data from the attached image.") + .files(List.of(ft.fileToken())) + .build()); +``` + +`ttlSecs` is clamped server-side to `[60, 86400]`. Pass `0` to use the +server default of 3600. + +## Build + +```sh +mvn package # produces target/clawdforge-client-0.1.0.jar +mvn test # runs the JUnit 5 suite +mvn javadoc:javadoc +``` + +The test suite uses the JDK built-in `com.sun.net.httpserver.HttpServer` +in-process — no external mock-server dependency. + +## Threading + +`ForgeClient` instances are immutable and safe to share across threads. +Construct once, reuse everywhere. diff --git a/clients/java/examples/Basic.java b/clients/java/examples/Basic.java new file mode 100644 index 0000000..79a293a --- /dev/null +++ b/clients/java/examples/Basic.java @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT +// +// Build & run from clients/java/: +// mvn -q package +// javac -cp target/clawdforge-client-0.1.0.jar:$(ls ~/.m2/repository/com/fasterxml/jackson/core/jackson-databind/*/jackson-databind-*.jar | head -1):$(ls ~/.m2/repository/com/fasterxml/jackson/core/jackson-core/*/jackson-core-*.jar | head -1):$(ls ~/.m2/repository/com/fasterxml/jackson/core/jackson-annotations/*/jackson-annotations-*.jar | head -1) -d /tmp/cf-example examples/Basic.java +// java -cp /tmp/cf-example:target/classes: Basic +// +// Or just import the jar into your own project and run from there. + +import com.clawdforge.AppToken; +import com.clawdforge.FileToken; +import com.clawdforge.ForgeClient; +import com.clawdforge.HealthStatus; +import com.clawdforge.RunRequest; +import com.clawdforge.RunResult; +import com.fasterxml.jackson.databind.JsonNode; + +import java.nio.file.Path; +import java.util.List; + +public class Basic { + public static void main(String[] args) { + String baseUrl = System.getenv().getOrDefault("CLAWDFORGE_URL", "http://localhost:8800"); + String token = System.getenv("CLAWDFORGE_TOKEN"); + if (token == null || token.isBlank()) { + System.err.println("set CLAWDFORGE_TOKEN"); + System.exit(2); + } + + ForgeClient client = ForgeClient.builder() + .baseUrl(baseUrl) + .token(token) + .build(); + + // 1. health + HealthStatus h = client.healthz(); + System.out.printf("ok=%s claude_present=%s version=%s%n", + h.ok(), h.claudePresent(), h.claudeVersion()); + + // 2. run a prompt asking for JSON + RunResult res = client.run(RunRequest.builder() + .prompt("Reply with JSON: {\"hello\": \"world\"}") + .model("sonnet") + .timeoutSecs(60) + .build()); + + System.out.printf("duration=%dms stop_reason=%s%n", res.durationMs(), res.stopReason()); + JsonNode r = res.result(); + if (r.isObject() && r.has("hello")) { + System.out.println("hello -> " + r.get("hello").asText()); + } else if (r.isTextual()) { + System.out.println("text -> " + r.asText()); + } else { + System.out.println("raw -> " + r); + } + + // 3. (optional) upload + reference a file + String filePath = System.getenv("CLAWDFORGE_FILE"); + if (filePath != null && !filePath.isBlank()) { + FileToken ft = client.uploadFile(Path.of(filePath), 3600); + System.out.println("uploaded " + ft.fileToken() + " (" + ft.size() + " bytes)"); + + RunResult res2 = client.run(RunRequest.builder() + .prompt("Summarize the attached file in one sentence.") + .files(List.of(ft.fileToken())) + .build()); + System.out.println("summary -> " + res2.result()); + } + + // 4. (admin) list tokens — only works with the admin bootstrap token + if (Boolean.parseBoolean(System.getenv().getOrDefault("CLAWDFORGE_ADMIN", "false"))) { + List tokens = client.listTokens(); + for (AppToken t : tokens) { + System.out.printf("token name=%s created_at=%d ip_cidrs=%s%n", + t.name(), t.createdAt(), t.ipCidrs()); + } + } + } +} diff --git a/clients/java/pom.xml b/clients/java/pom.xml new file mode 100644 index 0000000..5d76a67 --- /dev/null +++ b/clients/java/pom.xml @@ -0,0 +1,99 @@ + + + 4.0.0 + + com.clawdforge + clawdforge-client + 0.1.0 + jar + + clawdforge-client + + Java SDK for the clawdforge LAN-only HTTP service that wraps + `claude -p` subprocess calls behind a bearer-token-gated REST API. + + + + + MIT License + https://opensource.org/licenses/MIT + repo + + + + + 17 + 17 + UTF-8 + 2.17.2 + 5.10.2 + + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 17 + 17 + + -Xlint:all + -Werror + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.1 + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.6.3 + + 17 + all,-missing + true + + **/exception/package-info.java + + + + + + diff --git a/clients/java/src/main/java/com/clawdforge/AppToken.java b/clients/java/src/main/java/com/clawdforge/AppToken.java new file mode 100644 index 0000000..0d6b776 --- /dev/null +++ b/clients/java/src/main/java/com/clawdforge/AppToken.java @@ -0,0 +1,35 @@ +package com.clawdforge; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * One entry from {@code GET /admin/tokens}, also returned (with the plaintext + * {@code token} populated) by {@code POST /admin/tokens}. + * + *

The plaintext is shown once at create time — the server + * stores only a SHA-256 hash, so a lost plaintext means revoking and minting + * a new one. + * + * @param name app-level identifier, e.g. {@code "cauldron"} + * @param token plaintext token, populated only on create; {@code null} on list + * @param ipCidrs per-app IP allowlist, may be empty (global allowlist still applies) + * @param createdAt unix-second creation timestamp; 0 on create response + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record AppToken( + @JsonProperty("name") String name, + @JsonProperty("token") String token, + @JsonProperty("ip_cidrs") List ipCidrs, + @JsonProperty("created_at") long createdAt) { + + /** + * Jackson-friendly canonical constructor. + */ + @JsonCreator + public AppToken { + } +} diff --git a/clients/java/src/main/java/com/clawdforge/FileToken.java b/clients/java/src/main/java/com/clawdforge/FileToken.java new file mode 100644 index 0000000..e1d4fb0 --- /dev/null +++ b/clients/java/src/main/java/com/clawdforge/FileToken.java @@ -0,0 +1,29 @@ +package com.clawdforge; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Parsed response from {@code POST /files}. + * + *

The {@code fileToken} value is a server-issued opaque handle (prefixed + * {@code ff_}) that may be passed in {@link RunRequest#files()} on subsequent + * {@code /run} calls. File tokens are scoped to the uploading app — token A + * cannot reference token B's files. + * + * @param fileToken server-issued opaque handle, prefixed {@code ff_} + * @param ttlSecs time-to-live in seconds (clamped server-side to [60, 86400]) + * @param size size of the staged file in bytes + */ +public record FileToken( + @JsonProperty("file_token") String fileToken, + @JsonProperty("ttl_secs") int ttlSecs, + @JsonProperty("size") long size) { + + /** + * Jackson-friendly canonical constructor. + */ + @JsonCreator + public FileToken { + } +} diff --git a/clients/java/src/main/java/com/clawdforge/ForgeClient.java b/clients/java/src/main/java/com/clawdforge/ForgeClient.java new file mode 100644 index 0000000..1c004b5 --- /dev/null +++ b/clients/java/src/main/java/com/clawdforge/ForgeClient.java @@ -0,0 +1,472 @@ +package com.clawdforge; + +import com.clawdforge.exception.ApiException; +import com.clawdforge.exception.AuthException; +import com.clawdforge.exception.ForgeException; +import com.clawdforge.exception.TransportException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublisher; +import java.net.http.HttpRequest.BodyPublishers; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Java SDK client for the LAN-only clawdforge HTTP service. + * + *

Construct via {@link #builder()}; instances are immutable and safe for + * concurrent use. + * + *

Wire format note. The clawdforge API uses + * {@code snake_case} on the wire (e.g. {@code timeout_secs}, {@code file_token}). + * This SDK uses idiomatic Java {@code camelCase} everywhere; the snake_case + * mapping is handled by Jackson {@code @JsonProperty} annotations on each + * record. + * + *

Example: + *

{@code
+ * ForgeClient client = ForgeClient.builder()
+ *     .baseUrl("http://192.168.0.5:8800")
+ *     .token(System.getenv("CLAWDFORGE_TOKEN"))
+ *     .build();
+ *
+ * RunResult res = client.run(RunRequest.builder()
+ *     .prompt("Reply with JSON: {\"hello\": \"world\"}")
+ *     .model("sonnet")
+ *     .timeoutSecs(60)
+ *     .build());
+ *
+ * System.out.println(res.durationMs());
+ * }
+ */ +public final class ForgeClient { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final String baseUrl; + private final String token; + private final HttpClient httpClient; + private final Duration defaultTimeout; + + private ForgeClient(Builder b) { + this.baseUrl = stripTrailingSlash(Objects.requireNonNull(b.baseUrl, "baseUrl")); + this.token = Objects.requireNonNull(b.token, "token"); + this.defaultTimeout = b.defaultTimeout == null ? Duration.ofSeconds(120) : b.defaultTimeout; + this.httpClient = b.httpClient != null + ? b.httpClient + : HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + } + + /** + * @return a fresh builder for {@link ForgeClient} + */ + public static Builder builder() { + return new Builder(); + } + + // ---------- public API -------------------------------------------------- + + /** + * Issues {@code GET /healthz}. The bearer token is sent but not strictly + * required by the server; the caller's IP must satisfy the global + * allowlist regardless. + * + * @return parsed health status + * @throws AuthException on HTTP 401/403 + * @throws ApiException on any other non-2xx response + * @throws TransportException on transport / decoding failure + */ + public HealthStatus healthz() { + HttpRequest req = newRequest("/healthz", "GET", BodyPublishers.noBody(), null); + return send(req, HealthStatus.class); + } + + /** + * Issues {@code POST /run}. + * + *

Note on timeouts: {@code body.timeoutSecs()} controls the upstream + * subprocess timeout. The HTTP-level timeout adds a 30 second margin over + * that so this client doesn't bail while the server is still doing useful + * work. If {@code body.timeoutSecs()} is {@code null}, the configured + * default is used. + * + * @param body run request (must be non-null; {@code prompt} is required) + * @return parsed run result on HTTP 200 + * @throws AuthException on HTTP 401/403 + * @throws ApiException on HTTP 502 (run failure) or other non-2xx + * @throws TransportException on transport / decoding failure + */ + public RunResult run(RunRequest body) { + Objects.requireNonNull(body, "body"); + byte[] payload = serialize(body); + Duration httpTimeout = httpTimeoutFor(body.timeoutSecs()); + HttpRequest req = newRequest( + "/run", + "POST", + BodyPublishers.ofByteArray(payload), + "application/json", + httpTimeout); + return send(req, RunResult.class); + } + + /** + * Streams a file from disk to {@code POST /files} as + * {@code multipart/form-data} and returns the resulting file token. + * + *

The file is read from disk in chunks via + * {@link BodyPublishers#ofByteArrays} rather than slurped fully into + * memory. + * + * @param path local file to upload + * @param ttlSecs time-to-live in seconds; server clamps to [60, 86400]. + * Pass {@code 0} to use the server default of 3600. + * @return parsed file-token response + * @throws AuthException on HTTP 401/403 + * @throws ApiException on any other non-2xx response + * @throws TransportException on transport / IO / decoding failure + */ + public FileToken uploadFile(Path path, int ttlSecs) { + Objects.requireNonNull(path, "path"); + if (!Files.isRegularFile(path)) { + throw new TransportException("not a regular file: " + path); + } + String filename = path.getFileName().toString(); + String boundary = generateBoundary(); + + BodyPublisher publisher; + try { + publisher = multipartFromFile(boundary, path, filename, ttlSecs); + } catch (IOException e) { + throw new TransportException("read " + path, e); + } + + HttpRequest req = newRequest( + "/files", + "POST", + publisher, + "multipart/form-data; boundary=" + boundary, + defaultTimeout); + return send(req, FileToken.class); + } + + /** + * Mints a new per-app token. Plaintext is in + * {@link AppToken#token()} and will not be retrievable again. + * + * @param name short slug (lowercase alphanumerics, dash/underscore) + * @param ipCidrs optional per-app IP allowlist; may be empty/null + * @return the freshly-minted token (with {@code token} populated) + * @throws AuthException if not using the admin bootstrap token + * @throws ApiException on any other non-2xx + * @throws TransportException on transport / decoding failure + */ + public AppToken createToken(String name, List ipCidrs) { + Objects.requireNonNull(name, "name"); + AppToken body = new AppToken(name, null, ipCidrs == null ? List.of() : ipCidrs, 0L); + byte[] payload = serialize(body); + HttpRequest req = newRequest( + "/admin/tokens", + "POST", + BodyPublishers.ofByteArray(payload), + "application/json"); + return send(req, AppToken.class); + } + + /** + * Lists configured app tokens (no plaintexts). + * + * @return list of {@link AppToken} (with {@code token} unset) + * @throws AuthException if not using the admin bootstrap token + * @throws ApiException on any other non-2xx + * @throws TransportException on transport / decoding failure + */ + public List listTokens() { + HttpRequest req = newRequest("/admin/tokens", "GET", BodyPublishers.noBody(), null); + HttpResponse resp = sendRaw(req); + ensure2xx(req, resp); + try { + JsonNode root = MAPPER.readTree(resp.body()); + JsonNode arr = root.path("tokens"); + List out = new ArrayList<>(); + if (arr.isArray()) { + for (JsonNode n : arr) { + out.add(MAPPER.treeToValue(n, AppToken.class)); + } + } + return out; + } catch (IOException e) { + throw new TransportException("decode GET /admin/tokens", e); + } + } + + /** + * Revokes the token with the given {@code name}. + * + * @param name token slug to revoke + * @throws AuthException if not using the admin bootstrap token + * @throws ApiException on any other non-2xx (404 = no such token) + * @throws TransportException on transport / decoding failure + */ + public void revokeToken(String name) { + Objects.requireNonNull(name, "name"); + if (name.isBlank()) { + throw new IllegalArgumentException("name must not be blank"); + } + String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8); + HttpRequest req = newRequest("/admin/tokens/" + encoded, "DELETE", + BodyPublishers.noBody(), null); + HttpResponse resp = sendRaw(req); + ensure2xx(req, resp); + } + + // ---------- internals --------------------------------------------------- + + private HttpRequest newRequest(String path, String method, BodyPublisher body, String contentType) { + return newRequest(path, method, body, contentType, defaultTimeout); + } + + private HttpRequest newRequest(String path, String method, BodyPublisher body, + String contentType, Duration timeout) { + URI uri; + try { + uri = new URI(baseUrl + path); + } catch (URISyntaxException e) { + throw new TransportException("bad URI: " + baseUrl + path, e); + } + HttpRequest.Builder b = HttpRequest.newBuilder(uri) + .method(method, body) + .header("Authorization", "Bearer " + token) + .header("Accept", "application/json") + .timeout(timeout); + if (contentType != null) { + b.header("Content-Type", contentType); + } + return b.build(); + } + + private T send(HttpRequest req, Class type) { + HttpResponse resp = sendRaw(req); + ensure2xx(req, resp); + try { + return MAPPER.readValue(resp.body(), type); + } catch (IOException e) { + throw new TransportException("decode " + req.method() + " " + req.uri().getPath(), e); + } + } + + private HttpResponse sendRaw(HttpRequest req) { + try { + return httpClient.send(req, BodyHandlers.ofByteArray()); + } catch (IOException e) { + throw new TransportException(req.method() + " " + req.uri().getPath(), e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new TransportException(req.method() + " " + req.uri().getPath() + ": interrupted", e); + } + } + + private void ensure2xx(HttpRequest req, HttpResponse resp) { + int status = resp.statusCode(); + if (status >= 200 && status < 300) { + return; + } + byte[] body = resp.body(); + String bodyStr = body == null ? "" : new String(body, StandardCharsets.UTF_8); + // Cap body in error path to keep messages bounded + if (bodyStr.length() > 8192) { + bodyStr = bodyStr.substring(0, 8192) + "..."; + } + String summary = summarizeBody(bodyStr); + if (status == 401 || status == 403) { + throw new AuthException(status, summary, bodyStr); + } + throw new ApiException(status, summary, bodyStr); + } + + private static String summarizeBody(String body) { + if (body == null || body.isBlank()) { + return ""; + } + try { + JsonNode root = MAPPER.readTree(body); + for (String key : new String[] {"error", "detail", "message"}) { + JsonNode v = root.path(key); + if (v.isTextual()) { + return v.asText(); + } + } + } catch (IOException ignored) { + // body wasn't JSON — fall through + } + String trimmed = body.strip(); + if (trimmed.length() > 500) { + return trimmed.substring(0, 500) + "..."; + } + return trimmed; + } + + private byte[] serialize(Object o) { + try { + return MAPPER.writeValueAsBytes(o); + } catch (JsonProcessingException e) { + throw new ForgeException("serialize " + o.getClass().getSimpleName() + ": " + e.getMessage(), e); + } + } + + private Duration httpTimeoutFor(Integer subprocessTimeoutSecs) { + if (subprocessTimeoutSecs == null) { + return defaultTimeout; + } + // 30s margin over subprocess timeout so HTTP doesn't bail prematurely + return Duration.ofSeconds(subprocessTimeoutSecs.longValue() + 30L); + } + + // ---------- multipart upload -------------------------------------------- + + private static String generateBoundary() { + // Random hex boundary, prefixed to be unambiguous. + byte[] bytes = new byte[16]; + ThreadLocalRandom.current().nextBytes(bytes); + StringBuilder sb = new StringBuilder("----clawdforge-"); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + private static BodyPublisher multipartFromFile(String boundary, Path path, + String filename, int ttlSecs) throws IOException { + List parts = new ArrayList<>(); + String dashBoundary = "--" + boundary + "\r\n"; + + if (ttlSecs > 0) { + StringBuilder ttlPart = new StringBuilder(); + ttlPart.append(dashBoundary) + .append("Content-Disposition: form-data; name=\"ttl_secs\"\r\n\r\n") + .append(ttlSecs).append("\r\n"); + parts.add(ttlPart.toString().getBytes(StandardCharsets.UTF_8)); + } + + StringBuilder fileHeader = new StringBuilder(); + fileHeader.append(dashBoundary) + .append("Content-Disposition: form-data; name=\"file\"; filename=\"") + .append(escapeQuoted(filename)) + .append("\"\r\n") + .append("Content-Type: application/octet-stream\r\n\r\n"); + parts.add(fileHeader.toString().getBytes(StandardCharsets.UTF_8)); + + // Stream file in 1 MiB chunks rather than loading whole file into memory. + try (InputStream in = Files.newInputStream(path)) { + byte[] buf = new byte[1024 * 1024]; + int n; + while ((n = in.read(buf)) > 0) { + byte[] chunk = new byte[n]; + System.arraycopy(buf, 0, chunk, 0, n); + parts.add(chunk); + } + } + parts.add("\r\n".getBytes(StandardCharsets.UTF_8)); + parts.add(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8)); + + return BodyPublishers.ofByteArrays(parts); + } + + private static String escapeQuoted(String s) { + // Best-effort escape of double quotes and CR/LF in a filename. + return s.replace("\\", "\\\\").replace("\"", "\\\"") + .replace("\r", "").replace("\n", ""); + } + + private static String stripTrailingSlash(String s) { + if (s.isEmpty()) { + throw new IllegalArgumentException("baseUrl must not be empty"); + } + return s.endsWith("/") ? s.substring(0, s.length() - 1) : s; + } + + // ---------- builder ----------------------------------------------------- + + /** + * Fluent builder for {@link ForgeClient}. + */ + public static final class Builder { + private String baseUrl; + private String token; + private HttpClient httpClient; + private Duration defaultTimeout; + + private Builder() { + } + + /** + * @param baseUrl e.g. {@code "http://192.168.0.5:8800"} (trailing slash trimmed) + * @return this builder + */ + public Builder baseUrl(String baseUrl) { + this.baseUrl = baseUrl; + return this; + } + + /** + * @param token bearer token (full string, without {@code "Bearer "} prefix) + * @return this builder + */ + public Builder token(String token) { + this.token = token; + return this; + } + + /** + * Optional. Inject a caller-supplied {@link HttpClient} for proxy / + * TLS / connection-pool tuning. If unset, a default client with a + * 10-second connect timeout is used. + * + * @param httpClient the client to use + * @return this builder + */ + public Builder httpClient(HttpClient httpClient) { + this.httpClient = httpClient; + return this; + } + + /** + * Optional. HTTP-level request timeout used when no per-request + * timeout is implied by {@link RunRequest#timeoutSecs()}. Defaults + * to 120 seconds. + * + * @param timeout request timeout + * @return this builder + */ + public Builder defaultTimeout(Duration timeout) { + this.defaultTimeout = timeout; + return this; + } + + /** + * @return a configured {@link ForgeClient} + * @throws NullPointerException if {@code baseUrl} or {@code token} is unset + */ + public ForgeClient build() { + return new ForgeClient(this); + } + } +} diff --git a/clients/java/src/main/java/com/clawdforge/HealthStatus.java b/clients/java/src/main/java/com/clawdforge/HealthStatus.java new file mode 100644 index 0000000..1208531 --- /dev/null +++ b/clients/java/src/main/java/com/clawdforge/HealthStatus.java @@ -0,0 +1,29 @@ +package com.clawdforge; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Parsed response from {@code GET /healthz}. + * + *

Wire shape (snake_case) is mapped to camelCase accessors via Jackson + * annotations on the canonical constructor. + * + * @param ok server liveness flag + * @param claudePresent whether the {@code claude} binary was found on PATH + * @param claudeVersion first line of {@code claude --version}, or {@code null} + * if the binary was missing or errored + */ +public record HealthStatus( + @JsonProperty("ok") boolean ok, + @JsonProperty("claude_present") boolean claudePresent, + @JsonProperty("claude_version") String claudeVersion) { + + /** + * Jackson-friendly canonical constructor. Explicit annotation makes the + * mapping unambiguous when records are deserialized via Jackson. + */ + @JsonCreator + public HealthStatus { + } +} diff --git a/clients/java/src/main/java/com/clawdforge/RunRequest.java b/clients/java/src/main/java/com/clawdforge/RunRequest.java new file mode 100644 index 0000000..732cf50 --- /dev/null +++ b/clients/java/src/main/java/com/clawdforge/RunRequest.java @@ -0,0 +1,124 @@ +package com.clawdforge; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; +import java.util.Objects; + +/** + * Body for {@code POST /run}. Build via {@link #builder()}. + * + *

Wire mapping uses snake_case via {@link JsonProperty}; null/zero-value + * optional fields are dropped via {@link JsonInclude}. + * + *

Field semantics: + *

    + *
  • {@code prompt} — required, non-empty.
  • + *
  • {@code model} — optional override; server default is {@code "sonnet"}.
  • + *
  • {@code system} — optional system prompt.
  • + *
  • {@code files} — optional list of {@code ff_...} tokens previously + * returned from {@link ForgeClient#uploadFile}.
  • + *
  • {@code timeoutSecs} — optional subprocess timeout, server clamps to + * [5, 600]. {@code null} means use server default.
  • + *
+ * + * @param prompt required prompt text (non-empty) + * @param model optional model name, e.g. {@code "sonnet"}, {@code "haiku"} + * @param system optional system prompt prepended to the run + * @param files optional list of file tokens to attach + * @param timeoutSecs optional subprocess timeout in seconds (5..600) + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record RunRequest( + @JsonProperty("prompt") String prompt, + @JsonProperty("model") String model, + @JsonProperty("system") String system, + @JsonProperty("files") List files, + @JsonProperty("timeout_secs") Integer timeoutSecs) { + + /** + * Validates that {@code prompt} is non-null and non-blank. + */ + public RunRequest { + Objects.requireNonNull(prompt, "prompt must not be null"); + if (prompt.isBlank()) { + throw new IllegalArgumentException("prompt must not be blank"); + } + } + + /** + * @return a fresh builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Fluent builder for {@link RunRequest}. Not thread-safe; intended for + * one-shot construction at call sites. + */ + public static final class Builder { + private String prompt; + private String model; + private String system; + private List files; + private Integer timeoutSecs; + + private Builder() { + } + + /** + * @param prompt required prompt text + * @return this builder + */ + public Builder prompt(String prompt) { + this.prompt = prompt; + return this; + } + + /** + * @param model optional model name (e.g. {@code "sonnet"}) + * @return this builder + */ + public Builder model(String model) { + this.model = model; + return this; + } + + /** + * @param system optional system prompt + * @return this builder + */ + public Builder system(String system) { + this.system = system; + return this; + } + + /** + * @param files optional list of file tokens (each prefixed {@code ff_}) + * @return this builder + */ + public Builder files(List files) { + this.files = files; + return this; + } + + /** + * @param timeoutSecs optional subprocess timeout in seconds (5..600) + * @return this builder + */ + public Builder timeoutSecs(Integer timeoutSecs) { + this.timeoutSecs = timeoutSecs; + return this; + } + + /** + * @return the assembled {@link RunRequest} + * @throws IllegalArgumentException if {@code prompt} is null or blank + */ + public RunRequest build() { + return new RunRequest(prompt, model, system, files, timeoutSecs); + } + } +} diff --git a/clients/java/src/main/java/com/clawdforge/RunResult.java b/clients/java/src/main/java/com/clawdforge/RunResult.java new file mode 100644 index 0000000..d305adb --- /dev/null +++ b/clients/java/src/main/java/com/clawdforge/RunResult.java @@ -0,0 +1,42 @@ +package com.clawdforge; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Parsed response from {@code POST /run} on success (HTTP 200). + * + *

{@code result} is exposed as a Jackson {@link JsonNode} because the + * upstream may return either a structured JSON value (object/array/etc) when + * the prompt asked for JSON, or a plain JSON string. Callers narrow: + * + *

{@code
+ * RunResult res = client.run(req);
+ * JsonNode r = res.result();
+ * if (r.isObject() && r.has("hello")) {
+ *     System.out.println(r.get("hello").asText());
+ * } else if (r.isTextual()) {
+ *     System.out.println(r.asText());
+ * }
+ * }
+ * + * @param ok always {@code true} on the success path + * @param result the parsed result, never {@code null} + * (use {@link JsonNode#isMissingNode()} on absence) + * @param durationMs subprocess wall-clock duration in milliseconds + * @param stopReason claude's reported stop reason, e.g. {@code "end_turn"} + */ +public record RunResult( + @JsonProperty("ok") boolean ok, + @JsonProperty("result") JsonNode result, + @JsonProperty("duration_ms") long durationMs, + @JsonProperty("stop_reason") String stopReason) { + + /** + * Jackson-friendly canonical constructor. + */ + @JsonCreator + public RunResult { + } +} diff --git a/clients/java/src/main/java/com/clawdforge/exception/ApiException.java b/clients/java/src/main/java/com/clawdforge/exception/ApiException.java new file mode 100644 index 0000000..8ebd164 --- /dev/null +++ b/clients/java/src/main/java/com/clawdforge/exception/ApiException.java @@ -0,0 +1,54 @@ +package com.clawdforge.exception; + +/** + * Thrown for any non-2xx HTTP response from clawdforge that isn't a + * transport-layer error. + * + *

For 502 responses to {@code /run}, the body usually carries the + * upstream failure shape: {@code error}, {@code stderr}, {@code duration_ms}, + * {@code stop_reason}. Callers that need those fields can parse {@link #body()} + * with their preferred JSON mapper. + */ +public class ApiException extends ForgeException { + + private static final long serialVersionUID = 1L; + + private final int statusCode; + private final String body; + + /** + * @param statusCode HTTP status + * @param message short human-readable summary (often pulled from the + * body's {@code error} / {@code detail} field) + * @param body raw response body (truncated to a sane size) + */ + public ApiException(int statusCode, String message, String body) { + super(formatMessage(statusCode, message, body)); + this.statusCode = statusCode; + this.body = body; + } + + /** + * @return the HTTP status code from the failing response + */ + public int statusCode() { + return statusCode; + } + + /** + * @return the raw (possibly truncated) response body, or {@code ""} if empty + */ + public String body() { + return body == null ? "" : body; + } + + private static String formatMessage(int statusCode, String message, String body) { + if (message != null && !message.isBlank()) { + return "clawdforge: HTTP " + statusCode + ": " + message; + } + if (body != null && !body.isBlank()) { + return "clawdforge: HTTP " + statusCode + ": " + body; + } + return "clawdforge: HTTP " + statusCode; + } +} diff --git a/clients/java/src/main/java/com/clawdforge/exception/AuthException.java b/clients/java/src/main/java/com/clawdforge/exception/AuthException.java new file mode 100644 index 0000000..d4f63ca --- /dev/null +++ b/clients/java/src/main/java/com/clawdforge/exception/AuthException.java @@ -0,0 +1,19 @@ +package com.clawdforge.exception; + +/** + * Thrown for HTTP 401 / 403 responses from clawdforge — the bearer token is + * missing, malformed, revoked, or the caller's IP is not on the allowlist. + */ +public class AuthException extends ApiException { + + private static final long serialVersionUID = 1L; + + /** + * @param statusCode HTTP status (401 or 403) + * @param message short human-readable summary + * @param body raw response body (truncated) + */ + public AuthException(int statusCode, String message, String body) { + super(statusCode, message, body); + } +} diff --git a/clients/java/src/main/java/com/clawdforge/exception/ForgeException.java b/clients/java/src/main/java/com/clawdforge/exception/ForgeException.java new file mode 100644 index 0000000..0f73968 --- /dev/null +++ b/clients/java/src/main/java/com/clawdforge/exception/ForgeException.java @@ -0,0 +1,45 @@ +package com.clawdforge.exception; + +/** + * Base unchecked exception for all clawdforge SDK failures. + * + *

Why unchecked? Checked exceptions feel un-modern in + * Java 17 — they bleed across API boundaries, force {@code throws} chains in + * lambdas, and don't pair well with the records / sealed-interface idioms + * the rest of this SDK leans on. All clawdforge errors extend this class so + * a single {@code catch (ForgeException e)} catches everything; subclasses + * let callers narrow when they care: + * + *

{@code
+ * try {
+ *     client.run(req);
+ * } catch (AuthException e) {
+ *     // 401/403 — token revoked, IP not allowed
+ * } catch (ApiException e) {
+ *     // any other non-2xx, including 502 run failures
+ * } catch (TransportException e) {
+ *     // DNS, connect-refused, TLS, timeout, IO
+ * } catch (ForgeException e) {
+ *     // catch-all for SDK-level failures (e.g. JSON decode)
+ * }
+ * }
+ */ +public class ForgeException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** + * @param message human-readable description + */ + public ForgeException(String message) { + super(message); + } + + /** + * @param message human-readable description + * @param cause underlying cause + */ + public ForgeException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/clients/java/src/main/java/com/clawdforge/exception/TransportException.java b/clients/java/src/main/java/com/clawdforge/exception/TransportException.java new file mode 100644 index 0000000..ccca544 --- /dev/null +++ b/clients/java/src/main/java/com/clawdforge/exception/TransportException.java @@ -0,0 +1,31 @@ +package com.clawdforge.exception; + +/** + * Thrown for low-level transport failures: DNS, connection refused, TLS + * handshake, request-build, IO mid-stream, request timeout, JSON decode of + * a 2xx response, etc. + * + *

The wrapped cause is preserved via {@link Throwable#getCause()} so + * callers can branch on, e.g., {@link java.net.http.HttpTimeoutException} or + * {@link java.net.ConnectException}. + */ +public class TransportException extends ForgeException { + + private static final long serialVersionUID = 1L; + + /** + * @param message human-readable description (typically the operation, + * e.g. {@code "POST /run"}) + * @param cause underlying network/IO/decoding cause + */ + public TransportException(String message, Throwable cause) { + super("clawdforge: transport: " + message, cause); + } + + /** + * @param message human-readable description + */ + public TransportException(String message) { + super("clawdforge: transport: " + message); + } +} diff --git a/clients/java/src/test/java/com/clawdforge/ForgeClientTest.java b/clients/java/src/test/java/com/clawdforge/ForgeClientTest.java new file mode 100644 index 0000000..132e298 --- /dev/null +++ b/clients/java/src/test/java/com/clawdforge/ForgeClientTest.java @@ -0,0 +1,314 @@ +package com.clawdforge; + +import com.clawdforge.exception.ApiException; +import com.clawdforge.exception.AuthException; +import com.clawdforge.exception.TransportException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * End-to-end tests against an in-process {@link HttpServer}. No external + * mock-server dependency — JDK's {@code com.sun.net.httpserver} is enough + * to exercise headers, status codes, JSON bodies, and multipart uploads. + */ +class ForgeClientTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private HttpServer server; + private String baseUrl; + + @BeforeEach + void setUp() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.start(); + baseUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + } + + @AfterEach + void tearDown() { + if (server != null) { + server.stop(0); + } + } + + private ForgeClient client(String token) { + return ForgeClient.builder().baseUrl(baseUrl).token(token).build(); + } + + private void route(String path, HttpHandler h) { + server.createContext(path, h); + } + + private static void respond(HttpExchange ex, int status, String body) throws IOException { + byte[] payload = body.getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().add("Content-Type", "application/json"); + ex.sendResponseHeaders(status, payload.length); + ex.getResponseBody().write(payload); + ex.close(); + } + + private static String readBody(HttpExchange ex) throws IOException { + return new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + } + + private static byte[] readBytes(HttpExchange ex) throws IOException { + return ex.getRequestBody().readAllBytes(); + } + + // ----- tests ------------------------------------------------------------- + + @Test + @DisplayName("healthz: parses ok + claude_version + claude_present") + void healthzParses() { + route("/healthz", ex -> respond(ex, 200, + "{\"ok\":true,\"claude_present\":true,\"claude_version\":\"1.2.3 (sonnet)\"}")); + HealthStatus h = client("cf_test").healthz(); + assertTrue(h.ok()); + assertTrue(h.claudePresent()); + assertEquals("1.2.3 (sonnet)", h.claudeVersion()); + } + + @Test + @DisplayName("healthz: sends Bearer token + Accept header") + void healthzSendsHeaders() { + AtomicReference auth = new AtomicReference<>(); + AtomicReference accept = new AtomicReference<>(); + route("/healthz", ex -> { + auth.set(ex.getRequestHeaders().getFirst("Authorization")); + accept.set(ex.getRequestHeaders().getFirst("Accept")); + respond(ex, 200, "{\"ok\":true,\"claude_present\":true,\"claude_version\":\"x\"}"); + }); + client("cf_abc").healthz(); + assertEquals("Bearer cf_abc", auth.get()); + assertEquals("application/json", accept.get()); + } + + @Test + @DisplayName("run: serializes camelCase -> snake_case and parses JsonNode result") + void runRoundTrip() throws IOException { + AtomicReference got = new AtomicReference<>(); + route("/run", ex -> { + got.set(readBody(ex)); + respond(ex, 200, + "{\"ok\":true,\"result\":{\"hello\":\"world\"},\"duration_ms\":1234,\"stop_reason\":\"end_turn\"}"); + }); + RunResult res = client("cf_test").run(RunRequest.builder() + .prompt("hi") + .model("sonnet") + .timeoutSecs(60) + .build()); + + // Verify wire-format snake_case + JsonNode sent = MAPPER.readTree(got.get()); + assertEquals("hi", sent.get("prompt").asText()); + assertEquals("sonnet", sent.get("model").asText()); + assertEquals(60, sent.get("timeout_secs").asInt()); + assertFalse(sent.has("system"), "null fields should be omitted"); + assertFalse(sent.has("files"), "null fields should be omitted"); + + // Verify response parse + assertTrue(res.ok()); + assertEquals(1234L, res.durationMs()); + assertEquals("end_turn", res.stopReason()); + assertTrue(res.result().isObject()); + assertEquals("world", res.result().get("hello").asText()); + } + + @Test + @DisplayName("run: result-as-string is a JsonNode of textual type") + void runResultStringNode() { + route("/run", ex -> respond(ex, 200, + "{\"ok\":true,\"result\":\"plain text\",\"duration_ms\":12,\"stop_reason\":\"end_turn\"}")); + RunResult res = client("cf_test").run(RunRequest.builder().prompt("hi").build()); + assertTrue(res.result().isTextual()); + assertEquals("plain text", res.result().asText()); + } + + @Test + @DisplayName("run: 401 -> AuthException") + void runAuth401() { + route("/run", ex -> respond(ex, 401, "{\"detail\":\"bad token\"}")); + AuthException e = assertThrows(AuthException.class, + () -> client("cf_bad").run(RunRequest.builder().prompt("hi").build())); + assertEquals(401, e.statusCode()); + assertTrue(e.getMessage().contains("bad token"), "got: " + e.getMessage()); + } + + @Test + @DisplayName("run: 502 with run-failure shape -> ApiException with body intact") + void runUpstream502() { + String body = "{\"ok\":false,\"error\":\"timeout\",\"stderr\":\"\",\"duration_ms\":60000,\"stop_reason\":\"timeout\"}"; + route("/run", ex -> respond(ex, 502, body)); + ApiException e = assertThrows(ApiException.class, + () -> client("cf_test").run(RunRequest.builder().prompt("hi").build())); + assertEquals(502, e.statusCode()); + assertEquals(body, e.body()); + assertTrue(e.getMessage().contains("timeout")); + } + + @Test + @DisplayName("uploadFile: multipart wire shape carries ttl_secs + file content") + void uploadFileMultipart(@org.junit.jupiter.api.io.TempDir Path tmp) throws IOException { + Path file = tmp.resolve("hello.txt"); + Files.writeString(file, "hello world"); + + AtomicReference contentType = new AtomicReference<>(); + AtomicReference raw = new AtomicReference<>(); + route("/files", ex -> { + contentType.set(ex.getRequestHeaders().getFirst("Content-Type")); + raw.set(readBytes(ex)); + respond(ex, 200, "{\"file_token\":\"ff_abc\",\"ttl_secs\":3600,\"size\":11}"); + }); + + FileToken ft = client("cf_test").uploadFile(file, 3600); + + assertNotNull(contentType.get()); + assertTrue(contentType.get().startsWith("multipart/form-data; boundary=")); + + String body = new String(raw.get(), StandardCharsets.UTF_8); + assertTrue(body.contains("name=\"ttl_secs\""), "ttl_secs part missing: " + body); + assertTrue(body.contains("3600")); + assertTrue(body.contains("name=\"file\"; filename=\"hello.txt\"")); + assertTrue(body.contains("hello world")); + + assertEquals("ff_abc", ft.fileToken()); + assertEquals(3600, ft.ttlSecs()); + assertEquals(11L, ft.size()); + } + + @Test + @DisplayName("uploadFile: omits ttl_secs part when ttlSecs <= 0") + void uploadFileDefaultTtl(@org.junit.jupiter.api.io.TempDir Path tmp) throws IOException { + Path file = tmp.resolve("a.bin"); + Files.write(file, new byte[] {0x1, 0x2, 0x3}); + AtomicReference raw = new AtomicReference<>(); + route("/files", ex -> { + raw.set(readBytes(ex)); + respond(ex, 200, "{\"file_token\":\"ff_zzz\",\"ttl_secs\":3600,\"size\":3}"); + }); + client("cf_test").uploadFile(file, 0); + String body = new String(raw.get(), StandardCharsets.UTF_8); + assertFalse(body.contains("name=\"ttl_secs\""), "should omit ttl_secs when 0"); + } + + @Test + @DisplayName("createToken: posts name + ip_cidrs (snake_case), returns plaintext token") + void createToken() throws IOException { + AtomicReference got = new AtomicReference<>(); + route("/admin/tokens", ex -> { + if (!"POST".equals(ex.getRequestMethod())) { + respond(ex, 405, "{}"); + return; + } + got.set(readBody(ex)); + respond(ex, 200, "{\"name\":\"cauldron\",\"token\":\"cf_secret\",\"ip_cidrs\":[\"10.0.0.0/8\"]}"); + }); + + AppToken t = client("admin_bootstrap").createToken("cauldron", List.of("10.0.0.0/8")); + + JsonNode sent = MAPPER.readTree(got.get()); + assertEquals("cauldron", sent.get("name").asText()); + assertTrue(sent.get("ip_cidrs").isArray()); + assertEquals("10.0.0.0/8", sent.get("ip_cidrs").get(0).asText()); + + assertEquals("cauldron", t.name()); + assertEquals("cf_secret", t.token()); + } + + @Test + @DisplayName("listTokens: parses {tokens: [...]} envelope into List") + void listTokens() { + route("/admin/tokens", ex -> { + if (!"GET".equals(ex.getRequestMethod())) { + try { respond(ex, 405, "{}"); } catch (IOException ignored) {} + return; + } + respond(ex, 200, + "{\"tokens\":[{\"name\":\"a\",\"created_at\":1},{\"name\":\"b\",\"ip_cidrs\":[\"1.2.3.4/32\"]}]}"); + }); + List ts = client("admin_bootstrap").listTokens(); + assertEquals(2, ts.size()); + assertEquals("a", ts.get(0).name()); + assertEquals(1L, ts.get(0).createdAt()); + assertEquals("b", ts.get(1).name()); + assertEquals(List.of("1.2.3.4/32"), ts.get(1).ipCidrs()); + } + + @Test + @DisplayName("revokeToken: DELETE on /admin/tokens/; 404 -> ApiException") + void revokeToken() { + List hits = new ArrayList<>(); + route("/admin/tokens/", ex -> { + hits.add(ex.getRequestMethod() + " " + ex.getRequestURI().getPath()); + if (ex.getRequestURI().getPath().endsWith("/missing")) { + respond(ex, 404, "{\"detail\":\"no such token\"}"); + return; + } + respond(ex, 200, "{\"ok\":true}"); + }); + + client("admin_bootstrap").revokeToken("cauldron"); + ApiException e = assertThrows(ApiException.class, + () -> client("admin_bootstrap").revokeToken("missing")); + assertEquals(404, e.statusCode()); + + assertTrue(hits.contains("DELETE /admin/tokens/cauldron"), "hits: " + hits); + assertTrue(hits.contains("DELETE /admin/tokens/missing"), "hits: " + hits); + } + + @Test + @DisplayName("transport: connection-refused -> TransportException") + void transportFailure() { + // Use a port that nothing is listening on. Stop our server and reuse its port. + int port = server.getAddress().getPort(); + server.stop(0); + ForgeClient c = ForgeClient.builder() + .baseUrl("http://127.0.0.1:" + port) + .token("cf_test") + .build(); + assertThrows(TransportException.class, c::healthz); + } + + @Test + @DisplayName("baseUrl: trailing slash is trimmed") + void baseUrlTrailingSlash() { + route("/healthz", ex -> respond(ex, 200, + "{\"ok\":true,\"claude_present\":true,\"claude_version\":\"x\"}")); + ForgeClient c = ForgeClient.builder() + .baseUrl(baseUrl + "/") + .token("cf_test") + .build(); + HealthStatus h = c.healthz(); + assertTrue(h.ok()); + } + + @Test + @DisplayName("RunRequest builder rejects blank prompt") + void runRequestRejectsBlank() { + assertThrows(IllegalArgumentException.class, + () -> RunRequest.builder().prompt(" ").build()); + } +}