clawdforge/clients/java/README.md
Kayos 9866e97977 clients/java: apply audit findings — true streaming upload + token redaction (0d3ee26 → next)
MEDIUM:
- C1: multipart upload now actually streams via SequenceInputStream + Files.newInputStream. Code comment + README + javadoc updated to match reality. Test added uploading 10 MiB file with received-bytes assertion bounding envelope overhead.
- S1: AppToken.toString() override redacts token (was leaking plaintext via record auto-toString).

LOW:
- C2: RunResult.result null/missing-field handling — canonical-constructor coerces null/NullNode to MissingNode, javadoc updated.
- C3: HTTP timeout lower bound: Math.max(5L, n + 30L).
- C4: ForgeClient implements AutoCloseable (no-op on JDK 17, documented).
- S4: javadoc warning on uploadFile path traversal / symlink follow.

Quality:
- Q1: package-info.java added for com.clawdforge.exception (clears pom.xml dead exclude).
- C7: @JsonInclude(NON_DEFAULT) on POST DTOs (drops wire "created_at": 0).

Deps:
- jackson-databind/core/annotations 2.17.2 → 2.18.2 (2.17 EOL'd Aug 2025).

Tests: 14 → 23 (9 added).

Audit: memory/clawdforge-audits/java-0d3ee26.md
2026-04-28 23:20:45 -07:00

228 lines
7.3 KiB
Markdown

# 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;
try (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());
}
}
```
`ForgeClient` is immutable and safe to share across threads — for
long-lived multi-tenant deployments, construct once and reuse, rather
than relying on `close()`.
## 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 body through
`HttpRequest.BodyPublishers.ofInputStream` chained over a
`SequenceInputStream` (multipart preamble + `Files.newInputStream(path)` +
trailing boundary). The file body is never fully resident in heap — peak
upload memory is bounded by the `HttpClient` read-ahead buffer, so a
multi-GiB upload won't OOM a default-heap JVM:
```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.
> **Path safety.** `uploadFile` does not constrain its `Path` argument to
> any sandbox root, and `Files.isRegularFile` follows symlinks by default.
> Callers that pass user-supplied paths must canonicalise and sandbox them
> first (e.g. resolve under a known directory and reject any `Path` whose
> `toRealPath()` escapes it).
## Lifecycle
`ForgeClient` implements `AutoCloseable` for try-with-resources symmetry:
```java
try (ForgeClient client = ForgeClient.builder()
.baseUrl("http://192.168.0.5:8800")
.token(System.getenv("CLAWDFORGE_TOKEN"))
.build()) {
client.healthz();
}
```
On JDK 17 `HttpClient` has no `close()` (added in JDK 21), so the SDK's
`close()` is currently a no-op — the underlying client is reclaimed when
its last reference goes out of scope. For long-lived multi-tenant
deployments, prefer constructing a single shared `HttpClient` (injected
via `Builder.httpClient(HttpClient)`) and reusing it across `ForgeClient`
instances.
## 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.