headers = new HashMap<>();
+headers.put("Content-Type", "application/json");
+headers.putAll(
+ HttpSign.signHeaders(
+ "POST",
+ "http://localhost:8080/v1/chat/completions",
+ body,
+ pem,
+ "local-signer"));
+```
diff --git a/clients/java/pom.xml b/clients/java/pom.xml
new file mode 100644
index 0000000..7a9f300
--- /dev/null
+++ b/clients/java/pom.xml
@@ -0,0 +1,114 @@
+
+
+ 4.0.0
+
+ ai.afi
+ platform-client
+ 1.0.0
+ jar
+
+ AFI Platform Client
+ Thin Java client for the AFI Platform API
+ https://github.com/curefatih/afi
+
+
+
+ Apache License, Version 2.0
+ https://www.apache.org/licenses/LICENSE-2.0.txt
+
+
+
+
+
+ AFI Contributors
+ https://github.com/curefatih/afi
+
+
+
+
+ scm:git:https://github.com/curefatih/afi.git
+ scm:git:https://github.com/curefatih/afi.git
+ https://github.com/curefatih/afi/tree/main/clients/java
+
+
+
+ UTF-8
+ 17
+ 5.11.4
+ 2.18.2
+ curefatih
+ afi
+
+
+
+
+ github
+ GitHub Packages
+ https://maven.pkg.github.com/${github.owner}/${github.repo}
+
+
+
+
+
+
+ github
+ https://maven.pkg.github.com/${github.owner}/${github.repo}
+
+ true
+
+
+
+
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ ${jackson.version}
+
+
+ org.junit.jupiter
+ junit-jupiter
+ ${junit.version}
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.5.2
+
+
+ org.apache.maven.plugins
+ maven-source-plugin
+ 3.3.1
+
+
+ attach-sources
+ jar-no-fork
+
+
+
+
+ org.apache.maven.plugins
+ maven-javadoc-plugin
+ 3.11.2
+
+
+ attach-javadocs
+ jar
+
+
+
+
+
+
diff --git a/clients/java/src/main/java/ai/afi/platform/HttpSign.java b/clients/java/src/main/java/ai/afi/platform/HttpSign.java
new file mode 100644
index 0000000..ca995bf
--- /dev/null
+++ b/clients/java/src/main/java/ai/afi/platform/HttpSign.java
@@ -0,0 +1,163 @@
+package ai.afi.platform;
+
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyFactory;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.SecureRandom;
+import java.security.Signature;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.HexFormat;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * RFC 9421 gateway request signing helpers for AFI signed-request auth.
+ *
+ * Covers {@code @method}, {@code @path}, {@code @query}, and {@code content-digest} — the set
+ * required by the AFI gateway.
+ */
+public final class HttpSign {
+ public static final String SIGNATURE_NAME = "sig1";
+ public static final List REQUIRED_COMPONENTS =
+ List.of("@method", "@path", "@query", "content-digest");
+
+ private HttpSign() {}
+
+ /** Build an RFC 9530 Content-Digest header value for sha-256. */
+ public static String contentDigestSha256(byte[] body) {
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-256").digest(body == null ? new byte[0] : body);
+ return "sha-256=:" + Base64.getEncoder().encodeToString(digest) + ":";
+ } catch (Exception e) {
+ throw new IllegalStateException("SHA-256 unavailable", e);
+ }
+ }
+
+ /**
+ * Return Content-Digest / Signature-Input / Signature headers for a gateway call.
+ *
+ * @param method HTTP method
+ * @param url absolute URL or path (query included)
+ * @param body request body bytes (may be empty)
+ * @param privateKeyPem Ed25519 PKCS#8 PEM
+ * @param keyId signing key id (required)
+ */
+ public static Map signHeaders(
+ String method,
+ String url,
+ byte[] body,
+ byte[] privateKeyPem,
+ String keyId) {
+ return signHeaders(method, url, body, privateKeyPem, keyId, null, null);
+ }
+
+ public static Map signHeaders(
+ String method,
+ String url,
+ byte[] body,
+ byte[] privateKeyPem,
+ String keyId,
+ String nonce,
+ Long created) {
+ if (keyId == null || keyId.isBlank()) {
+ throw new IllegalArgumentException("keyId is required");
+ }
+ Objects.requireNonNull(method, "method");
+ Objects.requireNonNull(url, "url");
+ Objects.requireNonNull(privateKeyPem, "privateKeyPem");
+ byte[] payload = body == null ? new byte[0] : body;
+ PathAndQuery pq = pathAndQuery(url);
+ long createdTs = created == null ? Instant.now().getEpochSecond() : created;
+ String nonceVal = nonce == null || nonce.isBlank() ? randomNonce() : nonce;
+ String digest = contentDigestSha256(payload);
+ String sigParams =
+ "(\"@method\" \"@path\" \"@query\" \"content-digest\")"
+ + ";created="
+ + createdTs
+ + ";nonce=\""
+ + nonceVal
+ + "\";alg=\"ed25519\";keyid=\""
+ + keyId
+ + "\"";
+ String sigBase =
+ String.join(
+ "\n",
+ "\"@method\": " + method.toUpperCase(),
+ "\"@path\": " + pq.path(),
+ "\"@query\": " + pq.query(),
+ "\"content-digest\": " + digest,
+ "\"@signature-params\": " + sigParams);
+ byte[] signature = signEd25519(privateKeyPem, sigBase.getBytes(StandardCharsets.UTF_8));
+ Map headers = new LinkedHashMap<>();
+ headers.put("Content-Digest", digest);
+ headers.put("Signature-Input", SIGNATURE_NAME + "=" + sigParams);
+ headers.put(
+ "Signature",
+ SIGNATURE_NAME + "=:" + Base64.getEncoder().encodeToString(signature) + ":");
+ return headers;
+ }
+
+ /** Copy {@code headers} and overlay RFC 9421 signing headers. */
+ public static Map mergeSignedHeaders(
+ Map headers,
+ String method,
+ String url,
+ byte[] body,
+ byte[] privateKeyPem,
+ String keyId) {
+ Map out = new LinkedHashMap<>();
+ if (headers != null) {
+ out.putAll(headers);
+ }
+ out.putAll(signHeaders(method, url, body, privateKeyPem, keyId));
+ return out;
+ }
+
+ /** Split path and RFC 9421 {@code @query} ({@code "?" + rawQuery}, even when empty). */
+ public static PathAndQuery pathAndQuery(String urlOrPath) {
+ URI uri = URI.create(urlOrPath);
+ String path = uri.getRawPath();
+ if (path == null || path.isEmpty()) {
+ path = "/";
+ }
+ String rawQuery = uri.getRawQuery();
+ String query = "?" + (rawQuery == null ? "" : rawQuery);
+ return new PathAndQuery(path, query);
+ }
+
+ public record PathAndQuery(String path, String query) {}
+
+ private static String randomNonce() {
+ byte[] b = new byte[16];
+ new SecureRandom().nextBytes(b);
+ return HexFormat.of().formatHex(b);
+ }
+
+ private static byte[] signEd25519(byte[] privateKeyPem, byte[] message) {
+ try {
+ PrivateKey key = loadPrivateKey(privateKeyPem);
+ Signature sig = Signature.getInstance("Ed25519");
+ sig.initSign(key);
+ sig.update(message);
+ return sig.sign();
+ } catch (Exception e) {
+ throw new IllegalArgumentException("failed to sign with Ed25519 private key", e);
+ }
+ }
+
+ static PrivateKey loadPrivateKey(byte[] privateKeyPem) throws Exception {
+ String text = new String(privateKeyPem, StandardCharsets.US_ASCII);
+ String b64 =
+ text.replace("-----BEGIN PRIVATE KEY-----", "")
+ .replace("-----END PRIVATE KEY-----", "")
+ .replaceAll("\\s+", "");
+ byte[] der = Base64.getDecoder().decode(b64);
+ return KeyFactory.getInstance("Ed25519").generatePrivate(new PKCS8EncodedKeySpec(der));
+ }
+}
diff --git a/clients/java/src/main/java/ai/afi/platform/PlatformApiException.java b/clients/java/src/main/java/ai/afi/platform/PlatformApiException.java
new file mode 100644
index 0000000..30af5fd
--- /dev/null
+++ b/clients/java/src/main/java/ai/afi/platform/PlatformApiException.java
@@ -0,0 +1,25 @@
+package ai.afi.platform;
+
+/** Error from the AFI Platform HTTP API. */
+public final class PlatformApiException extends RuntimeException {
+ private final int status;
+ private final Object body;
+
+ public PlatformApiException(String message, int status) {
+ this(message, status, null);
+ }
+
+ public PlatformApiException(String message, int status, Object body) {
+ super(message);
+ this.status = status;
+ this.body = body;
+ }
+
+ public int status() {
+ return status;
+ }
+
+ public Object body() {
+ return body;
+ }
+}
diff --git a/clients/java/src/main/java/ai/afi/platform/PlatformClient.java b/clients/java/src/main/java/ai/afi/platform/PlatformClient.java
new file mode 100644
index 0000000..6d9f772
--- /dev/null
+++ b/clients/java/src/main/java/ai/afi/platform/PlatformClient.java
@@ -0,0 +1,325 @@
+package ai.afi.platform;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+/**
+ * Thin synchronous HTTP client for {@code /api/v1/platform/*}.
+ *
+ * {@code
+ * PlatformClient client = new PlatformClient("http://localhost:8081");
+ * String token = client.login("admin@example.com", "secret").path("token").asText();
+ * client = new PlatformClient("http://localhost:8081", () -> token);
+ * }
+ */
+public final class PlatformClient implements AutoCloseable {
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+ private static final TypeReference> LIST_OF_NODE = new TypeReference<>() {};
+
+ private final String baseUrl;
+ private final Supplier tokenGetter;
+ private final HttpClient http;
+ private final Duration timeout;
+ private final RequestExecutor executor;
+ private final boolean ownsHttp;
+
+ /** Functional escape hatch for tests. */
+ @FunctionalInterface
+ public interface RequestExecutor {
+ RawResponse execute(String method, URI uri, Map headers, byte[] body)
+ throws IOException, InterruptedException;
+ }
+
+ /** Minimal HTTP response used by {@link RequestExecutor}. */
+ public record RawResponse(int status, String body) {}
+
+ public PlatformClient(String baseUrl) {
+ this(baseUrl, null, null, Duration.ofSeconds(30), null);
+ }
+
+ public PlatformClient(String baseUrl, Supplier tokenGetter) {
+ this(baseUrl, tokenGetter, null, Duration.ofSeconds(30), null);
+ }
+
+ public PlatformClient(
+ String baseUrl, Supplier tokenGetter, HttpClient http, Duration timeout) {
+ this(baseUrl, tokenGetter, http, timeout, null);
+ }
+
+ PlatformClient(
+ String baseUrl,
+ Supplier tokenGetter,
+ HttpClient http,
+ Duration timeout,
+ RequestExecutor executor) {
+ Objects.requireNonNull(baseUrl, "baseUrl");
+ String normalized = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
+ this.baseUrl = normalized;
+ this.tokenGetter = tokenGetter;
+ this.timeout = timeout == null ? Duration.ofSeconds(30) : timeout;
+ this.ownsHttp = http == null && executor == null;
+ this.http =
+ http != null
+ ? http
+ : HttpClient.newBuilder().connectTimeout(this.timeout).build();
+ this.executor = executor != null ? executor : this::defaultExecute;
+ }
+
+ /** Test constructor with a custom transport. */
+ public static PlatformClient withExecutor(
+ String baseUrl, Supplier tokenGetter, RequestExecutor executor) {
+ return new PlatformClient(baseUrl, tokenGetter, null, Duration.ofSeconds(30), executor);
+ }
+
+ @Override
+ public void close() {
+ // java.net.http.HttpClient does not need explicit close on modern JDKs.
+ }
+
+ public JsonNode request(
+ String method, String path, Object body, boolean auth, Map query) {
+ try {
+ Map headers = new LinkedHashMap<>();
+ byte[] bodyBytes = null;
+ if (body != null) {
+ headers.put("Content-Type", "application/json");
+ bodyBytes = MAPPER.writeValueAsBytes(body);
+ }
+ if (auth) {
+ String token = tokenGetter == null ? null : tokenGetter.get();
+ if (token == null || token.isBlank()) {
+ throw new PlatformApiException("missing access token", 401);
+ }
+ headers.put("Authorization", "Bearer " + token);
+ }
+ URI uri = buildUri(path, query);
+ RawResponse res = executor.execute(method, uri, headers, bodyBytes);
+ if (res.status() == 204) {
+ return null;
+ }
+ JsonNode parsed = null;
+ if (res.body() != null && !res.body().isBlank()) {
+ try {
+ parsed = MAPPER.readTree(res.body());
+ } catch (IOException ignored) {
+ // leave parsed null; error path uses raw text via body field
+ }
+ }
+ if (res.status() >= 400) {
+ String message = "request failed";
+ if (parsed != null && parsed.path("error").isTextual()) {
+ message = parsed.get("error").asText();
+ }
+ Object errBody = parsed != null ? parsed : res.body();
+ throw new PlatformApiException(message, res.status(), errBody);
+ }
+ return parsed;
+ } catch (PlatformApiException e) {
+ throw e;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new PlatformApiException("request interrupted", 0, e.getMessage());
+ } catch (IOException e) {
+ throw new PlatformApiException("request failed: " + e.getMessage(), 0, e.getMessage());
+ }
+ }
+
+ public JsonNode request(String method, String path) {
+ return request(method, path, null, true, null);
+ }
+
+ public JsonNode healthz() {
+ return request("GET", "/healthz", null, false, null);
+ }
+
+ public JsonNode login(String email, String password) {
+ return request(
+ "POST",
+ "/api/v1/platform/auth/login",
+ Map.of("email", email, "password", password),
+ false,
+ null);
+ }
+
+ public JsonNode authFeatures() {
+ return request("GET", "/api/v1/platform/auth/features", null, false, null);
+ }
+
+ public JsonNode register(String email, String name, String password) {
+ return request(
+ "POST",
+ "/api/v1/platform/auth/register",
+ Map.of("email", email, "name", name, "password", password),
+ false,
+ null);
+ }
+
+ public JsonNode requestPasswordReset(String email) {
+ return request(
+ "POST",
+ "/api/v1/platform/auth/password-reset",
+ Map.of("email", email),
+ false,
+ null);
+ }
+
+ public JsonNode confirmPasswordReset(String token, String password) {
+ return request(
+ "POST",
+ "/api/v1/platform/auth/password-reset/" + encodePath(token),
+ Map.of("password", password),
+ false,
+ null);
+ }
+
+ public JsonNode me() {
+ return request("GET", "/api/v1/platform/auth/me");
+ }
+
+ public List listOrganizations() {
+ return asList(request("GET", "/api/v1/platform/organizations"));
+ }
+
+ public JsonNode createOrganization(String name) {
+ return request("POST", "/api/v1/platform/organizations", Map.of("name", name), true, null);
+ }
+
+ public List listOrgKeys(String orgId) {
+ return asList(
+ request("GET", "/api/v1/platform/organizations/" + encodePath(orgId) + "/keys"));
+ }
+
+ public JsonNode createOrgKey(String orgId, Map body) {
+ return request(
+ "POST",
+ "/api/v1/platform/organizations/" + encodePath(orgId) + "/keys",
+ body,
+ true,
+ null);
+ }
+
+ public List listEnvironments(String orgId, String projectId) {
+ return asList(
+ request(
+ "GET",
+ "/api/v1/platform/organizations/"
+ + encodePath(orgId)
+ + "/projects/"
+ + encodePath(projectId)
+ + "/environments"));
+ }
+
+ public JsonNode createEnvironment(String orgId, String projectId, String name, String slug) {
+ return request(
+ "POST",
+ "/api/v1/platform/organizations/"
+ + encodePath(orgId)
+ + "/projects/"
+ + encodePath(projectId)
+ + "/environments",
+ Map.of("name", name, "slug", slug),
+ true,
+ null);
+ }
+
+ public void deleteEnvironment(String environmentId) {
+ request("DELETE", "/api/v1/platform/environments/" + encodePath(environmentId));
+ }
+
+ public List listProviders(String orgId) {
+ return asList(
+ request("GET", "/api/v1/platform/organizations/" + encodePath(orgId) + "/providers"));
+ }
+
+ public List listRoutes(String orgId) {
+ return asList(
+ request("GET", "/api/v1/platform/organizations/" + encodePath(orgId) + "/routes"));
+ }
+
+ public List listUsage(String orgId, Map query) {
+ return asList(
+ request(
+ "GET",
+ "/api/v1/platform/organizations/" + encodePath(orgId) + "/usage",
+ null,
+ true,
+ query));
+ }
+
+ public List listAudit(String orgId, Map query) {
+ return asList(
+ request(
+ "GET",
+ "/api/v1/platform/organizations/" + encodePath(orgId) + "/audit",
+ null,
+ true,
+ query));
+ }
+
+ private RawResponse defaultExecute(
+ String method, URI uri, Map headers, byte[] body)
+ throws IOException, InterruptedException {
+ HttpRequest.Builder b =
+ HttpRequest.newBuilder(uri).timeout(timeout).method(
+ method,
+ body == null
+ ? HttpRequest.BodyPublishers.noBody()
+ : HttpRequest.BodyPublishers.ofByteArray(body));
+ headers.forEach(b::header);
+ HttpResponse res = http.send(b.build(), HttpResponse.BodyHandlers.ofString());
+ return new RawResponse(res.statusCode(), res.body());
+ }
+
+ private URI buildUri(String path, Map query) {
+ String p = path.startsWith("/") ? path : "/" + path;
+ StringBuilder sb = new StringBuilder(baseUrl).append(p);
+ if (query != null && !query.isEmpty()) {
+ String qs =
+ query.entrySet().stream()
+ .filter(e -> e.getValue() != null)
+ .map(
+ e ->
+ URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8)
+ + "="
+ + URLEncoder.encode(String.valueOf(e.getValue()), StandardCharsets.UTF_8))
+ .collect(Collectors.joining("&"));
+ if (!qs.isEmpty()) {
+ sb.append('?').append(qs);
+ }
+ }
+ return URI.create(sb.toString());
+ }
+
+ private static String encodePath(String segment) {
+ return URLEncoder.encode(segment, StandardCharsets.UTF_8).replace("+", "%20");
+ }
+
+ private static List asList(JsonNode node) {
+ if (node == null || node.isNull()) {
+ return List.of();
+ }
+ if (!node.isArray()) {
+ throw new PlatformApiException("expected JSON array", 0, node);
+ }
+ try {
+ return MAPPER.convertValue(node, LIST_OF_NODE);
+ } catch (IllegalArgumentException e) {
+ throw new PlatformApiException("expected JSON array", 0, node);
+ }
+ }
+}
diff --git a/clients/java/src/test/java/ai/afi/platform/HttpSignTest.java b/clients/java/src/test/java/ai/afi/platform/HttpSignTest.java
new file mode 100644
index 0000000..c4acdc7
--- /dev/null
+++ b/clients/java/src/test/java/ai/afi/platform/HttpSignTest.java
@@ -0,0 +1,119 @@
+package ai.afi.platform;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.Signature;
+import java.util.Base64;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+class HttpSignTest {
+
+ private static byte[] pemPrivate(KeyPair pair) {
+ String b64 = Base64.getMimeEncoder(64, new byte[] {'\n'}).encodeToString(pair.getPrivate().getEncoded());
+ return ("-----BEGIN PRIVATE KEY-----\n" + b64 + "\n-----END PRIVATE KEY-----\n")
+ .getBytes(StandardCharsets.US_ASCII);
+ }
+
+ private static KeyPair ed25519() throws Exception {
+ return KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
+ }
+
+ @Test
+ void contentDigestShape() {
+ String d = HttpSign.contentDigestSha256("hello".getBytes(StandardCharsets.UTF_8));
+ assertTrue(d.startsWith("sha-256=:"));
+ assertTrue(d.endsWith(":"));
+ }
+
+ @Test
+ void signHeadersRoundtripVerifyBase() throws Exception {
+ KeyPair pair = ed25519();
+ byte[] pem = pemPrivate(pair);
+ byte[] body = "{\"ping\":true}".getBytes(StandardCharsets.UTF_8);
+ Map headers =
+ HttpSign.signHeaders(
+ "POST",
+ "http://localhost:8080/v1/chat/completions",
+ body,
+ pem,
+ "local-signer",
+ "n1",
+ 1_700_000_000L);
+ assertTrue(headers.containsKey("Content-Digest"));
+ assertTrue(headers.get("Signature-Input").startsWith("sig1="));
+ assertTrue(headers.get("Signature").startsWith("sig1=:"));
+ for (String c : HttpSign.REQUIRED_COMPONENTS) {
+ assertTrue(headers.get("Signature-Input").contains(c));
+ }
+
+ String digest = headers.get("Content-Digest");
+ String sigParams = headers.get("Signature-Input").substring("sig1=".length());
+ String sigBase =
+ String.join(
+ "\n",
+ "\"@method\": POST",
+ "\"@path\": /v1/chat/completions",
+ "\"@query\": ?",
+ "\"content-digest\": " + digest,
+ "\"@signature-params\": " + sigParams);
+ String sigB64 =
+ headers
+ .get("Signature")
+ .substring("sig1=:".length(), headers.get("Signature").length() - 1);
+ byte[] raw = Base64.getDecoder().decode(sigB64);
+ Signature verifier = Signature.getInstance("Ed25519");
+ verifier.initVerify(pair.getPublic());
+ verifier.update(sigBase.getBytes(StandardCharsets.UTF_8));
+ assertTrue(verifier.verify(raw));
+ }
+
+ @Test
+ void signHeadersWithQuery() throws Exception {
+ KeyPair pair = ed25519();
+ byte[] pem = pemPrivate(pair);
+ Map headers =
+ HttpSign.signHeaders("GET", "/v1/models?foo=1", new byte[0], pem, "kid", "n", 1L);
+ assertTrue(headers.get("Signature-Input").contains("@query"));
+ String digest = headers.get("Content-Digest");
+ String sigParams = headers.get("Signature-Input").substring("sig1=".length());
+ String sigBase =
+ String.join(
+ "\n",
+ "\"@method\": GET",
+ "\"@path\": /v1/models",
+ "\"@query\": ?foo=1",
+ "\"content-digest\": " + digest,
+ "\"@signature-params\": " + sigParams);
+ String sigB64 =
+ headers
+ .get("Signature")
+ .substring("sig1=:".length(), headers.get("Signature").length() - 1);
+ Signature verifier = Signature.getInstance("Ed25519");
+ verifier.initVerify(pair.getPublic());
+ verifier.update(sigBase.getBytes(StandardCharsets.UTF_8));
+ assertTrue(verifier.verify(Base64.getDecoder().decode(sigB64)));
+ }
+
+ @Test
+ void pathAndQuery() {
+ HttpSign.PathAndQuery abs = HttpSign.pathAndQuery("http://x/v1/chat?a=1");
+ assertEquals("/v1/chat", abs.path());
+ assertEquals("?a=1", abs.query());
+ HttpSign.PathAndQuery empty = HttpSign.pathAndQuery("/v1/models");
+ assertEquals("/v1/models", empty.path());
+ assertEquals("?", empty.query());
+ }
+
+ @Test
+ void loadRoundtripKeyFactory() throws Exception {
+ KeyPair pair = ed25519();
+ byte[] pem = pemPrivate(pair);
+ var priv = HttpSign.loadPrivateKey(pem);
+ assertTrue(priv.getAlgorithm().equals("Ed25519") || priv.getAlgorithm().equals("EdDSA"));
+ }
+}
diff --git a/clients/java/src/test/java/ai/afi/platform/PlatformClientTest.java b/clients/java/src/test/java/ai/afi/platform/PlatformClientTest.java
new file mode 100644
index 0000000..3e0dd33
--- /dev/null
+++ b/clients/java/src/test/java/ai/afi/platform/PlatformClientTest.java
@@ -0,0 +1,127 @@
+package ai.afi.platform;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.Test;
+
+class PlatformClientTest {
+
+ @Test
+ void meSendsBearer() {
+ PlatformClient client =
+ PlatformClient.withExecutor(
+ "http://cp.test",
+ () -> "tok",
+ (method, uri, headers, body) -> {
+ assertEquals("GET", method);
+ assertEquals("/api/v1/platform/auth/me", uri.getPath());
+ assertEquals("Bearer tok", headers.get("Authorization"));
+ return new PlatformClient.RawResponse(
+ 200, "{\"id\":\"u1\",\"name\":\"A\",\"email\":\"a@b.c\",\"role\":\"user\"}");
+ });
+ JsonNode me = client.me();
+ assertEquals("u1", me.path("id").asText());
+ }
+
+ @Test
+ void errorEnvelope() {
+ PlatformClient client =
+ PlatformClient.withExecutor(
+ "http://cp.test",
+ () -> "tok",
+ (method, uri, headers, body) ->
+ new PlatformClient.RawResponse(403, "{\"error\":\"nope\"}"));
+ PlatformApiException ex =
+ assertThrows(PlatformApiException.class, client::listOrganizations);
+ assertEquals(403, ex.status());
+ assertEquals("nope", ex.getMessage());
+ }
+
+ @Test
+ void loginNoAuth() {
+ AtomicReference auth = new AtomicReference<>();
+ PlatformClient client =
+ PlatformClient.withExecutor(
+ "http://cp.test",
+ null,
+ (method, uri, headers, body) -> {
+ auth.set(headers.get("Authorization"));
+ assertEquals("/api/v1/platform/auth/login", uri.getPath());
+ return new PlatformClient.RawResponse(200, "{\"token\":\"jwt\"}");
+ });
+ assertEquals("jwt", client.login("a@b.c", "x").path("token").asText());
+ assertNull(auth.get());
+ }
+
+ @Test
+ void registerAndResetNoAuth() {
+ List paths = new ArrayList<>();
+ PlatformClient client =
+ PlatformClient.withExecutor(
+ "http://cp.test",
+ null,
+ (method, uri, headers, body) -> {
+ paths.add(uri.getPath());
+ assertNull(headers.get("Authorization"));
+ String path = uri.getPath();
+ if (path.endsWith("/features")) {
+ return new PlatformClient.RawResponse(
+ 200, "{\"signup_enabled\":true,\"password_reset_enabled\":true}");
+ }
+ if (path.endsWith("/register")) {
+ return new PlatformClient.RawResponse(
+ 201,
+ "{\"token\":\"jwt\",\"user\":{\"id\":\"u1\",\"email\":\"a@b.c\",\"name\":\"A\",\"role\":\"member\"}}");
+ }
+ if (path.endsWith("/password-reset")) {
+ return new PlatformClient.RawResponse(200, "{\"ok\":true}");
+ }
+ return new PlatformClient.RawResponse(
+ 200,
+ "{\"token\":\"jwt2\",\"user\":{\"id\":\"u1\",\"email\":\"a@b.c\",\"name\":\"A\",\"role\":\"member\"}}");
+ });
+ assertTrue(client.authFeatures().path("signup_enabled").asBoolean());
+ assertEquals("jwt", client.register("a@b.c", "A", "password1").path("token").asText());
+ assertTrue(client.requestPasswordReset("a@b.c").path("ok").asBoolean());
+ assertEquals("jwt2", client.confirmPasswordReset("tok", "password2").path("token").asText());
+ assertTrue(paths.contains("/api/v1/platform/auth/features"));
+ }
+
+ @Test
+ void missingToken() {
+ PlatformClient client =
+ PlatformClient.withExecutor(
+ "http://cp.test",
+ () -> null,
+ (method, uri, headers, body) -> new PlatformClient.RawResponse(200, "{}"));
+ PlatformApiException ex = assertThrows(PlatformApiException.class, client::me);
+ assertEquals(401, ex.status());
+ assertEquals("missing access token", ex.getMessage());
+ }
+
+ @Test
+ void queryEncoding() throws Exception {
+ AtomicReference seen = new AtomicReference<>();
+ PlatformClient client =
+ PlatformClient.withExecutor(
+ "http://cp.test",
+ () -> "tok",
+ (method, uri, headers, body) -> {
+ seen.set(uri);
+ return new PlatformClient.RawResponse(200, "[]");
+ });
+ client.listUsage("org1", Map.of("limit", 10));
+ assertTrue(seen.get().toString().contains("limit=10"));
+ assertEquals("/api/v1/platform/organizations/org1/usage", seen.get().getPath());
+ }
+}
diff --git a/docs/api/index.md b/docs/api/index.md
index daaf078..93bc569 100644
--- a/docs/api/index.md
+++ b/docs/api/index.md
@@ -19,6 +19,7 @@ Thin platform clients (not for chat completions — use OpenAI/Anthropic/Gemini
- TypeScript: `clients/typescript` (`@afi-ai/platform-client`)
- Python: `clients/python` (`afi-platform`)
+- Java: `clients/java` (`ai.afi:platform-client`)
See [Platform API](platform.md) and [Gateway overlay](gateway.md).
diff --git a/docs/development/config-reference.md b/docs/development/config-reference.md
index ea82537..99d127a 100644
--- a/docs/development/config-reference.md
+++ b/docs/development/config-reference.md
@@ -223,6 +223,7 @@ Prefer signature label `sig1`. Client helpers that build these headers:
- Go: [`sdk/httpsign`](../../sdk/httpsign) (`SignRequest`, `Client`)
- Python: `afi_platform.sign_headers` in [`clients/python`](../../clients/python)
- TypeScript: `signHeaders` in [`clients/typescript`](../../clients/typescript)
+- Java: `HttpSign.signHeaders` in [`clients/java`](../../clients/java)
If signing headers are absent, the gateway falls back to the existing API key flow. Replay protection is enforced per gateway instance with a bounded in-memory nonce cache.
diff --git a/docs/development/repository-layout.md b/docs/development/repository-layout.md
index 38eed40..9c47152 100644
--- a/docs/development/repository-layout.md
+++ b/docs/development/repository-layout.md
@@ -88,7 +88,8 @@ Platform events (bus + durable outbox): [Platform domain events](platform-events
| `sdk/hook` | Lifecycle hook contracts (Go + WASM ABI docs) |
| `sdk/httpsign` | RFC 9421 / Content-Digest signing for gateway clients |
| `api/openapi` | Public OpenAPI contracts (platform + gateway overlay) |
-| `clients/*` | Thin TypeScript / Python platform HTTP clients |
+| `clients/*` | Thin TypeScript / Python / Java platform HTTP clients |
+
| `extensions/*` | Example SDK providers + hooks registered from `cmd/gateway` |
| `examples/a2a-echo` | Standalone A2A echo agent for local gateway / playground tests |
| `internal/modelcatalog` | Curated model metadata (mode, context, pricing) |
diff --git a/scripts/release-client-java.sh b/scripts/release-client-java.sh
new file mode 100755
index 0000000..2018ef2
--- /dev/null
+++ b/scripts/release-client-java.sh
@@ -0,0 +1,182 @@
+#!/usr/bin/env bash
+# Build, test, and publish ai.afi:platform-client to GitHub Packages.
+#
+# Usage:
+# bash scripts/release-client-java.sh
+# DRY_RUN=1 bash scripts/release-client-java.sh
+# VERSION=1.2.3 bash scripts/release-client-java.sh
+#
+# Env:
+# DRY_RUN=1 / SKIP_TESTS=1 / SKIP_PUBLISH=1 / COMMIT_BUMP=1 / VERSION
+# GITHUB_TOKEN — required to publish (and to read latest published version)
+# GITHUB_ACTOR — GitHub username (default: github-actions[bot] in CI)
+# GITHUB_REPOSITORY — owner/repo (default: curefatih/afi)
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+# shellcheck source=semver.sh
+source "${ROOT}/scripts/semver.sh"
+
+PKG_DIR="${ROOT}/clients/java"
+POM="${PKG_DIR}/pom.xml"
+ARTIFACT="platform-client"
+# GitHub Packages Maven package name is groupId.artifactId
+GH_PACKAGE="ai.afi.platform-client"
+GITHUB_REPOSITORY="${GITHUB_REPOSITORY:-curefatih/afi}"
+GH_OWNER="${GITHUB_REPOSITORY%%/*}"
+DRY_RUN="${DRY_RUN:-0}"
+SKIP_TESTS="${SKIP_TESTS:-0}"
+SKIP_PUBLISH="${SKIP_PUBLISH:-0}"
+COMMIT_BUMP="${COMMIT_BUMP:-0}"
+MVN="${MVN:-mvn}"
+
+read_local_version() {
+ sed -n 's|^[[:space:]]*\([^<]*\).*|\1|p' "${POM}" | head -n1
+}
+
+read_published_version() {
+ local token="${GITHUB_TOKEN:-}"
+ local url
+ url="https://api.github.com/users/${GH_OWNER}/packages/maven/${GH_PACKAGE}/versions?per_page=1"
+ if [[ -z "${token}" ]]; then
+ # Anonymous metadata fetch often fails for GitHub Packages; treat as unpublished.
+ return 0
+ fi
+ python3 - <<'PY' "${url}" "${token}"
+import json, sys, urllib.request, urllib.error
+url, token = sys.argv[1], sys.argv[2]
+req = urllib.request.Request(
+ url,
+ headers={
+ "Accept": "application/vnd.github+json",
+ "Authorization": f"Bearer {token}",
+ "X-GitHub-Api-Version": "2022-11-28",
+ },
+)
+try:
+ with urllib.request.urlopen(req, timeout=30) as resp:
+ data = json.load(resp)
+except urllib.error.HTTPError as e:
+ if e.code in (404, 401, 403):
+ sys.exit(0)
+ raise
+if not data:
+ sys.exit(0)
+# Prefer non-deleted versions; API returns newest first.
+for item in data:
+ name = item.get("name")
+ if name and not item.get("deleted_at"):
+ print(name)
+ break
+PY
+}
+
+set_version() {
+ local v="$1"
+ python3 - <<'PY' "${POM}" "${v}"
+import pathlib, re, sys
+path = pathlib.Path(sys.argv[1])
+version = sys.argv[2]
+text = path.read_text()
+new, n = re.subn(
+ r"(platform-client\s*\n\s*)[^<]+()",
+ rf"\g<1>{version}\g<2>",
+ text,
+ count=1,
+)
+if n != 1:
+ raise SystemExit("failed to update version in pom.xml")
+path.write_text(new)
+PY
+}
+
+write_settings() {
+ local settings="$1"
+ local user="${GITHUB_ACTOR:-github-actions[bot]}"
+ local pass="${GITHUB_TOKEN:?GITHUB_TOKEN required for GitHub Packages}"
+ cat >"${settings}" <
+
+
+ github
+ ${user}
+ ${pass}
+
+
+
+EOF
+}
+
+cd "${PKG_DIR}"
+
+echo "==> Java client (ai.afi:${ARTIFACT}) → GitHub Packages"
+local_v="$(read_local_version)"
+published_v="$(read_published_version || true)"
+if [[ -n "${VERSION:-}" ]]; then
+ next_v="${VERSION}"
+else
+ next_v="$(semver_next_publish "${local_v}" "${published_v}")"
+fi
+semver_is_valid "${next_v}" || { echo "invalid VERSION=${next_v}" >&2; exit 1; }
+
+echo " local=${local_v} published=${published_v:-} → release=${next_v}"
+
+if [[ -n "${published_v}" && "$(semver_cmp "${next_v}" "${published_v}")" != "1" ]]; then
+ echo "ERROR: release version ${next_v} is not greater than published ${published_v}" >&2
+ exit 1
+fi
+
+if [[ "${next_v}" != "${local_v}" ]]; then
+ echo "==> Bumping version ${local_v} → ${next_v}"
+ set_version "${next_v}"
+fi
+
+if [[ "${SKIP_TESTS}" != "1" ]]; then
+ echo "==> Test"
+ "${MVN}" -B -q test
+else
+ echo "==> Package (skip tests)"
+ "${MVN}" -B -q -DskipTests package
+fi
+
+echo "==> Package"
+"${MVN}" -B -q -DskipTests package
+
+if [[ "${DRY_RUN}" == "1" || "${SKIP_PUBLISH}" == "1" ]]; then
+ echo "==> Dry run / skip publish"
+ ls -la target/*.jar
+ echo "Done (not published)."
+ exit 0
+fi
+
+if [[ -z "${GITHUB_TOKEN:-}" ]]; then
+ echo "ERROR: set GITHUB_TOKEN to publish to GitHub Packages" >&2
+ exit 1
+fi
+
+SETTINGS="$(mktemp)"
+trap 'rm -f "${SETTINGS}"' EXIT
+write_settings "${SETTINGS}"
+
+echo "==> Publish ai.afi:${ARTIFACT}:${next_v} to GitHub Packages"
+"${MVN}" -B -q -s "${SETTINGS}" -DskipTests deploy
+
+if [[ "${COMMIT_BUMP}" == "1" ]]; then
+ echo "==> Commit version bump / tag"
+ git -C "${ROOT}" add "${POM}"
+ if ! git -C "${ROOT}" diff --cached --quiet; then
+ git -C "${ROOT}" commit -m "chore(clients): bump java to ${next_v} [skip release]"
+ fi
+ tag="clients-java-v${next_v}"
+ if git -C "${ROOT}" rev-parse "${tag}" >/dev/null 2>&1; then
+ echo " tag ${tag} already exists"
+ else
+ git -C "${ROOT}" tag -a "${tag}" -m "Release ai.afi:${ARTIFACT}:${next_v}"
+ echo " tagged ${tag}"
+ fi
+fi
+
+echo "Done. Published ai.afi:${ARTIFACT}:${next_v}"
+echo " https://github.com/${GITHUB_REPOSITORY}/packages"
diff --git a/scripts/release-clients.sh b/scripts/release-clients.sh
index 0775f32..60a7e9d 100755
--- a/scripts/release-clients.sh
+++ b/scripts/release-clients.sh
@@ -8,7 +8,7 @@
#
# Env:
# BASE_REF — git ref to diff against (default: HEAD~1, or origin/main if set)
-# CLIENTS — comma list: typescript,python,all (default: auto-detect from diff)
+# CLIENTS — comma list: typescript,python,java,all (default: auto-detect from diff)
# FORCE=1 — release even when the path did not change
# DRY_RUN, SKIP_TESTS, SKIP_PUBLISH, COMMIT_BUMP, VERSION — passed through
set -euo pipefail
@@ -35,6 +35,7 @@ path_changed() {
want_typescript=0
want_python=0
+want_java=0
if [[ -n "${CLIENTS}" && "${CLIENTS}" != "all" ]]; then
IFS=',' read -r -a list <<<"${CLIENTS}"
@@ -43,12 +44,14 @@ if [[ -n "${CLIENTS}" && "${CLIENTS}" != "all" ]]; then
case "${c}" in
typescript|ts) want_typescript=1 ;;
python|py) want_python=1 ;;
+ java) want_java=1 ;;
*) echo "unknown CLIENTS entry: ${c}" >&2; exit 1 ;;
esac
done
elif [[ "${CLIENTS}" == "all" || "${FORCE}" == "1" ]]; then
want_typescript=1
want_python=1
+ want_java=1
else
echo "==> Detecting client changes since ${BASE_REF}"
if path_changed "clients/typescript"; then
@@ -63,16 +66,23 @@ else
else
echo " python: unchanged"
fi
+ if path_changed "clients/java"; then
+ want_java=1
+ echo " java: changed"
+ else
+ echo " java: unchanged"
+ fi
fi
-if [[ "${want_typescript}" != "1" && "${want_python}" != "1" ]]; then
+if [[ "${want_typescript}" != "1" && "${want_python}" != "1" && "${want_java}" != "1" ]]; then
echo "No client path changes — nothing to release."
exit 0
fi
pass_env=( )
for key in DRY_RUN SKIP_TESTS SKIP_PUBLISH COMMIT_BUMP VERSION NODE_AUTH_TOKEN NPM_TOKEN \
- NPM_TRUSTED_PUBLISHING TWINE_USERNAME TWINE_PASSWORD PYPI_API_TOKEN TWINE_REPOSITORY_URL PYTHON; do
+ NPM_TRUSTED_PUBLISHING TWINE_USERNAME TWINE_PASSWORD PYPI_API_TOKEN TWINE_REPOSITORY_URL \
+ GITHUB_TOKEN GITHUB_ACTOR GITHUB_REPOSITORY PYTHON MVN; do
if [[ -n "${!key:-}" ]]; then
pass_env+=("${key}=${!key}")
fi
@@ -88,5 +98,10 @@ if [[ "${want_python}" == "1" ]]; then
env "${pass_env[@]}" bash "${ROOT}/scripts/release-client-python.sh"
fi
+if [[ "${want_java}" == "1" ]]; then
+ echo
+ env "${pass_env[@]}" bash "${ROOT}/scripts/release-client-java.sh"
+fi
+
echo
echo "Client release finished."