diff --git a/.github/workflows/release-clients.yml b/.github/workflows/release-clients.yml index 710970a..1b25ab2 100644 --- a/.github/workflows/release-clients.yml +++ b/.github/workflows/release-clients.yml @@ -1,7 +1,7 @@ -# Publish TypeScript / Python platform clients when their paths change on main. +# Publish TypeScript / Python / Java platform clients when their paths change on main. # # - push to main under clients/** → detect which client(s) changed and publish -# - workflow_dispatch → optional CLIENTS override (typescript|python|all) +# - workflow_dispatch → optional CLIENTS override (typescript|python|java|all) # # npm (OIDC trusted publishing — no NPM_TOKEN): # https://www.npmjs.com/package/@afi-ai/platform-client/access @@ -11,6 +11,11 @@ # Environment: leave blank (must match this workflow) # # Python: PYPI_API_TOKEN secret (or PyPI trusted publisher) +# Java: GitHub Packages via GITHUB_TOKEN (packages:write) +# +# Version bump PR (main is PR-only): +# Enable Actions → "Allow GitHub Actions to create and approve pull requests" +# OR set secret CLIENT_RELEASE_GH_TOKEN (PAT with contents + pull requests) name: Release clients @@ -22,6 +27,8 @@ on: - '!clients/typescript/package.json' - 'clients/python/**' - '!clients/python/pyproject.toml' + - 'clients/java/**' + - '!clients/java/pom.xml' - 'scripts/release-client-*.sh' - 'scripts/release-clients.sh' - 'scripts/open-client-version-pr.sh' @@ -30,7 +37,7 @@ on: workflow_dispatch: inputs: clients: - description: "Clients to release (typescript, python, or all)" + description: "Clients to release (typescript, python, java, or all)" required: true default: all type: choice @@ -38,6 +45,7 @@ on: - all - typescript - python + - java dry_run: description: "Build and test without publishing" required: false @@ -48,6 +56,7 @@ permissions: contents: write id-token: write pull-requests: write + packages: write concurrency: group: release-clients-${{ github.ref }} @@ -63,6 +72,7 @@ jobs: contents: write id-token: write pull-requests: write + packages: write steps: - uses: actions/checkout@v4 with: @@ -106,6 +116,16 @@ jobs: with: python-version: "3.12" + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + cache: maven + cache-dependency-path: clients/java/pom.xml + server-id: github + server-username: GITHUB_ACTOR + server-password: GITHUB_TOKEN + - name: Semver unit tests run: bash scripts/semver_test.sh @@ -142,6 +162,10 @@ jobs: clients/python scripts/release-client-python.sh | grep -q .; then clients+=(python) fi + if git diff --name-only "${before}" HEAD -- \ + clients/java scripts/release-client-java.sh | grep -q .; then + clients+=(java) + fi if [[ ${#clients[@]} -eq 0 ]]; then echo "clients=" >> "$GITHUB_OUTPUT" else @@ -153,6 +177,9 @@ jobs: env: PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }} TWINE_USERNAME: __token__ + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_REPOSITORY: ${{ github.repository }} DRY_RUN: ${{ steps.resolve.outputs.dry_run }} COMMIT_BUMP: ${{ steps.resolve.outputs.commit_bump }} CLIENTS: ${{ steps.resolve.outputs.clients }} @@ -162,7 +189,10 @@ jobs: - name: Push tags and open version bump PR if: steps.resolve.outputs.clients != '' && steps.resolve.outputs.commit_bump == '1' env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # GITHUB_TOKEN cannot create PRs unless the repo enables + # "Allow GitHub Actions to create and approve pull requests". + # Optional CLIENT_RELEASE_GH_TOKEN (PAT) bypasses that restriction. + GH_TOKEN: ${{ secrets.CLIENT_RELEASE_GH_TOKEN || secrets.GITHUB_TOKEN }} GITHUB_REF_NAME: ${{ github.ref_name }} run: bash scripts/open-client-version-pr.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 62eacd9..1a2be9e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -125,6 +125,13 @@ jobs: with: python-version: "3.12" + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + cache: maven + cache-dependency-path: clients/java/pom.xml + - name: Install PyYAML run: pip install pyyaml @@ -142,3 +149,7 @@ jobs: run: | pip install -e ".[dev]" pytest -q + + - name: Java client tests + working-directory: clients/java + run: mvn -B -q test diff --git a/Makefile b/Makefile index d7ca717..ba63f70 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ quickstart deploy-init deploy-up deploy-down deploy-logs deploy-health \ deploy-infra deploy-controlplane deploy-dataplane deploy-worker deploy-web \ build-release build-images brand-assets \ - release-clients release-client-typescript release-client-python \ + release-clients release-client-typescript release-client-python release-client-java \ openapi-lint openapi-drift openapi-gen openapi-check \ proto-gen proto-check @@ -79,6 +79,7 @@ build-release: # Publish platform clients (npm / PyPI). Examples: # DRY_RUN=1 make release-clients # CLIENTS=typescript make release-client-typescript +# CLIENTS=java make release-client-java # FORCE=1 CLIENTS=all make release-clients release-clients: bash scripts/release-clients.sh @@ -89,6 +90,9 @@ release-client-typescript: release-client-python: bash scripts/release-client-python.sh +release-client-java: + bash scripts/release-client-java.sh + build-images: @test -f $(DEPLOY_ENV) || (echo "missing $(DEPLOY_ENV) — run make deploy-init" >&2; exit 1) docker compose -f $(DEPLOY_COMPOSE) --env-file $(DEPLOY_ENV) \ @@ -140,7 +144,7 @@ openapi-drift: openapi-gen: $(NPX) --yes openapi-typescript@7 api/openapi/platform.openapi.yaml -o clients/typescript/src/schema.gen.ts @echo "Generated clients/typescript/src/schema.gen.ts" - @echo "Python client is hand-maintained against platform.openapi.yaml (see clients/python)." + @echo "Python and Java clients are hand-maintained against platform.openapi.yaml." openapi-check: openapi-lint openapi-drift $(MAKE) openapi-gen diff --git a/clients/README.md b/clients/README.md index bd3eea4..b2b2ed8 100644 --- a/clients/README.md +++ b/clients/README.md @@ -6,8 +6,9 @@ Thin clients generated / maintained against [`../api/openapi/platform.openapi.ya | ------- | -------- | ------- | | [`typescript/`](typescript/) | TypeScript | `npm i @afi-ai/platform-client` | | [`python/`](python/) | Python | `pip install afi-platform` | +| [`java/`](java/) | Java | `ai.afi:platform-client` ([GitHub Packages](https://github.com/curefatih/afi/packages)) | -Local development: `pnpm add ../clients/typescript` or `pip install -e clients/python`. +Local development: path install for TS/Python, or `mvn -f clients/java test`. These are **not** the in-process extension SDKs under [`../sdk/`](../sdk/). @@ -20,10 +21,11 @@ For **signed-request auth** (RFC 9421) instead of a virtual API key, use the sha | Go | [`../sdk/httpsign`](../sdk/httpsign) — `SignRequest`, `Client` | | Python | `afi_platform.sign_headers` | | TypeScript | `signHeaders` from `@afi-ai/platform-client` | +| Java | `ai.afi.platform.HttpSign.signHeaders` | ## Releasing -Pushes to `main` that touch `clients/typescript` or `clients/python` run [`.github/workflows/release-clients.yml`](../.github/workflows/release-clients.yml). Each changed client is tested, versioned (patch auto-bump when the local version is already published), and published to npm / PyPI. +Pushes to `main` that touch `clients/typescript`, `clients/python`, or `clients/java` run [`.github/workflows/release-clients.yml`](../.github/workflows/release-clients.yml). Each changed client is tested, versioned (patch auto-bump when the local version is already published), and published to npm / PyPI / GitHub Packages. ### npm auth (TypeScript) @@ -45,10 +47,17 @@ After a successful publish, CI pushes the release tag, opens `chore/clients-version-bump`, and **merges it** (`gh pr merge --squash`). Title includes `[skip release]` so merge does not republish. -If auto-merge fails under branch rules, enable **Settings → General → Allow -auto-merge**, and/or allow `github-actions[bot]` to bypass required -reviews/checks for that PR. The workflow falls back to `--auto` (merge when -checks go green). +**Required for PR create/merge with `GITHUB_TOKEN`:** + +1. **Settings → Actions → General → Workflow permissions** + - Read and write permissions + - ✅ **Allow GitHub Actions to create and approve pull requests** + +Or set secret `CLIENT_RELEASE_GH_TOKEN` to a fine-grained PAT (contents + +pull requests) / classic `repo` PAT — the workflow prefers that token. + +If merge still fails under branch rules, enable **Allow auto-merge** and/or +allow the bot/PAT to bypass required reviews/checks. If CI prints `ENEEDAUTH` or the “bypass 2FA” notice with no token configured, npm never completed the OIDC exchange — usually an empty `_authToken` in `$NPM_CONFIG_USERCONFIG`, a Trusted Publisher mismatch, or npm < 11.5.1. @@ -56,11 +65,21 @@ If CI prints `ENEEDAUTH` or the “bypass 2FA” notice with no token configured Repo secret `PYPI_API_TOKEN` (or configure a PyPI trusted publisher for this workflow). +### GitHub Packages (Java) + +CI publishes with `GITHUB_TOKEN` (`packages: write`). No extra Maven secrets. + +Consumers add the repo + a PAT with `read:packages` — see [`java/README.md`](java/README.md). + +```bash +DRY_RUN=1 CLIENTS=java FORCE=1 make release-clients +GITHUB_TOKEN=… CLIENTS=java FORCE=1 make release-client-java +``` + ### Manual / dry-run ```bash DRY_RUN=1 make release-clients # detect changes since HEAD~1 -DRY_RUN=1 CLIENTS=typescript FORCE=1 make release-clients -NODE_AUTH_TOKEN=… make release-client-typescript -PYPI_API_TOKEN=… make release-client-python +DRY_RUN=1 CLIENTS=java FORCE=1 make release-clients +CLIENTS=typescript FORCE=1 make release-clients ``` diff --git a/clients/java/.gitignore b/clients/java/.gitignore new file mode 100644 index 0000000..434dfcf --- /dev/null +++ b/clients/java/.gitignore @@ -0,0 +1,6 @@ +target/ +.idea/ +*.iml +.classpath +.project +.settings/ diff --git a/clients/java/README.md b/clients/java/README.md new file mode 100644 index 0000000..4b2f714 --- /dev/null +++ b/clients/java/README.md @@ -0,0 +1,74 @@ +# ai.afi:platform-client + +Thin Java client for the AFI control plane (`/api/v1/platform`). + +Distributed via **GitHub Packages**: +https://github.com/curefatih/afi/packages + +```bash +cd clients/java && mvn test +``` + +### Install (Maven) + +Add the GitHub Packages repository and dependency (needs a PAT with `read:packages`): + +```xml + + + github + https://maven.pkg.github.com/curefatih/afi + + + + + ai.afi + platform-client + 1.0.0 + +``` + +`~/.m2/settings.xml`: + +```xml + + + github + YOUR_GITHUB_USERNAME + YOUR_PAT_WITH_READ_PACKAGES + + +``` + +```java +import ai.afi.platform.PlatformClient; + +try (PlatformClient client = new PlatformClient("http://localhost:8081")) { + String token = client.login("admin@example.com", "secret").path("token").asText(); + PlatformClient authed = new PlatformClient("http://localhost:8081", () -> token); + System.out.println(authed.listOrganizations()); +} +``` + +Contract: [`../../api/openapi/platform.openapi.yaml`](../../api/openapi/platform.openapi.yaml). + +## Gateway signed requests + +```java +import ai.afi.platform.HttpSign; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +byte[] pem = Files.readAllBytes(Path.of("signer.pem")); +Map 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."