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:
parent
e4e8192d4d
commit
0d3ee26e24
15 changed files with 1569 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -17,3 +17,6 @@ runs/
|
|||
# Rust
|
||||
clients/rust/target/
|
||||
clients/rust/Cargo.lock
|
||||
|
||||
# Java
|
||||
clients/java/target/
|
||||
|
|
|
|||
194
clients/java/README.md
Normal file
194
clients/java/README.md
Normal 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.
|
||||
79
clients/java/examples/Basic.java
Normal file
79
clients/java/examples/Basic.java
Normal 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
99
clients/java/pom.xml
Normal 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>
|
||||
35
clients/java/src/main/java/com/clawdforge/AppToken.java
Normal file
35
clients/java/src/main/java/com/clawdforge/AppToken.java
Normal 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 {
|
||||
}
|
||||
}
|
||||
29
clients/java/src/main/java/com/clawdforge/FileToken.java
Normal file
29
clients/java/src/main/java/com/clawdforge/FileToken.java
Normal 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 {
|
||||
}
|
||||
}
|
||||
472
clients/java/src/main/java/com/clawdforge/ForgeClient.java
Normal file
472
clients/java/src/main/java/com/clawdforge/ForgeClient.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
29
clients/java/src/main/java/com/clawdforge/HealthStatus.java
Normal file
29
clients/java/src/main/java/com/clawdforge/HealthStatus.java
Normal 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 {
|
||||
}
|
||||
}
|
||||
124
clients/java/src/main/java/com/clawdforge/RunRequest.java
Normal file
124
clients/java/src/main/java/com/clawdforge/RunRequest.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
42
clients/java/src/main/java/com/clawdforge/RunResult.java
Normal file
42
clients/java/src/main/java/com/clawdforge/RunResult.java
Normal 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 {
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
314
clients/java/src/test/java/com/clawdforge/ForgeClientTest.java
Normal file
314
clients/java/src/test/java/com/clawdforge/ForgeClientTest.java
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue