clients/java: initial Java SDK for clawdforge

- 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
This commit is contained in:
Kayos 2026-04-28 22:48:48 -07:00
parent e4e8192d4d
commit 0d3ee26e24
15 changed files with 1569 additions and 0 deletions

194
clients/java/README.md Normal file
View file

@ -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
<dependency>
<groupId>com.clawdforge</groupId>
<artifactId>clawdforge-client</artifactId>
<version>0.1.0</version>
</dependency>
```
## 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<String> ipCidrs)` | `POST /admin/tokens` | `AppToken` |
| `listTokens()` | `GET /admin/tokens` | `List<AppToken>` |
| `revokeToken(String name)` | `DELETE /admin/tokens/<name>` | `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<String> 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.

View file

@ -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:<jackson jars> 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<AppToken> 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());
}
}
}
}

99
clients/java/pom.xml Normal file
View file

@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.clawdforge</groupId>
<artifactId>clawdforge-client</artifactId>
<version>0.1.0</version>
<packaging>jar</packaging>
<name>clawdforge-client</name>
<description>
Java SDK for the clawdforge LAN-only HTTP service that wraps
`claude -p` subprocess calls behind a bearer-token-gated REST API.
</description>
<licenses>
<license>
<name>MIT License</name>
<url>https://opensource.org/licenses/MIT</url>
<distribution>repo</distribution>
</license>
</licenses>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<jackson.version>2.17.2</jackson.version>
<junit.version>5.10.2</junit.version>
</properties>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>17</source>
<target>17</target>
<compilerArgs>
<arg>-Xlint:all</arg>
<arg>-Werror</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
<version>3.6.3</version>
<configuration>
<source>17</source>
<doclint>all,-missing</doclint>
<quiet>true</quiet>
<sourceFileExcludes>
<exclude>**/exception/package-info.java</exclude>
</sourceFileExcludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View file

@ -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}.
*
* <p>The plaintext is shown <strong>once</strong> 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<String> ipCidrs,
@JsonProperty("created_at") long createdAt) {
/**
* Jackson-friendly canonical constructor.
*/
@JsonCreator
public AppToken {
}
}

View file

@ -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}.
*
* <p>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 {
}
}

View file

@ -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.
*
* <p>Construct via {@link #builder()}; instances are immutable and safe for
* concurrent use.
*
* <p><strong>Wire format note.</strong> 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.
*
* <p>Example:
* <pre>{@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());
* }</pre>
*/
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}.
*
* <p>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.
*
* <p>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<String> 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<AppToken> listTokens() {
HttpRequest req = newRequest("/admin/tokens", "GET", BodyPublishers.noBody(), null);
HttpResponse<byte[]> resp = sendRaw(req);
ensure2xx(req, resp);
try {
JsonNode root = MAPPER.readTree(resp.body());
JsonNode arr = root.path("tokens");
List<AppToken> 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<byte[]> 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> T send(HttpRequest req, Class<T> type) {
HttpResponse<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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);
}
}
}

View file

@ -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}.
*
* <p>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 {
}
}

View file

@ -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()}.
*
* <p>Wire mapping uses snake_case via {@link JsonProperty}; null/zero-value
* optional fields are dropped via {@link JsonInclude}.
*
* <p>Field semantics:
* <ul>
* <li>{@code prompt} required, non-empty.</li>
* <li>{@code model} optional override; server default is {@code "sonnet"}.</li>
* <li>{@code system} optional system prompt.</li>
* <li>{@code files} optional list of {@code ff_...} tokens previously
* returned from {@link ForgeClient#uploadFile}.</li>
* <li>{@code timeoutSecs} optional subprocess timeout, server clamps to
* [5, 600]. {@code null} means use server default.</li>
* </ul>
*
* @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<String> 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<String> 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<String> 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);
}
}
}

View file

@ -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).
*
* <p>{@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:
*
* <pre>{@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());
* }
* }</pre>
*
* @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 {
}
}

View file

@ -0,0 +1,54 @@
package com.clawdforge.exception;
/**
* Thrown for any non-2xx HTTP response from clawdforge that isn't a
* transport-layer error.
*
* <p>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;
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1,45 @@
package com.clawdforge.exception;
/**
* Base unchecked exception for all clawdforge SDK failures.
*
* <p><strong>Why unchecked?</strong> 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:
*
* <pre>{@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)
* }
* }</pre>
*/
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);
}
}

View file

@ -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.
*
* <p>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);
}
}

View file

@ -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<String> auth = new AtomicReference<>();
AtomicReference<String> 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<String> 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<String> contentType = new AtomicReference<>();
AtomicReference<byte[]> 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<byte[]> 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<String> 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<AppToken>")
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<AppToken> 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/<name>; 404 -> ApiException")
void revokeToken() {
List<String> 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());
}
}