From 5ab0a31a304842dd08238cea92feb8a188dc960e Mon Sep 17 00:00:00 2001 From: Madhu Dollu Date: Thu, 2 Apr 2026 15:52:06 +0530 Subject: [PATCH 1/7] add first iteration of the sdk for java --- .github/workflows/ci.yml | 28 ++ .github/workflows/publish.yml | 50 +++ .gitignore | 7 + README.md | 287 ++++++++++++++++ pom.xml | 73 ++++ publish.md | 165 +++++++++ src/main/java/com/supaship/Constants.java | 10 + .../com/supaship/FeatureEvaluateJson.java | 116 +++++++ src/main/java/com/supaship/NetworkConfig.java | 90 +++++ src/main/java/com/supaship/RetryConfig.java | 59 ++++ src/main/java/com/supaship/SupaClient.java | 324 ++++++++++++++++++ .../java/com/supaship/SupaClientConfig.java | 142 ++++++++ .../java/com/supaship/SupaClientListener.java | 28 ++ .../java/com/supaship/SupashipException.java | 27 ++ .../com/supaship/internal/AsyncRetry.java | 99 ++++++ .../java/com/supaship/AsyncRetryTest.java | 59 ++++ .../com/supaship/FeatureEvaluateJsonTest.java | 65 ++++ .../java/com/supaship/RetryConfigTest.java | 23 ++ .../com/supaship/SupaClientConfigTest.java | 28 ++ .../java/com/supaship/SupaClientHttpTest.java | 196 +++++++++++ 20 files changed, 1876 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 pom.xml create mode 100644 publish.md create mode 100644 src/main/java/com/supaship/Constants.java create mode 100644 src/main/java/com/supaship/FeatureEvaluateJson.java create mode 100644 src/main/java/com/supaship/NetworkConfig.java create mode 100644 src/main/java/com/supaship/RetryConfig.java create mode 100644 src/main/java/com/supaship/SupaClient.java create mode 100644 src/main/java/com/supaship/SupaClientConfig.java create mode 100644 src/main/java/com/supaship/SupaClientListener.java create mode 100644 src/main/java/com/supaship/SupashipException.java create mode 100644 src/main/java/com/supaship/internal/AsyncRetry.java create mode 100644 src/test/java/com/supaship/AsyncRetryTest.java create mode 100644 src/test/java/com/supaship/FeatureEvaluateJsonTest.java create mode 100644 src/test/java/com/supaship/RetryConfigTest.java create mode 100644 src/test/java/com/supaship/SupaClientConfigTest.java create mode 100644 src/test/java/com/supaship/SupaClientHttpTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f23530b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + java-version: "11" + distribution: "temurin" + cache: "maven" + + - name: Maven verify (compile, test, package) + run: mvn -B -ntp verify diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..b4bde9e --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,50 @@ +# Publishes the library to GitHub Packages when you publish a GitHub Release, +# or manually via workflow_dispatch (default branch). +# +# Consumers add GitHub Packages as a Maven repository, for example: +# +# +# github +# https://maven.pkg.github.com/OWNER/REPO +# +# +# and authenticate (token with read:packages). See GitHub’s “Apache Maven registry”. +# +# For Maven Central, use local `mvn deploy` with your Sonatype credentials +# and signing (see publish.md); do not rely on this workflow alone. + +name: Publish + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + github-packages: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.ref }} + + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + java-version: "11" + distribution: "temurin" + cache: "maven" + server-id: github + server-username: ${{ github.actor }} + server-password: ${{ secrets.GITHUB_TOKEN }} + + - name: Deploy to GitHub Packages + run: | + mvn -B -ntp deploy \ + -DaltDeploymentRepository=github::default::https://maven.pkg.github.com/${{ github.repository }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84e4505 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +target/ +.idea/ +*.iml +.project +.classpath +.settings/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..e8303f4 --- /dev/null +++ b/README.md @@ -0,0 +1,287 @@ +# Supaship Java SDK + +Small, production oriented client for [Supaship](https://supaship.com) feature flags. It mirrors the public behavior of [`@supashiphq/javascript-sdk`](https://www.npmjs.com/package/@supashiphq/javascript-sdk) (`SupaClient`): same default endpoints, request shape, retry policy, timeouts, sensitive-context hashing, and fallback rules when the API is unavailable. + +**Runtime:** Java 11 or newer. **Dependencies:** [Gson](https://github.com/google/gson) only (JSON). HTTP uses `java.net.http`. + +Browser only features from the JS SDK (for example the toolbar plugin) are not applicable here. + +## Install + +### Maven + +```xml + + com.supaship + supaship-sdk + 1.0.0 + +``` + +Replace the version with the latest release you publish (see [publish.md](publish.md) if you are publishing this library yourself). + +### Gradle (Kotlin DSL) + +```kotlin +dependencies { + implementation("com.supaship:supaship-sdk:1.0.0") +} +``` + +## Quick start + +```java +import com.supaship.SupaClient; +import com.supaship.SupaClientConfig; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +public class Example { + public static void main(String[] args) throws ExecutionException, InterruptedException { + Map fallbacks = + Map.of( + "dark-mode", false, + "max-items", 10L); + + SupaClient client = + new SupaClient( + SupaClientConfig.builder() + .sdkKey(System.getenv("SUPASHIP_SDK_KEY")) + .environment("production") + .features(fallbacks) + .context(Map.of("region", "eu")) + .build()); + + boolean dark = + (Boolean) client.getFeature("dark-mode").get(); + + Map batch = + client.getFeatures(List.of("dark-mode", "max-items")).get(); + + client.updateContext(Map.of("plan", "pro"), true); + } +} +``` + +### Async API + +All network calls return `CompletableFuture`. Use `thenApply`, `whenComplete`, or block with `get()` / `join()` as appropriate. + +```java +client + .getFeature("dark-mode", Map.of("userId", "u-123")) + .thenAccept( + value -> { + // value is the flag value or the configured fallback type + }); +``` + +## Configuration + +| Area | Java type | Notes | +|------|-----------|--------| +| SDK key | `SupaClientConfig.Builder.sdkKey` | Same as JS `sdkKey`; sent as `Authorization: Bearer …`. | +| Environment | `environment` | Same as JS `environment` (for example `production`, `staging`). | +| Fallbacks | `features(Map)` | Same role as JS `features` / `FeaturesWithFallbacks`. Used when the API fails or a variation is absent. Values may be `Boolean`, `Number`, `String`, `List`, `Map`, or `null`. | +| Default context | `context` | Merged into every evaluation unless you pass a per-call override. | +| Sensitive fields | `sensitiveContextProperties(Set)` | Names of context keys whose values are replaced with a **SHA-256** hex digest before the request is sent (same idea as the JS client). | +| Network | `NetworkConfig` | Optional: `featuresApiUrl`, `eventsApiUrl` (reserved for future use), `retry`, `requestTimeout`, `HttpClient`. | + +Defaults match the JS SDK: + +- Features URL: `https://edge.supaship.com/v1/features` +- Events URL: `https://edge.supaship.com/v1/events` (not used by evaluate yet; kept for parity) +- Retry: enabled, 3 attempts, base backoff 1000 ms, exponential factor \(2^{attempt-1}\) +- Request timeout: 10 seconds + +### Custom `HttpClient` + +Use your own `java.net.http.HttpClient` for TLS settings, proxy, or HTTP version: + +```java +import com.supaship.NetworkConfig; +import com.supaship.SupaClientConfig; + +import java.net.http.HttpClient; +import java.time.Duration; +import java.util.Map; + +HttpClient http = + HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .version(HttpClient.Version.HTTP_1_1) + .build(); + +NetworkConfig network = + NetworkConfig.builder() + .httpClient(http) + .featuresApiUrl("https://edge.supaship.com/v1/features") + .build(); + +SupaClientConfig config = + SupaClientConfig.builder() + .sdkKey(key) + .environment("production") + .features(Map.of("flag", false)) + .networkConfig(network) + .build(); +``` + +### Listeners + +`SupaClientListener` provides optional hooks similar in spirit to JS plugins (before/after fetch, retries, errors, fallbacks, context updates). Implement only what you need; default methods are no-ops. + +```java +import com.supaship.SupaClientConfig; +import com.supaship.SupaClientListener; + +import java.util.Map; + +SupaClientConfig config = + SupaClientConfig.builder() + .sdkKey(key) + .environment("production") + .features(Map.of("flag", false)) + .addListener( + new SupaClientListener() { + @Override + public void onError(Throwable error, Map context) { + // log, metrics, etc. + } + }) + .build(); +``` + +## Spring Boot + +Use a single application scoped `SupaClient` bean and inject it where needed. Prefer configuration properties for the SDK key and environment. + +### `application.properties` + +```properties +supaship.sdk-key=${SUPASHIP_SDK_KEY} +supaship.environment=production +``` + +### Configuration bean + +```java +import com.supaship.NetworkConfig; +import com.supaship.SupaClient; +import com.supaship.SupaClientConfig; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; + +import java.util.Map; + +@Configuration +@EnableConfigurationProperties(SupashipConfiguration.SupashipProps.class) +public class SupashipConfiguration { + + @Bean + public SupaClient supaClient(SupashipProps props) { + Map features = + Map.of( + "new-checkout", false, + "banner-message", "Welcome"); + + SupaClientConfig config = + SupaClientConfig.builder() + .sdkKey(props.getSdkKey()) + .environment(props.getEnvironment()) + .features(features) + .context(Map.of("service", "api")) + .networkConfig(NetworkConfig.builder().build()) + .build(); + + return new SupaClient(config); + } + + @ConfigurationProperties(prefix = "supaship") + public static class SupashipProps { + private String sdkKey; + private String environment; + + public String getSdkKey() { + return sdkKey; + } + + public void setSdkKey(String sdkKey) { + this.sdkKey = sdkKey; + } + + public String getEnvironment() { + return environment; + } + + public void setEnvironment(String environment) { + this.environment = environment; + } + } +} +``` + +### Using the client in a service + +```java +import com.supaship.SupaClient; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +public class FeatureService { + private final SupaClient supaship; + + public FeatureService(SupaClient supaship) { + this.supaship = supaship; + } + + public boolean isNewCheckoutEnabled(String userId) throws Exception { + Object v = supaship.getFeature("new-checkout", Map.of("userId", userId)).get(); + return Boolean.TRUE.equals(v); + } +} +``` + +**Gradle note:** Spring Boot does not change how you add this SDK: use `implementation("com.supaship:supaship-sdk:…")` alongside your usual `org.springframework.boot` dependencies. + +## Quarkus / Micronaut / other frameworks + +There is no framework specific integration: create one `SupaClient` instance (or one per tenant) at startup with `SupaClientConfig`, expose it as a CDI bean / singleton, and inject it into resources or services the same way as any other HTTP client. + +## Error handling and fallbacks + +If the features HTTP call fails after retries, or the response is not successful, `getFeature` / `getFeatures` still **complete normally** with values from your configured `features` map (fallbacks). That matches the JavaScript client. + +Use `SupaClientListener.onError` and `onFallbackUsed` if you want metrics or logs when fallbacks are used. + +Very rare failures (for example if SHA-256 is unavailable) complete the `CompletableFuture` exceptionally with `SupashipException`. Ordinary HTTP or parse errors follow the fallback path above instead of failing the future. + +## Building from source + +```bash +mvn test package +``` + +The resulting JAR is `target/supaship-sdk-*.jar`. + +## JavaScript parity (summary) + +| JavaScript `SupaClient` | Java `SupaClient` | +|------------------------|-------------------| +| `getFeature`, `getFeatures` | Same, return `CompletableFuture` | +| `updateContext`, `getContext` | Same | +| `getFeatureFallback` | `getFeatureFallback` | +| `networkConfig` (URLs, retry, timeout, custom fetch) | `NetworkConfig` + optional `HttpClient` | +| `sensitiveContextProperties` | Same (SHA-256 hex) | +| Plugins | `SupaClientListener` (subset of hooks) | +| Toolbar plugin | Not applicable on the JVM | + +## License + +See [LICENSE](LICENSE). \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..ac09118 --- /dev/null +++ b/pom.xml @@ -0,0 +1,73 @@ + + + 4.0.0 + + com.supaship + supaship-sdk + 1.0.0-SNAPSHOT + jar + + Supaship SDK + Lightweight Java client for Supaship feature flags (Java 11+, Gson for JSON) + https://github.com/SupashipHQ/java-sdk + + + + MIT License + https://opensource.org/licenses/MIT + + + + + UTF-8 + 11 + 5.10.2 + 2.13.2 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.12.1 + + ${maven.compiler.release} + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + true + + + + + + + + + + com.google.code.gson + gson + ${gson.version} + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + diff --git a/publish.md b/publish.md new file mode 100644 index 0000000..4cd6af9 --- /dev/null +++ b/publish.md @@ -0,0 +1,165 @@ +# Publishing the Supaship Java SDK + +This guide assumes you have **never** published a Java library before. It explains how artifacts get into a **package registry** that Maven and Gradle users can consume, and how to publish **updates** later. + +## 1. What you are publishing + +A Maven project produces a **JAR** and a **POM** (metadata). Together they are identified by three coordinates: + +| Coordinate | This project (example) | Meaning | +|-------------|-------------------------|---------| +| `groupId` | `com.supaship` | Your organization or product namespace (like an npm scope). | +| `artifactId`| `supaship-sdk` | The library name. | +| `version` | `1.0.0` | Semantic version; **release** versions must not end in `-SNAPSHOT`. | + +**Snapshot** versions (for example `1.0.0-SNAPSHOT`) are mutable development builds. **Release** versions (for example `1.0.0`) are immutable once on Maven Central. + +The **de facto** public registry for open source Java is **Maven Central** (synced from Sonatype). Alternatives include **GitHub Packages**, **JitPack**, or a private Nexus; this doc focuses on **Maven Central**, which is what most developers expect. + +## 2. Prerequisites on your machine + +1. **JDK 11+** (this project targets Java 11). +2. **Apache Maven** (3.9+ recommended). +3. **GnuPG** (`gpg`) for **signing** artifacts. Maven Central requires cryptographic signatures for releases. + +Install GnuPG and create a key (one time): + +```bash +gpg --full-generate-key +# Choose RSA, 4096 bits, expiry as you prefer, use your real name and the email you will register with Sonatype. +gpg --list-secret-keys --keyid-format=long +``` + +Publish the **public** key to a keyserver (Ubuntu keyserver is commonly used): + +```bash +gpg --keyserver keyserver.ubuntu.com --send-keys YOUR_KEY_ID_LONG +``` + +## 3. Register a namespace with Sonatype (Maven Central) + +Modern flow uses the **Central Portal**: https://central.sonatype.com/ + +1. **Sign up** and sign in. +2. **Choose a namespace** that matches your `groupId`: + - **Reverse DNS** you control: for example if you own `supaship.com`, you can use `com.supaship` after proving domain ownership (DNS TXT record they specify). + - **`io.github.your-org`** if you publish from GitHub and verify the namespace they require for that pattern. + +3. **Open a namespace / verify** following the portal wizard (DNS TXT, or GitHub org verification, depending on the namespace type). + +Until the namespace is approved, you **cannot** publish under that `groupId` to Maven Central. + +> If your current `groupId` is `com.supaship`, you must prove you control a domain that authorizes that group id, **or** change `groupId` in `pom.xml` to a namespace Sonatype assigns you (for example `io.github.supashiphq`). Coordinate this before the first release. + +## 4. Credentials in `settings.xml` (one time) + +Maven needs a **token** (or username/password) to upload. In the Central Portal, create a **User token** and add a `` entry. + +Edit `~/.m2/settings.xml` (create the file if missing). **Do not commit tokens to git.** + +```xml + + + + central + + + + + +``` + +The `` must match the `` repository `` in your `pom.xml` (Sonatype’s docs often use `central` or `ossrh`; follow the exact id from their publishing guide for the plugin you use). + +## 5. POM changes for publishing + +For Maven Central you typically need: + +1. **Project metadata**: `name`, `description`, `url`, `licenses`, `scm`, `developers`. +2. **Source and Javadoc JARs** (consumers and indexes expect them). +3. **GPG signing** of artifacts. +4. **distributionManagement** pointing at Sonatype’s deployment endpoint **or** use the official **Central Publishing** Maven plugin they document for new projects. + +Exact plugin coordinates change over time; always cross check https://central.sonatype.org/publish/publish-maven/ for the **current** recommended `pom.xml` fragment. + +A minimal **conceptual** checklist: + +- `maven-source-plugin` → attaches `-sources.jar` +- `maven-javadoc-plugin` → attaches `-javadoc.jar` +- `maven-gpg-plugin` → signs all attached artifacts +- **Staging / publishing** plugin as per Sonatype (classic OSSRH `nexus-staging-maven-plugin` vs newer Central Publishing) + +Add a **profile** `-P release` so day to day `mvn test` does not require GPG. + +## 6. First release (high level) + +1. Set **release version** in `pom.xml` (remove `-SNAPSHOT`), for example `1.0.0`. +2. Ensure `CHANGES` / git tag strategy is decided (`v1.0.0`). +3. Run locally: + + ```bash + mvn clean verify + ``` + + With signing profile (example): + + ```bash + mvn clean verify -P release + ``` + +4. **Deploy**: + + ```bash + mvn clean deploy -P release + ``` + +5. In the **Sonatype portal** (or Nexus UI if legacy): **close** the staging repository, **release** it, wait until artifacts propagate to **Maven Central** (often tens of minutes the first time). + +6. Verify in a browser: `https://repo1.maven.org/maven2/com/supaship/supaship-sdk/1.0.0/` (adjust `groupId` path: `com.supaship` → `com/supaship`). + +## 7. Later releases (updates) + +1. **Bump version** in `pom.xml`, for example `1.0.1` or `1.1.0`. +2. Commit and **tag** (`git tag v1.0.1`). +3. Run `mvn clean deploy -P release` again. +4. Close / release staging as before. + +**Semantic versioning** (recommended): + +- **PATCH** (`1.0.1`): bug fixes, documentation, internal refactors with no API change. +- **MINOR** (`1.1.0`): backward compatible API additions. +- **MAJOR** (`2.0.0`): breaking API changes. + +## 8. Snapshots (optional) + +If you want public `-SNAPSHOT` builds, configure a **snapshotRepository** in `distributionManagement` and publish snapshot versions. Many teams skip snapshots and only use Git commit hashes plus local `mvn install` for integration. + +## 9. Simpler alternative: GitHub Packages + +If Maven Central is too heavy for an early preview: + +1. Create a **Personal Access Token** with `write:packages`. +2. Add a `` in `distributionManagement` pointing at + `https://maven.pkg.github.com/OWNER/REPO`. +3. In `~/.m2/settings.xml`, add `` with your GitHub username and token. + +Consumers must add the same repository block in their `pom.xml` (GitHub Packages are not on the default Central mirror). + +## 10. Checklist before announcing a release + +- [ ] `mvn test` passes. +- [ ] Version is **not** `-SNAPSHOT` for a public release tag. +- [ ] `LICENSE` file matches `pom.xml` `licenses`. +- [ ] `groupId` is **approved** in Sonatype for your namespace. +- [ ] Javadoc builds (`mvn javadoc:javadoc` or via release profile). +- [ ] You can resolve the artifact from a **fresh** machine or project using only Central (or document extra repositories if using GitHub Packages). + +## 11. Where to get help + +- Sonatype: https://central.sonatype.org/ +- Maven Central status and search: https://central.sonatype.com/ + +When in doubt, Sonatype’s current **“Publish Maven”** article is the source of truth for XML snippets and plugin versions. diff --git a/src/main/java/com/supaship/Constants.java b/src/main/java/com/supaship/Constants.java new file mode 100644 index 0000000..ba8ad4f --- /dev/null +++ b/src/main/java/com/supaship/Constants.java @@ -0,0 +1,10 @@ +package com.supaship; + +/** Default Supaship API endpoints (same as the JavaScript SDK). */ +public final class Constants { + + public static final String DEFAULT_FEATURES_URL = "https://edge.supaship.com/v1/features"; + public static final String DEFAULT_EVENTS_URL = "https://edge.supaship.com/v1/events"; + + private Constants() {} +} diff --git a/src/main/java/com/supaship/FeatureEvaluateJson.java b/src/main/java/com/supaship/FeatureEvaluateJson.java new file mode 100644 index 0000000..f0caec9 --- /dev/null +++ b/src/main/java/com/supaship/FeatureEvaluateJson.java @@ -0,0 +1,116 @@ +package com.supaship; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Serializes/parses the Supaship features evaluate request and response using Gson. */ +final class FeatureEvaluateJson { + + private static final Gson GSON = + new GsonBuilder().serializeNulls().disableHtmlEscaping().create(); + + private FeatureEvaluateJson() {} + + static String buildEvaluateRequest( + String environment, List featureNames, Map context) { + Map body = new LinkedHashMap<>(); + body.put("environment", environment); + body.put("features", featureNames); + body.put("context", context); + return GSON.toJson(body); + } + + /** + * Parses {@code {"features":{"f":{"variation":...}}}} and returns map of feature name → + * variation value (Java representation). + */ + static Map parseEvaluateResponse(String json) { + JsonElement rootEl; + try { + rootEl = JsonParser.parseString(json); + } catch (JsonParseException e) { + throw e; + } + if (!rootEl.isJsonObject()) { + throw new IllegalArgumentException("Root must be a JSON object"); + } + JsonObject root = rootEl.getAsJsonObject(); + if (!root.has("features") || !root.get("features").isJsonObject()) { + throw new IllegalArgumentException("Missing or invalid 'features' object"); + } + JsonObject features = root.getAsJsonObject("features"); + Map out = new LinkedHashMap<>(); + for (String key : features.keySet()) { + JsonElement entry = features.get(key); + if (entry != null && entry.isJsonObject()) { + JsonObject node = entry.getAsJsonObject(); + if (node.has("variation")) { + out.put(key, toJava(node.get("variation"))); + } else { + out.put(key, null); + } + } else { + out.put(key, null); + } + } + return out; + } + + private static Object toJava(JsonElement el) { + if (el == null || el.isJsonNull()) { + return null; + } + if (el.isJsonPrimitive()) { + return primitiveToJava(el.getAsJsonPrimitive()); + } + if (el.isJsonArray()) { + JsonArray arr = el.getAsJsonArray(); + List list = new ArrayList<>(arr.size()); + for (JsonElement item : arr) { + list.add(toJava(item)); + } + return list; + } + if (el.isJsonObject()) { + JsonObject obj = el.getAsJsonObject(); + Map map = new LinkedHashMap<>(); + for (String k : obj.keySet()) { + map.put(k, toJava(obj.get(k))); + } + return map; + } + return null; + } + + private static Object primitiveToJava(JsonPrimitive p) { + if (p.isBoolean()) { + return p.getAsBoolean(); + } + if (p.isString()) { + return p.getAsString(); + } + if (p.isNumber()) { + String s = p.getAsNumber().toString(); + if (s.contains(".") || s.toLowerCase().contains("e")) { + return p.getAsDouble(); + } + try { + return Long.parseLong(s); + } catch (NumberFormatException e) { + return p.getAsDouble(); + } + } + return null; + } +} diff --git a/src/main/java/com/supaship/NetworkConfig.java b/src/main/java/com/supaship/NetworkConfig.java new file mode 100644 index 0000000..25aeeb3 --- /dev/null +++ b/src/main/java/com/supaship/NetworkConfig.java @@ -0,0 +1,90 @@ +package com.supaship; + +import java.net.http.HttpClient; +import java.time.Duration; +import java.util.Objects; + +/** + * Network settings for the Supaship client. Uses {@link java.net.http.HttpClient} (Java 11+). + */ +public final class NetworkConfig { + + private final String featuresApiUrl; + private final String eventsApiUrl; + private final RetryConfig retry; + private final Duration requestTimeout; + private final HttpClient httpClient; + + private NetworkConfig(Builder b) { + this.featuresApiUrl = b.featuresApiUrl; + this.eventsApiUrl = b.eventsApiUrl; + this.retry = b.retry; + this.requestTimeout = b.requestTimeout; + this.httpClient = b.httpClient != null ? b.httpClient : HttpClient.newBuilder().build(); + } + + public static Builder builder() { + return new Builder(); + } + + public String featuresApiUrl() { + return featuresApiUrl; + } + + public String eventsApiUrl() { + return eventsApiUrl; + } + + public RetryConfig retry() { + return retry; + } + + public Duration requestTimeout() { + return requestTimeout; + } + + public HttpClient httpClient() { + return httpClient; + } + + public static final class Builder { + + private String featuresApiUrl = Constants.DEFAULT_FEATURES_URL; + private String eventsApiUrl = Constants.DEFAULT_EVENTS_URL; + private RetryConfig retry = RetryConfig.defaultRetry(); + private Duration requestTimeout = Duration.ofMillis(10_000); + private HttpClient httpClient; + + public Builder featuresApiUrl(String featuresApiUrl) { + this.featuresApiUrl = Objects.requireNonNull(featuresApiUrl, "featuresApiUrl"); + return this; + } + + public Builder eventsApiUrl(String eventsApiUrl) { + this.eventsApiUrl = Objects.requireNonNull(eventsApiUrl, "eventsApiUrl"); + return this; + } + + public Builder retry(RetryConfig retry) { + this.retry = Objects.requireNonNull(retry, "retry"); + return this; + } + + public Builder requestTimeout(Duration requestTimeout) { + this.requestTimeout = Objects.requireNonNull(requestTimeout, "requestTimeout"); + return this; + } + + /** + * Optional custom {@link HttpClient} (SSL, proxy, version). When omitted, a default client is created. + */ + public Builder httpClient(HttpClient httpClient) { + this.httpClient = httpClient; + return this; + } + + public NetworkConfig build() { + return new NetworkConfig(this); + } + } +} diff --git a/src/main/java/com/supaship/RetryConfig.java b/src/main/java/com/supaship/RetryConfig.java new file mode 100644 index 0000000..0541f5b --- /dev/null +++ b/src/main/java/com/supaship/RetryConfig.java @@ -0,0 +1,59 @@ +package com.supaship; + +import java.util.Objects; + +/** Retry behavior for feature API requests (exponential backoff, same defaults as the JS SDK). */ +public final class RetryConfig { + + private final boolean enabled; + private final int maxAttempts; + private final long backoffMs; + + public RetryConfig(boolean enabled, int maxAttempts, long backoffMs) { + if (maxAttempts < 1) { + throw new IllegalArgumentException("maxAttempts must be at least 1"); + } + if (backoffMs < 0) { + throw new IllegalArgumentException("backoffMs must be non-negative"); + } + this.enabled = enabled; + this.maxAttempts = maxAttempts; + this.backoffMs = backoffMs; + } + + /** JS defaults: enabled true, 3 attempts, 1000 ms base backoff. */ + public static RetryConfig defaultRetry() { + return new RetryConfig(true, 3, 1000L); + } + + public boolean enabled() { + return enabled; + } + + public int maxAttempts() { + return maxAttempts; + } + + public long backoffMs() { + return backoffMs; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + RetryConfig that = (RetryConfig) o; + return enabled == that.enabled + && maxAttempts == that.maxAttempts + && backoffMs == that.backoffMs; + } + + @Override + public int hashCode() { + return Objects.hash(enabled, maxAttempts, backoffMs); + } +} diff --git a/src/main/java/com/supaship/SupaClient.java b/src/main/java/com/supaship/SupaClient.java new file mode 100644 index 0000000..65ac9f4 --- /dev/null +++ b/src/main/java/com/supaship/SupaClient.java @@ -0,0 +1,324 @@ +package com.supaship; + +import com.supaship.internal.AsyncRetry; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.Collectors; + +/** + * Supaship feature-flag client aligned with the JavaScript {@code SupaClient}: evaluates flags via + * the Supaship HTTP API with the same defaults (URLs, retry, timeout) and the same fallback rules + * when the network fails. + * + *

Requires Java 11+ ({@link java.net.http.HttpClient}) and Gson for JSON request/response bodies. + */ +public final class SupaClient { + + private final String sdkKey; + private final String environment; + private final Map featureDefinitions; + private final Object contextLock = new Object(); + private final Map defaultContext; + private final Set sensitiveContextProperties; + private final NetworkConfig network; + private final List listeners; + private final String clientId; + + public SupaClient(SupaClientConfig config) { + Objects.requireNonNull(config, "config"); + this.sdkKey = config.sdkKey(); + this.environment = config.environment(); + this.featureDefinitions = new HashMap<>(config.features()); + this.defaultContext = new HashMap<>(config.context()); + this.sensitiveContextProperties = config.sensitiveContextProperties(); + this.network = config.networkConfig(); + this.listeners = new ArrayList<>(config.listeners()); + this.clientId = generateClientId(); + } + + /** Stable per-instance id (same idea as the JS client for listeners/telemetry). */ + public String clientId() { + return clientId; + } + + public void updateContext(Map context, boolean mergeWithExisting) { + Map toApply = context == null ? Map.of() : context; + Map oldSnapshot; + Map newSnapshot; + synchronized (contextLock) { + oldSnapshot = new HashMap<>(defaultContext); + if (mergeWithExisting && !defaultContext.isEmpty()) { + putAllNullable(defaultContext, toApply); + } else { + defaultContext.clear(); + putAllNullable(defaultContext, toApply); + } + newSnapshot = new HashMap<>(defaultContext); + } + for (SupaClientListener listener : listeners) { + try { + listener.onContextUpdate(oldSnapshot, newSnapshot, "updateContext"); + } catch (Throwable ignored) { + // never fail core flow from a listener + } + } + } + + public Map getContext() { + synchronized (contextLock) { + return new HashMap<>(defaultContext); + } + } + + /** Fallback value from the configuration map for this feature. */ + public Object getFeatureFallback(String featureName) { + return featureDefinitions.get(featureName); + } + + public CompletableFuture getFeature(String featureName) { + return getFeature(featureName, null); + } + + public CompletableFuture getFeature(String featureName, Map contextOverride) { + List one = Collections.singletonList(featureName); + return getFeatures(one, contextOverride).thenApply(m -> m.get(featureName)); + } + + public CompletableFuture> getFeatures(List featureNames) { + return getFeatures(featureNames, null); + } + + /** + * Fetches evaluations for the given flags. On transport/HTTP/parse failure, returns fallback + * values from the configured feature map (same behavior as the JS SDK). If {@code featureNames} + * is empty, completes immediately with an empty map (no HTTP call). + */ + public CompletableFuture> getFeatures( + List featureNames, Map contextOverride) { + List names = + featureNames.stream().filter(Objects::nonNull).collect(Collectors.toList()); + if (names.isEmpty()) { + return CompletableFuture.completedFuture(Collections.emptyMap()); + } + + Map mergedContext = mergeContext(contextOverride); + if (contextOverride != null && !contextOverride.isEmpty()) { + Map defaultSnap = getContext(); + for (SupaClientListener listener : listeners) { + try { + listener.onContextUpdate(defaultSnap, new HashMap<>(mergedContext), "request"); + } catch (Throwable ignored) { + // + } + } + } + + for (SupaClientListener listener : listeners) { + try { + listener.beforeGetFeatures(Collections.unmodifiableList(names), mergedContext); + } catch (Throwable ignored) { + // + } + } + + Map contextForRequest; + try { + contextForRequest = hashSensitiveContext(mergedContext); + } catch (NoSuchAlgorithmException e) { + return CompletableFuture.failedFuture( + new SupashipException("SHA-256 not available", e)); + } + + RetryConfig retry = network.retry(); + CompletableFuture> evaluated = + AsyncRetry.runWithRetry( + attempt -> executeEvaluate(names, contextForRequest), + retry.maxAttempts(), + retry.backoffMs(), + retry.enabled(), + ev -> { + for (SupaClientListener listener : listeners) { + try { + listener.onRetryAttempt( + ev.attempt(), ev.error(), ev.willRetry()); + } catch (Throwable ignored) { + // + } + } + }, + null); + + return evaluated.handle( + (result, error) -> { + if (error == null) { + for (SupaClientListener listener : listeners) { + try { + listener.afterGetFeatures(result, mergedContext); + } catch (Throwable ignored) { + // + } + } + return result; + } + for (SupaClientListener listener : listeners) { + try { + listener.onError(error, mergedContext); + } catch (Throwable ignored) { + // + } + } + Map fallbacks = new LinkedHashMap<>(); + for (String name : names) { + Object fb = featureDefinitions.get(name); + fallbacks.put(name, fb); + for (SupaClientListener listener : listeners) { + try { + listener.onFallbackUsed(name, fb, error); + } catch (Throwable ignored) { + // + } + } + } + return fallbacks; + }); + } + + private CompletableFuture> executeEvaluate( + List featureNames, Map contextForRequest) { + String url = network.featuresApiUrl(); + String body = + FeatureEvaluateJson.buildEvaluateRequest(environment, featureNames, contextForRequest); + Map headers = new LinkedHashMap<>(); + headers.put("Content-Type", "application/json"); + headers.put("Authorization", "Bearer " + sdkKey); + for (SupaClientListener listener : listeners) { + try { + listener.beforeRequest(url, body, headers); + } catch (Throwable ignored) { + // + } + } + + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(network.requestTimeout()) + .header("Content-Type", headers.get("Content-Type")) + .header("Authorization", headers.get("Authorization")) + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) + .build(); + + long startNs = System.nanoTime(); + return network + .httpClient() + .sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)) + .thenApply( + response -> { + long durationMs = (System.nanoTime() - startNs) / 1_000_000L; + for (SupaClientListener listener : listeners) { + try { + listener.afterResponse(response.statusCode(), durationMs); + } catch (Throwable ignored) { + // + } + } + if (response.statusCode() / 100 != 2) { + throw new SupashipException( + response.statusCode(), + "Failed to fetch features: HTTP " + + response.statusCode()); + } + Map parsed = + FeatureEvaluateJson.parseEvaluateResponse(response.body()); + Map result = new LinkedHashMap<>(); + for (String name : featureNames) { + Object variation = parsed.get(name); + result.put( + name, + resolveVariation( + variation, featureDefinitions.get(name))); + } + return result; + }); + } + + private static Object resolveVariation(Object variation, Object fallback) { + if (variation != null) { + return variation; + } + return fallback; + } + + private Map mergeContext(Map contextOverride) { + synchronized (contextLock) { + if (contextOverride == null) { + return new HashMap<>(defaultContext); + } + Map merged = new HashMap<>(defaultContext); + putAllNullable(merged, contextOverride); + return merged; + } + } + + private Map hashSensitiveContext(Map context) + throws NoSuchAlgorithmException { + if (context == null + || context.isEmpty() + || sensitiveContextProperties == null + || sensitiveContextProperties.isEmpty()) { + return context; + } + MessageDigest md = MessageDigest.getInstance("SHA-256"); + Map out = new HashMap<>(context); + for (String key : sensitiveContextProperties) { + if (!out.containsKey(key)) { + continue; + } + Object val = out.get(key); + if (val == null) { + continue; + } + md.reset(); + md.update(String.valueOf(val).getBytes(StandardCharsets.UTF_8)); + out.put(key, toHex(md.digest())); + } + return out; + } + + private static void putAllNullable(Map dest, Map src) { + for (Map.Entry e : src.entrySet()) { + dest.put(e.getKey(), e.getValue()); + } + } + + private static String toHex(byte[] digest) { + StringBuilder sb = new StringBuilder(digest.length * 2); + for (byte b : digest) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + private static String generateClientId() { + String suffix = + Long.toString(ThreadLocalRandom.current().nextLong() & 0x1ffffffffffL, 36); + if (suffix.length() > 7) { + suffix = suffix.substring(0, 7); + } + return "supaship-" + System.currentTimeMillis() + "-" + suffix; + } +} diff --git a/src/main/java/com/supaship/SupaClientConfig.java b/src/main/java/com/supaship/SupaClientConfig.java new file mode 100644 index 0000000..09479eb --- /dev/null +++ b/src/main/java/com/supaship/SupaClientConfig.java @@ -0,0 +1,142 @@ +package com.supaship; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Configuration for {@link SupaClient}. Immutable after {@link Builder#build()}. */ +public final class SupaClientConfig { + + private final String sdkKey; + private final String environment; + private final Map features; + private final Map context; + private final Set sensitiveContextProperties; + private final NetworkConfig networkConfig; + private final List listeners; + + private SupaClientConfig(Builder b) { + this.sdkKey = b.sdkKey; + this.environment = b.environment; + this.features = Collections.unmodifiableMap(new HashMap<>(b.features)); + this.context = + b.context == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap<>(b.context)); + this.sensitiveContextProperties = + Collections.unmodifiableSet(new HashSet<>(b.sensitiveContextProperties)); + this.networkConfig = b.networkConfig != null ? b.networkConfig : NetworkConfig.builder().build(); + this.listeners = Collections.unmodifiableList(new ArrayList<>(b.listeners)); + } + + public static Builder builder() { + return new Builder(); + } + + public String sdkKey() { + return sdkKey; + } + + public String environment() { + return environment; + } + + /** Fallback values keyed by feature name (same role as {@code features} in the JS SDK). */ + public Map features() { + return features; + } + + /** Default evaluation context merged into each request unless overridden per call. */ + public Map context() { + return context; + } + + public Set sensitiveContextProperties() { + return sensitiveContextProperties; + } + + public NetworkConfig networkConfig() { + return networkConfig; + } + + public List listeners() { + return listeners; + } + + public static final class Builder { + + private String sdkKey; + private String environment; + private Map features = new HashMap<>(); + private Map context; + private Set sensitiveContextProperties = new HashSet<>(); + private NetworkConfig networkConfig; + private final List listeners = new ArrayList<>(); + + public Builder sdkKey(String sdkKey) { + this.sdkKey = sdkKey; + return this; + } + + public Builder environment(String environment) { + this.environment = environment; + return this; + } + + public Builder features(Map features) { + this.features.clear(); + if (features != null) { + for (Map.Entry e : features.entrySet()) { + this.features.put(e.getKey(), e.getValue()); + } + } + return this; + } + + public Builder context(Map context) { + if (context == null) { + this.context = null; + return this; + } + this.context = new HashMap<>(); + for (Map.Entry e : context.entrySet()) { + this.context.put(e.getKey(), e.getValue()); + } + return this; + } + + public Builder sensitiveContextProperties(Set sensitiveContextProperties) { + this.sensitiveContextProperties.clear(); + if (sensitiveContextProperties != null) { + this.sensitiveContextProperties.addAll(sensitiveContextProperties); + } + return this; + } + + public Builder networkConfig(NetworkConfig networkConfig) { + this.networkConfig = networkConfig; + return this; + } + + public Builder addListener(SupaClientListener listener) { + if (listener != null) { + this.listeners.add(listener); + } + return this; + } + + public SupaClientConfig build() { + if (sdkKey == null || sdkKey.isBlank()) { + throw new IllegalStateException("sdkKey is required"); + } + if (environment == null || environment.isBlank()) { + throw new IllegalStateException("environment is required"); + } + return new SupaClientConfig(this); + } + } +} diff --git a/src/main/java/com/supaship/SupaClientListener.java b/src/main/java/com/supaship/SupaClientListener.java new file mode 100644 index 0000000..85c2814 --- /dev/null +++ b/src/main/java/com/supaship/SupaClientListener.java @@ -0,0 +1,28 @@ +package com.supaship; + +import java.util.List; +import java.util.Map; + +/** + * Optional hooks mirroring the JavaScript SDK plugin extension points (subset). All methods have + * default no-op implementations. + */ +public interface SupaClientListener { + + default void beforeGetFeatures(List featureNames, Map context) {} + + default void afterGetFeatures(Map result, Map context) {} + + default void beforeRequest(String url, String body, Map headers) {} + + default void afterResponse(int statusCode, long durationMs) {} + + default void onRetryAttempt(int attempt, Throwable error, boolean willRetry) {} + + default void onError(Throwable error, Map context) {} + + default void onFallbackUsed(String featureName, Object fallbackValue, Throwable error) {} + + default void onContextUpdate( + Map previousContext, Map newContext, String reason) {} +} diff --git a/src/main/java/com/supaship/SupashipException.java b/src/main/java/com/supaship/SupashipException.java new file mode 100644 index 0000000..bd07b2d --- /dev/null +++ b/src/main/java/com/supaship/SupashipException.java @@ -0,0 +1,27 @@ +package com.supaship; + +/** Unchecked exception for HTTP or SDK failures that callers may inspect or wrap. */ +public final class SupashipException extends RuntimeException { + + private final Integer httpStatus; + + public SupashipException(String message) { + super(message); + this.httpStatus = null; + } + + public SupashipException(String message, Throwable cause) { + super(message, cause); + this.httpStatus = null; + } + + public SupashipException(int httpStatus, String message) { + super(message); + this.httpStatus = httpStatus; + } + + /** Present when the failure came from a non-success HTTP status. */ + public Integer httpStatus() { + return httpStatus; + } +} diff --git a/src/main/java/com/supaship/internal/AsyncRetry.java b/src/main/java/com/supaship/internal/AsyncRetry.java new file mode 100644 index 0000000..03dc510 --- /dev/null +++ b/src/main/java/com/supaship/internal/AsyncRetry.java @@ -0,0 +1,99 @@ +package com.supaship.internal; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.ForkJoinPool; +import java.util.function.Consumer; + +/** Exponential backoff retry for async operations (same shape as the JS SDK {@code retry} helper). */ +public final class AsyncRetry { + + private AsyncRetry() {} + + public static CompletableFuture runWithRetry( + RetryTask task, + int maxAttempts, + long baseBackoffMs, + boolean enabled, + Consumer onAttempt, + Executor executor) { + if (!enabled || maxAttempts < 1) { + return task.run(1).toCompletableFuture(); + } + Executor ex = executor != null ? executor : ForkJoinPool.commonPool(); + CompletableFuture result = new CompletableFuture<>(); + runAttempt(task, 1, maxAttempts, baseBackoffMs, onAttempt, ex, result); + return result; + } + + private static void runAttempt( + RetryTask task, + int attempt, + int maxAttempts, + long baseBackoffMs, + Consumer onAttempt, + Executor executor, + CompletableFuture result) { + task.run(attempt) + .whenComplete( + (value, error) -> { + if (error == null) { + result.complete(value); + return; + } + boolean willRetry = attempt < maxAttempts; + if (onAttempt != null) { + try { + onAttempt.accept(new RetryEvent(attempt, error, willRetry)); + } catch (Throwable ignored) { + // listener must not break retry + } + } + if (!willRetry) { + result.completeExceptionally(error); + return; + } + long delay = baseBackoffMs * (1L << (attempt - 1)); + CompletableFuture.delayedExecutor(delay, java.util.concurrent.TimeUnit.MILLISECONDS, executor) + .execute( + () -> + runAttempt( + task, + attempt + 1, + maxAttempts, + baseBackoffMs, + onAttempt, + executor, + result)); + }); + } + + @FunctionalInterface + public interface RetryTask { + CompletableFuture run(int attemptNumber); + } + + public static final class RetryEvent { + private final int attempt; + private final Throwable error; + private final boolean willRetry; + + public RetryEvent(int attempt, Throwable error, boolean willRetry) { + this.attempt = attempt; + this.error = error; + this.willRetry = willRetry; + } + + public int attempt() { + return attempt; + } + + public Throwable error() { + return error; + } + + public boolean willRetry() { + return willRetry; + } + } +} diff --git a/src/test/java/com/supaship/AsyncRetryTest.java b/src/test/java/com/supaship/AsyncRetryTest.java new file mode 100644 index 0000000..9703588 --- /dev/null +++ b/src/test/java/com/supaship/AsyncRetryTest.java @@ -0,0 +1,59 @@ +package com.supaship; + +import com.supaship.internal.AsyncRetry; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AsyncRetryTest { + + @Test + void retriesWithBackoff_untilSuccess() throws Exception { + AtomicInteger attempts = new AtomicInteger(); + CompletableFuture f = + AsyncRetry.runWithRetry( + n -> + CompletableFuture.supplyAsync( + () -> { + int a = attempts.incrementAndGet(); + if (a < 3) { + throw new RuntimeException("fail"); + } + return "ok"; + }), + 5, + 5L, + true, + null, + null); + assertEquals("ok", f.get()); + assertEquals(3, attempts.get()); + } + + @Test + void notifies_listener_on_retry() throws Exception { + AtomicInteger attempts = new AtomicInteger(); + AtomicInteger retryEvents = new AtomicInteger(); + CompletableFuture f = + AsyncRetry.runWithRetry( + n -> + CompletableFuture.supplyAsync( + () -> { + if (attempts.incrementAndGet() < 2) { + throw new RuntimeException("x"); + } + return "done"; + }), + 3, + 1L, + true, + ev -> retryEvents.incrementAndGet(), + null); + assertEquals("done", f.get()); + assertTrue(retryEvents.get() >= 1); + } +} diff --git a/src/test/java/com/supaship/FeatureEvaluateJsonTest.java b/src/test/java/com/supaship/FeatureEvaluateJsonTest.java new file mode 100644 index 0000000..0386bbe --- /dev/null +++ b/src/test/java/com/supaship/FeatureEvaluateJsonTest.java @@ -0,0 +1,65 @@ +package com.supaship; + +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FeatureEvaluateJsonTest { + + @Test + void buildEvaluateRequest_escapesKeysAndStrings() { + Map ctx = new LinkedHashMap<>(); + ctx.put("a\"b", "line\nbreak"); + ctx.put("n", 1L); + ctx.put("flag", true); + ctx.put("nil", null); + String json = + FeatureEvaluateJson.buildEvaluateRequest("prod", List.of("feat-1", "f\"x"), ctx); + assertTrue(json.contains("\"environment\":\"prod\"")); + assertTrue(json.contains("\"features\":[\"feat-1\",\"f\\\"x\"]")); + assertTrue(json.contains("\\n")); + } + + @Test + void parseEvaluateResponse_readsVariations() { + String json = + "{\"features\":{\"dark\":{\"variation\":true},\"legacy\":{\"variation\":null},\"obj\":{\"variation\":{\"x\":1}}}}"; + Map m = FeatureEvaluateJson.parseEvaluateResponse(json); + assertEquals(true, m.get("dark")); + assertNull(m.get("legacy")); + @SuppressWarnings("unchecked") + Map inner = (Map) m.get("obj"); + assertEquals(1L, inner.get("x")); + } + + @Test + void parseEvaluateResponse_nestedObjectNumber() { + String json = "{\"features\":{\"p\":{\"variation\":3.14}}}"; + assertEquals(3.14, (Double) FeatureEvaluateJson.parseEvaluateResponse(json).get("p")); + } + + @Test + void parseEvaluateResponse_arrayVariation() { + String json = "{\"features\":{\"arr\":{\"variation\":[1,2,3]}}}"; + @SuppressWarnings("unchecked") + List arr = + (List) FeatureEvaluateJson.parseEvaluateResponse(json).get("arr"); + assertEquals(List.of(1L, 2L, 3L), arr); + } + + @Test + void roundTripBoolean() { + assertFalse( + (Boolean) + FeatureEvaluateJson.parseEvaluateResponse( + "{\"features\":{\"f\":{\"variation\":false}}}") + .get("f")); + } +} diff --git a/src/test/java/com/supaship/RetryConfigTest.java b/src/test/java/com/supaship/RetryConfigTest.java new file mode 100644 index 0000000..48d9562 --- /dev/null +++ b/src/test/java/com/supaship/RetryConfigTest.java @@ -0,0 +1,23 @@ +package com.supaship; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RetryConfigTest { + + @Test + void defaults_match_javascript_sdk() { + RetryConfig r = RetryConfig.defaultRetry(); + assertTrue(r.enabled()); + assertEquals(3, r.maxAttempts()); + assertEquals(1000L, r.backoffMs()); + } + + @Test + void rejectsInvalidMaxAttempts() { + assertThrows(IllegalArgumentException.class, () -> new RetryConfig(true, 0, 100)); + } +} diff --git a/src/test/java/com/supaship/SupaClientConfigTest.java b/src/test/java/com/supaship/SupaClientConfigTest.java new file mode 100644 index 0000000..aa5da12 --- /dev/null +++ b/src/test/java/com/supaship/SupaClientConfigTest.java @@ -0,0 +1,28 @@ +package com.supaship; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +class SupaClientConfigTest { + + @Test + void builder_requires_sdkKey_and_environment() { + assertThrows( + IllegalStateException.class, + () -> + SupaClientConfig.builder() + .environment("prod") + .features(Map.of("a", true)) + .build()); + assertThrows( + IllegalStateException.class, + () -> + SupaClientConfig.builder() + .sdkKey("k") + .features(Map.of("a", true)) + .build()); + } +} diff --git a/src/test/java/com/supaship/SupaClientHttpTest.java b/src/test/java/com/supaship/SupaClientHttpTest.java new file mode 100644 index 0000000..fe0c894 --- /dev/null +++ b/src/test/java/com/supaship/SupaClientHttpTest.java @@ -0,0 +1,196 @@ +package com.supaship; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SuppressWarnings("resource") +class SupaClientHttpTest { + + private HttpServer server; + + @AfterEach + void stopServer() { + if (server != null) { + server.stop(0); + } + } + + private String startWithHandler(com.sun.net.httpserver.HttpHandler handler) throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/v1/features", handler); + server.start(); + int port = server.getAddress().getPort(); + return "http://127.0.0.1:" + port + "/v1/features"; + } + + @Test + void getFeatures_postsBearerTokenAndReturnsVariations() throws Exception { + AtomicReference requestBody = new AtomicReference<>(); + AtomicReference authorization = new AtomicReference<>(); + String baseUrl = + startWithHandler( + ex -> { + if (!"POST".equals(ex.getRequestMethod())) { + ex.sendResponseHeaders(405, -1); + ex.close(); + return; + } + authorization.set(ex.getRequestHeaders().getFirst("Authorization")); + requestBody.set( + new String( + ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + byte[] body = + "{\"features\":{\"dark-mode\":{\"variation\":true}}}" + .getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().add("Content-Type", "application/json"); + ex.sendResponseHeaders(200, body.length); + ex.getResponseBody().write(body); + ex.close(); + }); + + Map features = new HashMap<>(); + features.put("dark-mode", false); + SupaClientConfig cfg = + SupaClientConfig.builder() + .sdkKey("test-key") + .environment("staging") + .features(features) + .networkConfig( + NetworkConfig.builder() + .featuresApiUrl(baseUrl) + .retry(new RetryConfig(false, 1, 0)) + .build()) + .build(); + SupaClient client = new SupaClient(cfg); + Map out = client.getFeatures(List.of("dark-mode")).get(); + assertEquals(true, out.get("dark-mode")); + String req = requestBody.get(); + assertNotNull(req); + assertEquals( + "{\"environment\":\"staging\",\"features\":[\"dark-mode\"],\"context\":{}}", req); + assertNotNull(authorization.get()); + assertTrue( + authorization.get().contains("Bearer test-key"), + () -> "Authorization: " + authorization.get()); + } + + @Test + void fallsBackWhenHttpFails() throws Exception { + String baseUrl = + startWithHandler( + ex -> { + ex.sendResponseHeaders(503, -1); + ex.close(); + }); + + Map features = new HashMap<>(); + features.put("x", false); + SupaClientConfig cfg = + SupaClientConfig.builder() + .sdkKey("k") + .environment("e") + .features(features) + .networkConfig( + NetworkConfig.builder() + .featuresApiUrl(baseUrl) + .retry(new RetryConfig(false, 1, 0)) + .build()) + .build(); + SupaClient client = new SupaClient(cfg); + Map out = client.getFeatures(List.of("x")).get(); + assertEquals(false, out.get("x")); + } + + @Test + void retries_failed_requests() throws Exception { + AtomicInteger hits = new AtomicInteger(); + String baseUrl = + startWithHandler( + ex -> { + int h = hits.incrementAndGet(); + if (h < 2) { + ex.sendResponseHeaders(500, -1); + ex.close(); + return; + } + byte[] body = + "{\"features\":{\"f\":{\"variation\":true}}}" + .getBytes(StandardCharsets.UTF_8); + ex.sendResponseHeaders(200, body.length); + ex.getResponseBody().write(body); + ex.close(); + }); + + Map features = Map.of("f", false); + SupaClientConfig cfg = + SupaClientConfig.builder() + .sdkKey("k") + .environment("e") + .features(features) + .networkConfig( + NetworkConfig.builder() + .featuresApiUrl(baseUrl) + .retry(new RetryConfig(true, 3, 1L)) + .build()) + .build(); + SupaClient client = new SupaClient(cfg); + assertEquals(true, client.getFeature("f").get()); + assertEquals(2, hits.get()); + } + + @Test + void hashes_sensitive_context_fields() throws Exception { + AtomicReference requestBody = new AtomicReference<>(); + String baseUrl = + startWithHandler( + ex -> { + requestBody.set( + new String( + ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + byte[] body = + "{\"features\":{\"f\":{\"variation\":1}}}" + .getBytes(StandardCharsets.UTF_8); + ex.sendResponseHeaders(200, body.length); + ex.getResponseBody().write(body); + ex.close(); + }); + + Map ctx = new HashMap<>(); + ctx.put("email", "secret@test"); + Map features = Map.of("f", 0); + SupaClientConfig cfg = + SupaClientConfig.builder() + .sdkKey("k") + .environment("e") + .features(features) + .context(ctx) + .sensitiveContextProperties(java.util.Set.of("email")) + .networkConfig( + NetworkConfig.builder() + .featuresApiUrl(baseUrl) + .retry(new RetryConfig(false, 1, 0)) + .build()) + .build(); + SupaClient client = new SupaClient(cfg); + assertEquals(1L, client.getFeature("f").get()); + String req = requestBody.get(); + assertNotNull(req); + assertTrue( + req.contains("544f5a10f875f4db5c5faef39c35d9a5b51123eeb5019e8bf1c65cb0b3d01cd9"), + req); + } +} From ae18ee31e31bf983788c0034487168cafb77af76 Mon Sep 17 00:00:00 2001 From: Madhu Dollu Date: Thu, 2 Apr 2026 16:35:10 +0530 Subject: [PATCH 2/7] add kotlin support --- README.md | 34 ++++++++++++++++++- pom.xml | 8 ++++- src/main/java/com/supaship/SupaClient.java | 31 ++++++++++++----- .../java/com/supaship/SupaClientConfig.java | 33 ++++++++++++++---- 4 files changed, 89 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index e8303f4..287ca32 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ Small, production oriented client for [Supaship](https://supaship.com) feature flags. It mirrors the public behavior of [`@supashiphq/javascript-sdk`](https://www.npmjs.com/package/@supashiphq/javascript-sdk) (`SupaClient`): same default endpoints, request shape, retry policy, timeouts, sensitive-context hashing, and fallback rules when the API is unavailable. -**Runtime:** Java 11 or newer. **Dependencies:** [Gson](https://github.com/google/gson) only (JSON). HTTP uses `java.net.http`. +**Runtime:** Java 11 or newer (bytecode 11; use a Java 11+ target in Kotlin too). **Dependencies:** [Gson](https://github.com/google/gson) (JSON) and [JetBrains Annotations](https://github.com/JetBrains/java-annotations) (small JAR; helps Kotlin null-safety). HTTP uses `java.net.http`. + +**Kotlin:** You can depend on this artifact from any JVM Kotlin project—no Kotlin-specific module is required. Public API is annotated with `@NotNull` / `@Nullable` for Kotlin. See [Kotlin usage](#kotlin-usage). Browser only features from the JS SDK (for example the toolbar plugin) are not applicable here. @@ -28,6 +30,36 @@ dependencies { } ``` +Ensure the Kotlin/JVM target is **11 or newer** (for example in `kotlin { jvmToolchain(11) }` or matching `JavaPluginExtension`). + +### Kotlin usage + +Use the same types as in Java; `CompletableFuture` can be blocked with `.get()`, or adapted with coroutines: + +```kotlin +import com.supaship.SupaClient +import com.supaship.SupaClientConfig +import kotlinx.coroutines.future.await +import kotlinx.coroutines.runBlocking + +fun main() = runBlocking { + val client = SupaClient( + SupaClientConfig.builder() + .sdkKey(System.getenv("SUPASHIP_SDK_KEY")) + .environment("production") + .features(mapOf("dark-mode" to false, "max-items" to 10L)) + .context(mapOf("region" to "eu")) + .build() + ) + + val dark = client.getFeature("dark-mode").await() as Boolean + val batch = client.getFeatures(listOf("dark-mode", "max-items")).await() + client.updateContext(mapOf("plan" to "pro"), mergeWithExisting = true) +} +``` + +The `await()` example needs `kotlinx-coroutines-core` and `kotlinx-coroutines-jdk8` on your classpath (`implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.8.1")` or newer). Without coroutines, use `client.getFeature("dark-mode").get()` instead. + ## Quick start ```java diff --git a/pom.xml b/pom.xml index ac09118..567533f 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ jar Supaship SDK - Lightweight Java client for Supaship feature flags (Java 11+, Gson for JSON) + Lightweight JVM client for Supaship feature flags (Java 11+, Kotlin) https://github.com/SupashipHQ/java-sdk @@ -25,6 +25,7 @@ 11 5.10.2 2.13.2 + 26.0.2 @@ -63,6 +64,11 @@ gson ${gson.version} + + org.jetbrains + annotations + ${jetbrains-annotations.version} + org.junit.jupiter junit-jupiter diff --git a/src/main/java/com/supaship/SupaClient.java b/src/main/java/com/supaship/SupaClient.java index 65ac9f4..f78454b 100644 --- a/src/main/java/com/supaship/SupaClient.java +++ b/src/main/java/com/supaship/SupaClient.java @@ -1,6 +1,10 @@ package com.supaship; import com.supaship.internal.AsyncRetry; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + import java.net.URI; import java.net.http.HttpRequest; import java.net.http.HttpResponse; @@ -25,6 +29,8 @@ * when the network fails. * *

Requires Java 11+ ({@link java.net.http.HttpClient}) and Gson for JSON request/response bodies. + * + *

Safe to use from Kotlin; nullability is annotated for Kotlin interop. */ public final class SupaClient { @@ -38,7 +44,7 @@ public final class SupaClient { private final List listeners; private final String clientId; - public SupaClient(SupaClientConfig config) { + public SupaClient(@NotNull SupaClientConfig config) { Objects.requireNonNull(config, "config"); this.sdkKey = config.sdkKey(); this.environment = config.environment(); @@ -51,11 +57,12 @@ public SupaClient(SupaClientConfig config) { } /** Stable per-instance id (same idea as the JS client for listeners/telemetry). */ + @NotNull public String clientId() { return clientId; } - public void updateContext(Map context, boolean mergeWithExisting) { + public void updateContext(@Nullable Map context, boolean mergeWithExisting) { Map toApply = context == null ? Map.of() : context; Map oldSnapshot; Map newSnapshot; @@ -78,6 +85,7 @@ public void updateContext(Map context, boolean mergeWithExisting) { } } + @NotNull public Map getContext() { synchronized (contextLock) { return new HashMap<>(defaultContext); @@ -85,20 +93,26 @@ public Map getContext() { } /** Fallback value from the configuration map for this feature. */ - public Object getFeatureFallback(String featureName) { + @Nullable + public Object getFeatureFallback(@NotNull String featureName) { return featureDefinitions.get(featureName); } - public CompletableFuture getFeature(String featureName) { + @NotNull + public CompletableFuture<@Nullable Object> getFeature(@NotNull String featureName) { return getFeature(featureName, null); } - public CompletableFuture getFeature(String featureName, Map contextOverride) { + @NotNull + public CompletableFuture<@Nullable Object> getFeature( + @NotNull String featureName, @Nullable Map contextOverride) { List one = Collections.singletonList(featureName); return getFeatures(one, contextOverride).thenApply(m -> m.get(featureName)); } - public CompletableFuture> getFeatures(List featureNames) { + @NotNull + public CompletableFuture<@NotNull Map> getFeatures( + @NotNull List featureNames) { return getFeatures(featureNames, null); } @@ -107,8 +121,9 @@ public CompletableFuture> getFeatures(List featureNa * values from the configured feature map (same behavior as the JS SDK). If {@code featureNames} * is empty, completes immediately with an empty map (no HTTP call). */ - public CompletableFuture> getFeatures( - List featureNames, Map contextOverride) { + @NotNull + public CompletableFuture<@NotNull Map> getFeatures( + @NotNull List featureNames, @Nullable Map contextOverride) { List names = featureNames.stream().filter(Objects::nonNull).collect(Collectors.toList()); if (names.isEmpty()) { diff --git a/src/main/java/com/supaship/SupaClientConfig.java b/src/main/java/com/supaship/SupaClientConfig.java index 09479eb..f6829b2 100644 --- a/src/main/java/com/supaship/SupaClientConfig.java +++ b/src/main/java/com/supaship/SupaClientConfig.java @@ -1,5 +1,8 @@ package com.supaship; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -33,36 +36,44 @@ private SupaClientConfig(Builder b) { this.listeners = Collections.unmodifiableList(new ArrayList<>(b.listeners)); } + @NotNull public static Builder builder() { return new Builder(); } + @NotNull public String sdkKey() { return sdkKey; } + @NotNull public String environment() { return environment; } /** Fallback values keyed by feature name (same role as {@code features} in the JS SDK). */ + @NotNull public Map features() { return features; } /** Default evaluation context merged into each request unless overridden per call. */ + @NotNull public Map context() { return context; } + @NotNull public Set sensitiveContextProperties() { return sensitiveContextProperties; } + @NotNull public NetworkConfig networkConfig() { return networkConfig; } + @NotNull public List listeners() { return listeners; } @@ -77,17 +88,20 @@ public static final class Builder { private NetworkConfig networkConfig; private final List listeners = new ArrayList<>(); - public Builder sdkKey(String sdkKey) { + @NotNull + public Builder sdkKey(@Nullable String sdkKey) { this.sdkKey = sdkKey; return this; } - public Builder environment(String environment) { + @NotNull + public Builder environment(@Nullable String environment) { this.environment = environment; return this; } - public Builder features(Map features) { + @NotNull + public Builder features(@Nullable Map features) { this.features.clear(); if (features != null) { for (Map.Entry e : features.entrySet()) { @@ -97,7 +111,8 @@ public Builder features(Map features) { return this; } - public Builder context(Map context) { + @NotNull + public Builder context(@Nullable Map context) { if (context == null) { this.context = null; return this; @@ -109,7 +124,8 @@ public Builder context(Map context) { return this; } - public Builder sensitiveContextProperties(Set sensitiveContextProperties) { + @NotNull + public Builder sensitiveContextProperties(@Nullable Set sensitiveContextProperties) { this.sensitiveContextProperties.clear(); if (sensitiveContextProperties != null) { this.sensitiveContextProperties.addAll(sensitiveContextProperties); @@ -117,18 +133,21 @@ public Builder sensitiveContextProperties(Set sensitiveContextProperties return this; } - public Builder networkConfig(NetworkConfig networkConfig) { + @NotNull + public Builder networkConfig(@Nullable NetworkConfig networkConfig) { this.networkConfig = networkConfig; return this; } - public Builder addListener(SupaClientListener listener) { + @NotNull + public Builder addListener(@Nullable SupaClientListener listener) { if (listener != null) { this.listeners.add(listener); } return this; } + @NotNull public SupaClientConfig build() { if (sdkKey == null || sdkKey.isBlank()) { throw new IllegalStateException("sdkKey is required"); From 617c7c3c5dd662b812a68f5513c60c08d629e25e Mon Sep 17 00:00:00 2001 From: Madhu Dollu Date: Fri, 3 Apr 2026 16:42:40 +0530 Subject: [PATCH 3/7] make it a multi library structure --- README.md | 2 +- java-sdk/pom.xml | 80 +++++++++ .../main/java/com/supaship/Constants.java | 0 .../com/supaship/FeatureEvaluateJson.java | 0 .../main/java/com/supaship/NetworkConfig.java | 0 .../main/java/com/supaship/RetryConfig.java | 0 .../main/java/com/supaship/SupaClient.java | 0 .../java/com/supaship/SupaClientConfig.java | 0 .../java/com/supaship/SupaClientListener.java | 0 .../java/com/supaship/SupashipException.java | 0 .../com/supaship/internal/AsyncRetry.java | 0 .../java/com/supaship/AsyncRetryTest.java | 0 .../com/supaship/FeatureEvaluateJsonTest.java | 0 .../java/com/supaship/RetryConfigTest.java | 0 .../com/supaship/SupaClientConfigTest.java | 0 .../java/com/supaship/SupaClientHttpTest.java | 0 pom.xml | 163 +++++++++++++----- publish.md | 4 +- 18 files changed, 207 insertions(+), 42 deletions(-) create mode 100644 java-sdk/pom.xml rename {src => java-sdk/src}/main/java/com/supaship/Constants.java (100%) rename {src => java-sdk/src}/main/java/com/supaship/FeatureEvaluateJson.java (100%) rename {src => java-sdk/src}/main/java/com/supaship/NetworkConfig.java (100%) rename {src => java-sdk/src}/main/java/com/supaship/RetryConfig.java (100%) rename {src => java-sdk/src}/main/java/com/supaship/SupaClient.java (100%) rename {src => java-sdk/src}/main/java/com/supaship/SupaClientConfig.java (100%) rename {src => java-sdk/src}/main/java/com/supaship/SupaClientListener.java (100%) rename {src => java-sdk/src}/main/java/com/supaship/SupashipException.java (100%) rename {src => java-sdk/src}/main/java/com/supaship/internal/AsyncRetry.java (100%) rename {src => java-sdk/src}/test/java/com/supaship/AsyncRetryTest.java (100%) rename {src => java-sdk/src}/test/java/com/supaship/FeatureEvaluateJsonTest.java (100%) rename {src => java-sdk/src}/test/java/com/supaship/RetryConfigTest.java (100%) rename {src => java-sdk/src}/test/java/com/supaship/SupaClientConfigTest.java (100%) rename {src => java-sdk/src}/test/java/com/supaship/SupaClientHttpTest.java (100%) diff --git a/README.md b/README.md index 287ca32..2724f70 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,7 @@ Very rare failures (for example if SHA-256 is unavailable) complete the `Complet mvn test package ``` -The resulting JAR is `target/supaship-sdk-*.jar`. +The resulting JAR is `java-sdk/target/supaship-sdk-*.jar` (run Maven from the repository root). ## JavaScript parity (summary) diff --git a/java-sdk/pom.xml b/java-sdk/pom.xml new file mode 100644 index 0000000..3dd20a4 --- /dev/null +++ b/java-sdk/pom.xml @@ -0,0 +1,80 @@ + + + 4.0.0 + + + com.supaship + supaship-sdks + 1.0.0-SNAPSHOT + + + supaship-sdk + jar + + Supaship SDK + Lightweight JVM client for Supaship feature flags (Java 11+, Kotlin) + + + + com.google.code.gson + gson + + + org.jetbrains + annotations + + + org.junit.jupiter + junit-jupiter + test + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + true + + + + + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + package + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + none + true + + + + attach-javadocs + package + + jar + + + + + + + diff --git a/src/main/java/com/supaship/Constants.java b/java-sdk/src/main/java/com/supaship/Constants.java similarity index 100% rename from src/main/java/com/supaship/Constants.java rename to java-sdk/src/main/java/com/supaship/Constants.java diff --git a/src/main/java/com/supaship/FeatureEvaluateJson.java b/java-sdk/src/main/java/com/supaship/FeatureEvaluateJson.java similarity index 100% rename from src/main/java/com/supaship/FeatureEvaluateJson.java rename to java-sdk/src/main/java/com/supaship/FeatureEvaluateJson.java diff --git a/src/main/java/com/supaship/NetworkConfig.java b/java-sdk/src/main/java/com/supaship/NetworkConfig.java similarity index 100% rename from src/main/java/com/supaship/NetworkConfig.java rename to java-sdk/src/main/java/com/supaship/NetworkConfig.java diff --git a/src/main/java/com/supaship/RetryConfig.java b/java-sdk/src/main/java/com/supaship/RetryConfig.java similarity index 100% rename from src/main/java/com/supaship/RetryConfig.java rename to java-sdk/src/main/java/com/supaship/RetryConfig.java diff --git a/src/main/java/com/supaship/SupaClient.java b/java-sdk/src/main/java/com/supaship/SupaClient.java similarity index 100% rename from src/main/java/com/supaship/SupaClient.java rename to java-sdk/src/main/java/com/supaship/SupaClient.java diff --git a/src/main/java/com/supaship/SupaClientConfig.java b/java-sdk/src/main/java/com/supaship/SupaClientConfig.java similarity index 100% rename from src/main/java/com/supaship/SupaClientConfig.java rename to java-sdk/src/main/java/com/supaship/SupaClientConfig.java diff --git a/src/main/java/com/supaship/SupaClientListener.java b/java-sdk/src/main/java/com/supaship/SupaClientListener.java similarity index 100% rename from src/main/java/com/supaship/SupaClientListener.java rename to java-sdk/src/main/java/com/supaship/SupaClientListener.java diff --git a/src/main/java/com/supaship/SupashipException.java b/java-sdk/src/main/java/com/supaship/SupashipException.java similarity index 100% rename from src/main/java/com/supaship/SupashipException.java rename to java-sdk/src/main/java/com/supaship/SupashipException.java diff --git a/src/main/java/com/supaship/internal/AsyncRetry.java b/java-sdk/src/main/java/com/supaship/internal/AsyncRetry.java similarity index 100% rename from src/main/java/com/supaship/internal/AsyncRetry.java rename to java-sdk/src/main/java/com/supaship/internal/AsyncRetry.java diff --git a/src/test/java/com/supaship/AsyncRetryTest.java b/java-sdk/src/test/java/com/supaship/AsyncRetryTest.java similarity index 100% rename from src/test/java/com/supaship/AsyncRetryTest.java rename to java-sdk/src/test/java/com/supaship/AsyncRetryTest.java diff --git a/src/test/java/com/supaship/FeatureEvaluateJsonTest.java b/java-sdk/src/test/java/com/supaship/FeatureEvaluateJsonTest.java similarity index 100% rename from src/test/java/com/supaship/FeatureEvaluateJsonTest.java rename to java-sdk/src/test/java/com/supaship/FeatureEvaluateJsonTest.java diff --git a/src/test/java/com/supaship/RetryConfigTest.java b/java-sdk/src/test/java/com/supaship/RetryConfigTest.java similarity index 100% rename from src/test/java/com/supaship/RetryConfigTest.java rename to java-sdk/src/test/java/com/supaship/RetryConfigTest.java diff --git a/src/test/java/com/supaship/SupaClientConfigTest.java b/java-sdk/src/test/java/com/supaship/SupaClientConfigTest.java similarity index 100% rename from src/test/java/com/supaship/SupaClientConfigTest.java rename to java-sdk/src/test/java/com/supaship/SupaClientConfigTest.java diff --git a/src/test/java/com/supaship/SupaClientHttpTest.java b/java-sdk/src/test/java/com/supaship/SupaClientHttpTest.java similarity index 100% rename from src/test/java/com/supaship/SupaClientHttpTest.java rename to java-sdk/src/test/java/com/supaship/SupaClientHttpTest.java diff --git a/pom.xml b/pom.xml index 567533f..7b2a4f5 100644 --- a/pom.xml +++ b/pom.xml @@ -5,14 +5,18 @@ 4.0.0 com.supaship - supaship-sdk + supaship-sdks 1.0.0-SNAPSHOT - jar + pom - Supaship SDK - Lightweight JVM client for Supaship feature flags (Java 11+, Kotlin) + Supaship SDKs + Multi-module build for Supaship JVM libraries https://github.com/SupashipHQ/java-sdk + + java-sdk + + MIT License @@ -20,60 +24,141 @@ + + + Supaship + https://github.com/SupashipHQ + + + + + scm:git:https://github.com/SupashipHQ/java-sdk.git + scm:git:git@github.com:SupashipHQ/java-sdk.git + https://github.com/SupashipHQ/java-sdk + + UTF-8 11 5.10.2 2.13.2 26.0.2 + + 3.14.0 + 3.5.2 + 3.4.2 + 3.3.1 + 3.11.2 + 3.2.7 + 0.10.0 + + + + com.google.code.gson + gson + ${gson.version} + + + org.jetbrains + annotations + ${jetbrains-annotations.version} + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + ${maven.compiler.release} + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + + + org.apache.maven.plugins + maven-jar-plugin + ${maven-jar-plugin.version} + + + org.apache.maven.plugins + maven-source-plugin + ${maven-source-plugin.version} + + + org.apache.maven.plugins + maven-javadoc-plugin + ${maven-javadoc-plugin.version} + + + org.apache.maven.plugins + maven-gpg-plugin + ${maven-gpg-plugin.version} + + + org.sonatype.central + central-publishing-maven-plugin + ${central-publishing-maven-plugin.version} + + + + org.apache.maven.plugins maven-compiler-plugin - 3.12.1 - - ${maven.compiler.release} - org.apache.maven.plugins maven-surefire-plugin - 3.2.5 - - - org.apache.maven.plugins - maven-jar-plugin - 3.3.0 - - - - true - - - - - - com.google.code.gson - gson - ${gson.version} - - - org.jetbrains - annotations - ${jetbrains-annotations.version} - - - org.junit.jupiter - junit-jupiter - ${junit.version} - test - - + + + + release + + + + org.apache.maven.plugins + maven-gpg-plugin + + + sign-artifacts + verify + + sign + + + + + + org.sonatype.central + central-publishing-maven-plugin + true + + central + + + + + + diff --git a/publish.md b/publish.md index 4cd6af9..9894e5d 100644 --- a/publish.md +++ b/publish.md @@ -110,7 +110,7 @@ Add a **profile** `-P release` so day to day `mvn test` does not require GPG. mvn clean verify -P release ``` -4. **Deploy**: +4. **Deploy** (from the repository root; the reactor publishes the parent POM `com.supaship:supaship-sdks` and the library `com.supaship:supaship-sdk`): ```bash mvn clean deploy -P release @@ -118,7 +118,7 @@ Add a **profile** `-P release` so day to day `mvn test` does not require GPG. 5. In the **Sonatype portal** (or Nexus UI if legacy): **close** the staging repository, **release** it, wait until artifacts propagate to **Maven Central** (often tens of minutes the first time). -6. Verify in a browser: `https://repo1.maven.org/maven2/com/supaship/supaship-sdk/1.0.0/` (adjust `groupId` path: `com.supaship` → `com/supaship`). +6. Verify in a browser: `https://repo1.maven.org/maven2/com/supaship/supaship-sdk/1.0.0/` (adjust `groupId` path: `com.supaship` → `com/supaship`). The parent POM lives under `https://repo1.maven.org/maven2/com/supaship/supaship-sdks//`. ## 7. Later releases (updates) From e497b39d9f9a66c7a1e718b22d0f5f6512aad7e8 Mon Sep 17 00:00:00 2001 From: Madhu Dollu Date: Fri, 3 Apr 2026 17:10:39 +0530 Subject: [PATCH 4/7] add docs --- .gitignore | 2 + java-sdk/pom.xml | 74 ++++++++++++++- .../src/main/java/com/supaship/Constants.java | 3 + .../com/supaship/FeatureEvaluateJson.java | 22 ++++- .../main/java/com/supaship/NetworkConfig.java | 68 +++++++++++++- .../main/java/com/supaship/RetryConfig.java | 29 +++++- .../main/java/com/supaship/SupaClient.java | 57 ++++++++++- .../java/com/supaship/SupaClientConfig.java | 94 ++++++++++++++++++- .../java/com/supaship/SupaClientListener.java | 54 ++++++++++- .../java/com/supaship/SupashipException.java | 24 ++++- .../com/supaship/internal/AsyncRetry.java | 53 ++++++++++- pom.xml | 71 +++++++------- publish.md | 12 +-- 13 files changed, 509 insertions(+), 54 deletions(-) diff --git a/.gitignore b/.gitignore index 84e4505..52d73b5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ target/ .classpath .settings/ .DS_Store +.flattened-pom.xml +*.jar diff --git a/java-sdk/pom.xml b/java-sdk/pom.xml index 3dd20a4..caf5126 100644 --- a/java-sdk/pom.xml +++ b/java-sdk/pom.xml @@ -7,14 +7,26 @@ com.supaship supaship-sdks - 1.0.0-SNAPSHOT + ${revision} + ../pom.xml supaship-sdk jar - Supaship SDK + Supaship SDK for Java Lightweight JVM client for Supaship feature flags (Java 11+, Kotlin) + https://github.com/SupashipHQ/java-sdk + + + scm:git:https://github.com/SupashipHQ/java-sdk.git + scm:git:git@github.com:SupashipHQ/java-sdk.git + https://github.com/SupashipHQ/java-sdk + + + + false + @@ -34,6 +46,31 @@ + + + org.codehaus.mojo + flatten-maven-plugin + + true + oss + + + + flatten + process-resources + + flatten + + + + flatten.clean + clean + + clean + + + + org.apache.maven.plugins maven-jar-plugin @@ -62,7 +99,6 @@ org.apache.maven.plugins maven-javadoc-plugin - none true @@ -77,4 +113,36 @@ + + + + release + + + + org.apache.maven.plugins + maven-gpg-plugin + + + sign-artifacts + verify + + sign + + + + + + org.sonatype.central + central-publishing-maven-plugin + true + + central + true + + + + + + diff --git a/java-sdk/src/main/java/com/supaship/Constants.java b/java-sdk/src/main/java/com/supaship/Constants.java index ba8ad4f..cd2a60b 100644 --- a/java-sdk/src/main/java/com/supaship/Constants.java +++ b/java-sdk/src/main/java/com/supaship/Constants.java @@ -3,7 +3,10 @@ /** Default Supaship API endpoints (same as the JavaScript SDK). */ public final class Constants { + /** Default HTTPS URL for feature evaluation requests. */ public static final String DEFAULT_FEATURES_URL = "https://edge.supaship.com/v1/features"; + + /** Default HTTPS URL for analytics/events endpoints. */ public static final String DEFAULT_EVENTS_URL = "https://edge.supaship.com/v1/events"; private Constants() {} diff --git a/java-sdk/src/main/java/com/supaship/FeatureEvaluateJson.java b/java-sdk/src/main/java/com/supaship/FeatureEvaluateJson.java index f0caec9..4b0ebe8 100644 --- a/java-sdk/src/main/java/com/supaship/FeatureEvaluateJson.java +++ b/java-sdk/src/main/java/com/supaship/FeatureEvaluateJson.java @@ -14,7 +14,12 @@ import java.util.List; import java.util.Map; -/** Serializes/parses the Supaship features evaluate request and response using Gson. */ +/** + * Gson helpers for the Supaship features evaluate JSON request body and response envelope. + * + *

Request shape: {@code { "environment", "features": [...], "context": { ... } }}. + * Response shape: {@code { "features": { "name": { "variation": ... } } }}. + */ final class FeatureEvaluateJson { private static final Gson GSON = @@ -22,6 +27,12 @@ final class FeatureEvaluateJson { private FeatureEvaluateJson() {} + /** + * @param environment environment name + * @param featureNames list of feature keys to evaluate + * @param context context object (may contain hashed sensitive fields) + * @return JSON POST body for the evaluate endpoint + */ static String buildEvaluateRequest( String environment, List featureNames, Map context) { Map body = new LinkedHashMap<>(); @@ -32,8 +43,13 @@ static String buildEvaluateRequest( } /** - * Parses {@code {"features":{"f":{"variation":...}}}} and returns map of feature name → - * variation value (Java representation). + * Parses {@code {"features":{"f":{"variation":...}}}} and returns a map of feature name to variation value + * (booleans, strings, numbers, lists, or nested maps as plain Java objects). + * + * @param json raw response body from a successful evaluate call + * @return ordered map of feature name to variation or {@code null} entries where the payload omits {@code variation} + * @throws IllegalArgumentException if the JSON root or {@code features} object is missing or malformed + * @throws JsonParseException if the string is not valid JSON (propagated from Gson) */ static Map parseEvaluateResponse(String json) { JsonElement rootEl; diff --git a/java-sdk/src/main/java/com/supaship/NetworkConfig.java b/java-sdk/src/main/java/com/supaship/NetworkConfig.java index 25aeeb3..3619c4b 100644 --- a/java-sdk/src/main/java/com/supaship/NetworkConfig.java +++ b/java-sdk/src/main/java/com/supaship/NetworkConfig.java @@ -23,66 +23,132 @@ private NetworkConfig(Builder b) { this.httpClient = b.httpClient != null ? b.httpClient : HttpClient.newBuilder().build(); } + /** + * Starts a builder with Supaship production URLs and SDK defaults. + * + * @return builder with Supaship edge URLs, default retry policy, 10s per-request timeout, and a default {@link HttpClient} + */ public static Builder builder() { return new Builder(); } + /** + * URL used for batched feature evaluation HTTP POSTs. + * + * @return base URL for the features evaluate API + */ public String featuresApiUrl() { return featuresApiUrl; } + /** + * URL reserved for analytics or events traffic (not used by core flag evaluation today). + * + * @return base URL for the events API (reserved for future client features) + */ public String eventsApiUrl() { return eventsApiUrl; } + /** + * Policy governing retries when the evaluate request fails transiently. + * + * @return retry policy applied to feature evaluation HTTP calls + */ public RetryConfig retry() { return retry; } + /** + * Upper bound for blocking on request/response I/O for a single attempt. + * + * @return timeout applied to each HTTP request body send and response wait + */ public Duration requestTimeout() { return requestTimeout; } + /** + * Shared client for all outbound calls from {@link SupaClient}. + * + * @return client used for async requests; never null + */ public HttpClient httpClient() { return httpClient; } + /** Fluent builder; defaults match the JavaScript SDK ({@link Constants}). */ public static final class Builder { + /** Initializes URLs, retry, and timeout to Supaship defaults. */ + public Builder() {} + private String featuresApiUrl = Constants.DEFAULT_FEATURES_URL; private String eventsApiUrl = Constants.DEFAULT_EVENTS_URL; private RetryConfig retry = RetryConfig.defaultRetry(); private Duration requestTimeout = Duration.ofMillis(10_000); private HttpClient httpClient; + /** + * Overrides the default {@link Constants#DEFAULT_FEATURES_URL}. + * + * @param featuresApiUrl non-null evaluate API base URL + * @return this builder + */ public Builder featuresApiUrl(String featuresApiUrl) { this.featuresApiUrl = Objects.requireNonNull(featuresApiUrl, "featuresApiUrl"); return this; } + /** + * Overrides the default {@link Constants#DEFAULT_EVENTS_URL}. + * + * @param eventsApiUrl non-null events API base URL + * @return this builder + */ public Builder eventsApiUrl(String eventsApiUrl) { this.eventsApiUrl = Objects.requireNonNull(eventsApiUrl, "eventsApiUrl"); return this; } + /** + * Sets how failed evaluate requests are retried. + * + * @param retry non-null retry policy for failed feature requests + * @return this builder + */ public Builder retry(RetryConfig retry) { this.retry = Objects.requireNonNull(retry, "retry"); return this; } + /** + * Sets the per-request timeout passed to {@link java.net.http.HttpRequest.Builder#timeout(Duration)}. + * + * @param requestTimeout non-null timeout per HTTP request + * @return this builder + */ public Builder requestTimeout(Duration requestTimeout) { this.requestTimeout = Objects.requireNonNull(requestTimeout, "requestTimeout"); return this; } /** - * Optional custom {@link HttpClient} (SSL, proxy, version). When omitted, a default client is created. + * Optional custom {@link HttpClient} (SSL, proxy, HTTP version). When omitted, a default client is created. + * + * @param httpClient client instance, or {@code null} to use the default + * @return this builder */ public Builder httpClient(HttpClient httpClient) { this.httpClient = httpClient; return this; } + /** + * Builds an immutable snapshot of network-related settings. + * + * @return immutable network configuration + */ public NetworkConfig build() { return new NetworkConfig(this); } diff --git a/java-sdk/src/main/java/com/supaship/RetryConfig.java b/java-sdk/src/main/java/com/supaship/RetryConfig.java index 0541f5b..885fc6a 100644 --- a/java-sdk/src/main/java/com/supaship/RetryConfig.java +++ b/java-sdk/src/main/java/com/supaship/RetryConfig.java @@ -9,6 +9,14 @@ public final class RetryConfig { private final int maxAttempts; private final long backoffMs; + /** + * Defines how many times {@link SupaClient} may repeat a failed evaluate call and how long to wait between tries. + * + * @param enabled whether retries run after failures + * @param maxAttempts total attempts including the first try; must be ≥ 1 + * @param backoffMs base delay in milliseconds for exponential backoff (doubling each retry); must be ≥ 0 + * @throws IllegalArgumentException if {@code maxAttempts} or {@code backoffMs} is out of range + */ public RetryConfig(boolean enabled, int maxAttempts, long backoffMs) { if (maxAttempts < 1) { throw new IllegalArgumentException("maxAttempts must be at least 1"); @@ -21,19 +29,38 @@ public RetryConfig(boolean enabled, int maxAttempts, long backoffMs) { this.backoffMs = backoffMs; } - /** JS defaults: enabled true, 3 attempts, 1000 ms base backoff. */ + /** + * JavaScript SDK defaults: enabled, 3 attempts, 1000 ms base backoff. + * + * @return shared-equivalent retry configuration + */ public static RetryConfig defaultRetry() { return new RetryConfig(true, 3, 1000L); } + /** + * Whether failed feature requests are retried according to {@link #maxAttempts()} and {@link #backoffMs()}. + * + * @return {@code true} if retries are enabled + */ public boolean enabled() { return enabled; } + /** + * Total tries for one logical evaluation request, including the first call. + * + * @return maximum attempts (at least 1) + */ public int maxAttempts() { return maxAttempts; } + /** + * Base delay before the first retry; later waits multiply this value by powers of two. + * + * @return base backoff in milliseconds (non-negative) + */ public long backoffMs() { return backoffMs; } diff --git a/java-sdk/src/main/java/com/supaship/SupaClient.java b/java-sdk/src/main/java/com/supaship/SupaClient.java index f78454b..458f266 100644 --- a/java-sdk/src/main/java/com/supaship/SupaClient.java +++ b/java-sdk/src/main/java/com/supaship/SupaClient.java @@ -44,6 +44,11 @@ public final class SupaClient { private final List listeners; private final String clientId; + /** + * Creates a client from an immutable configuration (SDK key, environment, fallbacks, network, listeners). + * + * @param config non-null client configuration from {@link SupaClientConfig.Builder#build()} + */ public SupaClient(@NotNull SupaClientConfig config) { Objects.requireNonNull(config, "config"); this.sdkKey = config.sdkKey(); @@ -56,12 +61,25 @@ public SupaClient(@NotNull SupaClientConfig config) { this.clientId = generateClientId(); } - /** Stable per-instance id (same idea as the JS client for listeners/telemetry). */ + /** + * Stable per-instance id (same idea as the JS client for listeners/telemetry). + * + * @return non-null identifier unique to this client instance + */ @NotNull public String clientId() { return clientId; } + /** + * Updates the default evaluation context used for subsequent {@link #getFeature} / {@link #getFeatures} calls. + * + *

Notifies {@link SupaClientListener#onContextUpdate(Map, Map, String)} with reason {@code "updateContext"}. + * + * @param context map to apply; {@code null} is treated as an empty map + * @param mergeWithExisting when {@code true}, keys from {@code context} overwrite or add to the existing context; + * when {@code false}, the previous context is cleared first + */ public void updateContext(@Nullable Map context, boolean mergeWithExisting) { Map toApply = context == null ? Map.of() : context; Map oldSnapshot; @@ -85,6 +103,11 @@ public void updateContext(@Nullable Map context, boolean mergeWithExi } } + /** + * Returns a snapshot copy of the default evaluation context (thread-safe). + * + * @return mutable copy of the current default context; not the live backing map + */ @NotNull public Map getContext() { synchronized (contextLock) { @@ -92,17 +115,36 @@ public Map getContext() { } } - /** Fallback value from the configuration map for this feature. */ + /** + * Fallback value from the configuration map for this feature (no network call). + * + * @param featureName non-null feature key as configured in {@link SupaClientConfig.Builder#features(Map)} + * @return configured fallback, or {@code null} if none was defined + */ @Nullable public Object getFeatureFallback(@NotNull String featureName) { return featureDefinitions.get(featureName); } + /** + * Evaluates a single feature using the default context merged with configured fallbacks on failure. + * + * @param featureName non-null feature name + * @return future completed with the variation, fallback, or {@code null} depending on API and config + * @see #getFeature(String, Map) + */ @NotNull public CompletableFuture<@Nullable Object> getFeature(@NotNull String featureName) { return getFeature(featureName, null); } + /** + * Evaluates a single feature after merging {@code contextOverride} into the default context for this request. + * + * @param featureName non-null feature name + * @param contextOverride optional per-request context entries (merged for this call only) + * @return future completed with the variation, fallback, or {@code null} depending on API and config + */ @NotNull public CompletableFuture<@Nullable Object> getFeature( @NotNull String featureName, @Nullable Map contextOverride) { @@ -110,6 +152,13 @@ public Object getFeatureFallback(@NotNull String featureName) { return getFeatures(one, contextOverride).thenApply(m -> m.get(featureName)); } + /** + * Evaluates several features using the default context. + * + * @param featureNames non-null list of feature names (null entries are ignored) + * @return future map of feature name to variation or fallback; empty list yields an empty map without HTTP + * @see #getFeatures(List, Map) + */ @NotNull public CompletableFuture<@NotNull Map> getFeatures( @NotNull List featureNames) { @@ -120,6 +169,10 @@ public Object getFeatureFallback(@NotNull String featureName) { * Fetches evaluations for the given flags. On transport/HTTP/parse failure, returns fallback * values from the configured feature map (same behavior as the JS SDK). If {@code featureNames} * is empty, completes immediately with an empty map (no HTTP call). + * + * @param featureNames non-null list of feature names (null entries are ignored) + * @param contextOverride optional per-request context (merged into default context for this call) + * @return future map of feature name to evaluated value or configured fallback */ @NotNull public CompletableFuture<@NotNull Map> getFeatures( diff --git a/java-sdk/src/main/java/com/supaship/SupaClientConfig.java b/java-sdk/src/main/java/com/supaship/SupaClientConfig.java index f6829b2..8ad4c0a 100644 --- a/java-sdk/src/main/java/com/supaship/SupaClientConfig.java +++ b/java-sdk/src/main/java/com/supaship/SupaClientConfig.java @@ -36,50 +36,92 @@ private SupaClientConfig(Builder b) { this.listeners = Collections.unmodifiableList(new ArrayList<>(b.listeners)); } + /** + * Begins a new configuration builder. + * + * @return new mutable builder; call {@link Builder#build()} to obtain an immutable config + */ @NotNull public static Builder builder() { return new Builder(); } + /** + * Credential passed as {@code Authorization: Bearer} to Supaship APIs. + * + * @return Supaship SDK key (Bearer token for the API) + */ @NotNull public String sdkKey() { return sdkKey; } + /** + * Logical target environment for flag evaluation. + * + * @return environment name sent to the evaluate API (e.g. production, staging) + */ @NotNull public String environment() { return environment; } - /** Fallback values keyed by feature name (same role as {@code features} in the JS SDK). */ + /** + * Fallback values keyed by feature name (same role as {@code features} in the JS SDK). + * + * @return unmodifiable map of feature name to local default when the network or API cannot be used + */ @NotNull public Map features() { return features; } - /** Default evaluation context merged into each request unless overridden per call. */ + /** + * Default evaluation context merged into each request unless overridden per call. + * + * @return unmodifiable map; may be empty + */ @NotNull public Map context() { return context; } + /** + * Property names whose values are replaced with a SHA-256 hex digest before sending to the API. + * + * @return unmodifiable set of context keys to hash + */ @NotNull public Set sensitiveContextProperties() { return sensitiveContextProperties; } + /** + * Transport layer settings for feature evaluation calls. + * + * @return endpoints, timeouts, retry policy, and HTTP client used by {@link SupaClient} + */ @NotNull public NetworkConfig networkConfig() { return networkConfig; } + /** + * Extension hooks registered for this client. + * + * @return unmodifiable list of hooks invoked around requests and fallbacks + */ @NotNull public List listeners() { return listeners; } + /** Fluent builder for {@link SupaClientConfig}; {@link #build()} validates required fields. */ public static final class Builder { + /** Starts with empty features, listeners, and no default context until set. */ + public Builder() {} + private String sdkKey; private String environment; private Map features = new HashMap<>(); @@ -88,18 +130,36 @@ public static final class Builder { private NetworkConfig networkConfig; private final List listeners = new ArrayList<>(); + /** + * Sets the Supaship SDK key. + * + * @param sdkKey Supaship SDK key; required and must not be blank at {@link #build()} + * @return this builder + */ @NotNull public Builder sdkKey(@Nullable String sdkKey) { this.sdkKey = sdkKey; return this; } + /** + * Sets the environment name included in evaluate requests. + * + * @param environment environment name sent on evaluate requests; required at {@link #build()} + * @return this builder + */ @NotNull public Builder environment(@Nullable String environment) { this.environment = environment; return this; } + /** + * Replaces feature fallbacks with the given map (null clears to empty). + * + * @param features map of feature name to local default value + * @return this builder + */ @NotNull public Builder features(@Nullable Map features) { this.features.clear(); @@ -111,6 +171,12 @@ public Builder features(@Nullable Map features) { return this; } + /** + * Sets the default evaluation context ({@code null} means no default context). + * + * @param context default context entries merged into each evaluation unless overridden + * @return this builder + */ @NotNull public Builder context(@Nullable Map context) { if (context == null) { @@ -124,6 +190,12 @@ public Builder context(@Nullable Map context) { return this; } + /** + * Keys in the evaluation context whose raw values must not be sent over the wire (raw values are hashed). + * + * @param sensitiveContextProperties set of property names; null clears the set + * @return this builder + */ @NotNull public Builder sensitiveContextProperties(@Nullable Set sensitiveContextProperties) { this.sensitiveContextProperties.clear(); @@ -133,12 +205,24 @@ public Builder sensitiveContextProperties(@Nullable Set sensitiveContext return this; } + /** + * Overrides HTTP endpoints, timeouts, retry behavior, and the {@link java.net.http.HttpClient} instance. + * + * @param networkConfig optional; if null, {@link NetworkConfig#builder()}{@code .build()} defaults are used + * @return this builder + */ @NotNull public Builder networkConfig(@Nullable NetworkConfig networkConfig) { this.networkConfig = networkConfig; return this; } + /** + * Registers a listener (ignored if null). Order is preserved for notification callbacks. + * + * @param listener hook implementation; may be null (no-op) + * @return this builder + */ @NotNull public Builder addListener(@Nullable SupaClientListener listener) { if (listener != null) { @@ -147,6 +231,12 @@ public Builder addListener(@Nullable SupaClientListener listener) { return this; } + /** + * Validates required fields and returns an immutable configuration. + * + * @return immutable configuration + * @throws IllegalStateException if {@code sdkKey} or {@code environment} is null or blank + */ @NotNull public SupaClientConfig build() { if (sdkKey == null || sdkKey.isBlank()) { diff --git a/java-sdk/src/main/java/com/supaship/SupaClientListener.java b/java-sdk/src/main/java/com/supaship/SupaClientListener.java index 85c2814..402d54f 100644 --- a/java-sdk/src/main/java/com/supaship/SupaClientListener.java +++ b/java-sdk/src/main/java/com/supaship/SupaClientListener.java @@ -5,24 +5,76 @@ /** * Optional hooks mirroring the JavaScript SDK plugin extension points (subset). All methods have - * default no-op implementations. + * default no-op implementations; exceptions thrown from a listener are ignored so evaluation always proceeds. */ public interface SupaClientListener { + /** + * Called before a batch evaluation request is sent (after context merge). + * + * @param featureNames non-null list of features being requested + * @param context evaluation context that will be sent (sensitive values may already be hashed) + */ default void beforeGetFeatures(List featureNames, Map context) {} + /** + * Called after a successful evaluation (HTTP 2xx and parsed body), before the future completes normally. + * + * @param result map of feature name to variation (or fallback) as returned to the caller + * @param context context used for this request after merge + */ default void afterGetFeatures(Map result, Map context) {} + /** + * Called immediately before the HTTP request is issued. + * + * @param url request URL + * @param body JSON request body + * @param headers header map (e.g. Content-Type, Authorization); mutable only if the implementation copies it + */ default void beforeRequest(String url, String body, Map headers) {} + /** + * Called after the HTTP response headers and status are available. + * + * @param statusCode HTTP status of the response + * @param durationMs elapsed time for the round trip in milliseconds + */ default void afterResponse(int statusCode, long durationMs) {} + /** + * Called after a failed attempt when retries may continue. + * + * @param attempt 1-based attempt number for this logical request + * @param error failure from the attempt + * @param willRetry {@code true} if another attempt will follow + */ default void onRetryAttempt(int attempt, Throwable error, boolean willRetry) {} + /** + * Called when evaluation failed and fallbacks or terminal failure handling runs. + * + * @param error failure that caused fallbacks to be used (or terminal failure if not retrying) + * @param context evaluation context used for the failed attempt + */ default void onError(Throwable error, Map context) {} + /** + * Called once per feature when a configured local fallback is returned instead of a remote value. + * + * @param featureName feature for which a configured fallback was returned + * @param fallbackValue value from {@link SupaClientConfig#features()} + * @param error underlying error from the network or API + */ default void onFallbackUsed(String featureName, Object fallbackValue, Throwable error) {} + /** + * Called when the client default context changes or a per-request overlay is applied. + * + * @param previousContext snapshot before the update + * @param newContext snapshot after the update + * @param reason e.g. {@code "updateContext"} or {@code "request"} for per-request overlays + */ default void onContextUpdate( Map previousContext, Map newContext, String reason) {} } diff --git a/java-sdk/src/main/java/com/supaship/SupashipException.java b/java-sdk/src/main/java/com/supaship/SupashipException.java index bd07b2d..d60c74e 100644 --- a/java-sdk/src/main/java/com/supaship/SupashipException.java +++ b/java-sdk/src/main/java/com/supaship/SupashipException.java @@ -3,24 +3,46 @@ /** Unchecked exception for HTTP or SDK failures that callers may inspect or wrap. */ public final class SupashipException extends RuntimeException { + /** HTTP status when the failure was due to a non-2xx response; otherwise {@code null}. */ private final Integer httpStatus; + /** + * Failure without an associated HTTP status (for example parsing or internal SDK errors). + * + * @param message error description + */ public SupashipException(String message) { super(message); this.httpStatus = null; } + /** + * Failure with a root cause and no HTTP status. + * + * @param message error description + * @param cause underlying cause + */ public SupashipException(String message, Throwable cause) { super(message, cause); this.httpStatus = null; } + /** + * Failure caused by a non-success HTTP response from the Supaship API. + * + * @param httpStatus HTTP status code returned by the API + * @param message error description + */ public SupashipException(int httpStatus, String message) { super(message); this.httpStatus = httpStatus; } - /** Present when the failure came from a non-success HTTP status. */ + /** + * Optional HTTP status attached by {@link #SupashipException(int, String)}. + * + * @return HTTP status when constructed with {@link #SupashipException(int, String)}; otherwise {@code null} + */ public Integer httpStatus() { return httpStatus; } diff --git a/java-sdk/src/main/java/com/supaship/internal/AsyncRetry.java b/java-sdk/src/main/java/com/supaship/internal/AsyncRetry.java index 03dc510..bac089a 100644 --- a/java-sdk/src/main/java/com/supaship/internal/AsyncRetry.java +++ b/java-sdk/src/main/java/com/supaship/internal/AsyncRetry.java @@ -5,11 +5,28 @@ import java.util.concurrent.ForkJoinPool; import java.util.function.Consumer; -/** Exponential backoff retry for async operations (same shape as the JS SDK {@code retry} helper). */ +/** + * Exponential backoff retry for asynchronous work (aligned with the JavaScript SDK retry helper). + * + *

When retries are disabled or {@code maxAttempts < 1}, the first attempt is run once with no delay. + */ public final class AsyncRetry { private AsyncRetry() {} + /** + * Runs {@code task} at least once; on failure, waits {@code baseBackoffMs * 2^(attempt-1)} before the next try + * until {@code maxAttempts} is reached. + * + * @param result type + * @param task supplier of a future for each attempt; receives 1-based attempt number + * @param maxAttempts maximum number of tries when {@code enabled} ({@code >= 1}) + * @param baseBackoffMs base delay in milliseconds (doubled after each failure) + * @param enabled when {@code false}, only the first attempt runs + * @param onAttempt optional listener invoked after each failure with retry metadata; may be {@code null} + * @param executor executor used for scheduling delayed retries; {@code null} uses {@link ForkJoinPool#commonPool()} + * @return future that completes with the task result or exceptionally with the last error + */ public static CompletableFuture runWithRetry( RetryTask task, int maxAttempts, @@ -68,30 +85,64 @@ private static void runAttempt( }); } + /** + * Produces the async work for a single attempt. + * + * @param result type of the {@link CompletableFuture} + */ @FunctionalInterface public interface RetryTask { + /** + * Runs one logical try (for example one HTTP call). + * + * @param attemptNumber 1-based index (first try is {@code 1}) + * @return future for this attempt + */ CompletableFuture run(int attemptNumber); } + /** Details supplied to {@code onAttempt} after a failed try. */ public static final class RetryEvent { private final int attempt; private final Throwable error; private final boolean willRetry; + /** + * Immutable snapshot passed to retry listeners after a failed try. + * + * @param attempt 1-based attempt that failed + * @param error failure from the attempt + * @param willRetry {@code true} if another attempt is scheduled + */ public RetryEvent(int attempt, Throwable error, boolean willRetry) { this.attempt = attempt; this.error = error; this.willRetry = willRetry; } + /** + * Which try in the sequence failed. + * + * @return 1-based attempt number that produced {@link #error()} + */ public int attempt() { return attempt; } + /** + * Failure that triggered this notification. + * + * @return the throwable from the failed attempt + */ public Throwable error() { return error; } + /** + * Indicates whether {@link AsyncRetry#runWithRetry} will schedule another attempt. + * + * @return whether a follow-up attempt will run + */ public boolean willRetry() { return willRetry; } diff --git a/pom.xml b/pom.xml index 7b2a4f5..70e9453 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.supaship supaship-sdks - 1.0.0-SNAPSHOT + ${revision} pom Supaship SDKs @@ -38,12 +38,18 @@ + + 1.0.0-SNAPSHOT + UTF-8 11 5.10.2 2.13.2 26.0.2 + + true + 3.14.0 3.5.2 3.4.2 @@ -51,6 +57,7 @@ 3.11.2 3.2.7 0.10.0 + 1.6.0 @@ -115,10 +122,40 @@ central-publishing-maven-plugin ${central-publishing-maven-plugin.version} + + org.codehaus.mojo + flatten-maven-plugin + ${flatten-maven-plugin.version} + + + + org.codehaus.mojo + flatten-maven-plugin + + true + resolveCiFriendliesOnly + + + + flatten + validate + + flatten + + + + flatten.clean + clean + + clean + + + + org.apache.maven.plugins maven-compiler-plugin @@ -129,36 +166,4 @@ - - - - - release - - - - org.apache.maven.plugins - maven-gpg-plugin - - - sign-artifacts - verify - - sign - - - - - - org.sonatype.central - central-publishing-maven-plugin - true - - central - - - - - - diff --git a/publish.md b/publish.md index 9894e5d..872039e 100644 --- a/publish.md +++ b/publish.md @@ -96,7 +96,7 @@ Add a **profile** `-P release` so day to day `mvn test` does not require GPG. ## 6. First release (high level) -1. Set **release version** in `pom.xml` (remove `-SNAPSHOT`), for example `1.0.0`. +1. Set **release version** in the **root** `pom.xml` property **`revision`** (remove `-SNAPSHOT`), for example `1.0.0`. All modules inherit this version; you can instead pass **`-Drevision=…`** on the Maven command line for a one-off release build. 2. Ensure `CHANGES` / git tag strategy is decided (`v1.0.0`). 3. Run locally: @@ -110,22 +110,22 @@ Add a **profile** `-P release` so day to day `mvn test` does not require GPG. mvn clean verify -P release ``` -4. **Deploy** (from the repository root; the reactor publishes the parent POM `com.supaship:supaship-sdks` and the library `com.supaship:supaship-sdk`): +4. **Deploy** (from the repository root). Only **library modules** are uploaded: the aggregator POM `com.supaship:supaship-sdks` is skipped (`maven.deploy.skip`). Each published module’s **flattened** `pom.xml` is deployed (no parent reference on Central). The Central Publishing plugin is configured with **`autoPublish`** so a valid deployment can finish without a manual “release” click in the portal (you can still confirm status in the [Central Portal](https://central.sonatype.com/) if you want). ```bash mvn clean deploy -P release ``` -5. In the **Sonatype portal** (or Nexus UI if legacy): **close** the staging repository, **release** it, wait until artifacts propagate to **Maven Central** (often tens of minutes the first time). +5. Wait until artifacts propagate to **Maven Central** (often tens of minutes the first time). If you disabled automatic publishing or validation fails, use the portal to fix or publish the deployment. -6. Verify in a browser: `https://repo1.maven.org/maven2/com/supaship/supaship-sdk/1.0.0/` (adjust `groupId` path: `com.supaship` → `com/supaship`). The parent POM lives under `https://repo1.maven.org/maven2/com/supaship/supaship-sdks//`. +6. Verify in a browser: `https://repo1.maven.org/maven2/com/supaship/supaship-sdk/1.0.0/` (adjust `groupId` path: `com.supaship` → `com/supaship`). ## 7. Later releases (updates) -1. **Bump version** in `pom.xml`, for example `1.0.1` or `1.1.0`. +1. **Bump** the **`revision`** property in the **root** `pom.xml` (or use **`-Drevision=…`**), for example `1.0.1` or `1.1.0`. 2. Commit and **tag** (`git tag v1.0.1`). 3. Run `mvn clean deploy -P release` again. -4. Close / release staging as before. +4. Confirm the deployment in the Central Portal if needed (automatic publishing is enabled in the project POM). **Semantic versioning** (recommended): From 8526b6841442adf933bacc4e63c14e2677c7c0df Mon Sep 17 00:00:00 2001 From: Madhu Dollu Date: Fri, 3 Apr 2026 18:53:31 +0530 Subject: [PATCH 5/7] update workflow script to publish to maven central --- .github/workflows/publish.yml | 43 ++++++++++++++--------------------- .gitignore | 1 - java-sdk/pom.xml | 8 +++++++ pom.xml | 3 ++- settings.xml | 9 ++++++++ 5 files changed, 36 insertions(+), 28 deletions(-) create mode 100644 settings.xml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b4bde9e..e8a4ebd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,38 +1,21 @@ -# Publishes the library to GitHub Packages when you publish a GitHub Release, -# or manually via workflow_dispatch (default branch). -# -# Consumers add GitHub Packages as a Maven repository, for example: -# -# -# github -# https://maven.pkg.github.com/OWNER/REPO -# -# -# and authenticate (token with read:packages). See GitHub’s “Apache Maven registry”. -# -# For Maven Central, use local `mvn deploy` with your Sonatype credentials -# and signing (see publish.md); do not rely on this workflow alone. - -name: Publish +name: Publish to Maven Central on: release: types: [published] - workflow_dispatch: permissions: contents: read - packages: write jobs: - github-packages: + publish: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.ref }} + ref: ${{ github.event.release.tag_name }} - name: Set up JDK 11 uses: actions/setup-java@v4 @@ -40,11 +23,19 @@ jobs: java-version: "11" distribution: "temurin" cache: "maven" - server-id: github - server-username: ${{ github.actor }} - server-password: ${{ secrets.GITHUB_TOKEN }} - - name: Deploy to GitHub Packages + - name: Import GPG key + run: echo "${{ secrets.GPG_PRIVATE_KEY }}" | base64 --decode | gpg --batch --import + + - name: Set release version from tag + run: echo "RELEASE_VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV + + - name: Publish run: | - mvn -B -ntp deploy \ - -DaltDeploymentRepository=github::default::https://maven.pkg.github.com/${{ github.repository }} + mvn -B -ntp deploy -Prelease \ + -Drevision=${{ env.RELEASE_VERSION }} \ + --settings settings.xml + env: + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} diff --git a/.gitignore b/.gitignore index 52d73b5..956f688 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,3 @@ target/ .settings/ .DS_Store .flattened-pom.xml -*.jar diff --git a/java-sdk/pom.xml b/java-sdk/pom.xml index caf5126..af661c9 100644 --- a/java-sdk/pom.xml +++ b/java-sdk/pom.xml @@ -131,6 +131,13 @@ + + + + --pinentry-mode + loopback + + org.sonatype.central @@ -139,6 +146,7 @@ central true + published diff --git a/pom.xml b/pom.xml index 70e9453..e325c36 100644 --- a/pom.xml +++ b/pom.xml @@ -27,6 +27,7 @@ Supaship + developer@supaship.com https://github.com/SupashipHQ @@ -39,7 +40,7 @@ - 1.0.0-SNAPSHOT + 0.1.0-SNAPSHOT UTF-8 11 diff --git a/settings.xml b/settings.xml new file mode 100644 index 0000000..a73b181 --- /dev/null +++ b/settings.xml @@ -0,0 +1,9 @@ + + + + central + ${env.MAVEN_CENTRAL_USERNAME} + ${env.MAVEN_CENTRAL_PASSWORD} + + + \ No newline at end of file From 9d1de9b0e2bf7a09e38eb52a53c644ff357b19fd Mon Sep 17 00:00:00 2001 From: Madhu Dollu Date: Fri, 3 Apr 2026 19:01:00 +0530 Subject: [PATCH 6/7] minor description updates --- java-sdk/pom.xml | 2 +- pom.xml | 2 +- publish.md | 165 ----------------------------------------------- 3 files changed, 2 insertions(+), 167 deletions(-) delete mode 100644 publish.md diff --git a/java-sdk/pom.xml b/java-sdk/pom.xml index af661c9..062afe2 100644 --- a/java-sdk/pom.xml +++ b/java-sdk/pom.xml @@ -15,7 +15,7 @@ jar Supaship SDK for Java - Lightweight JVM client for Supaship feature flags (Java 11+, Kotlin) + Official Java SDK for Supaship (Java 11+, Kotlin) https://github.com/SupashipHQ/java-sdk diff --git a/pom.xml b/pom.xml index e325c36..2481cf4 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ pom Supaship SDKs - Multi-module build for Supaship JVM libraries + Multi-module build for Supaship SDKs https://github.com/SupashipHQ/java-sdk diff --git a/publish.md b/publish.md deleted file mode 100644 index 872039e..0000000 --- a/publish.md +++ /dev/null @@ -1,165 +0,0 @@ -# Publishing the Supaship Java SDK - -This guide assumes you have **never** published a Java library before. It explains how artifacts get into a **package registry** that Maven and Gradle users can consume, and how to publish **updates** later. - -## 1. What you are publishing - -A Maven project produces a **JAR** and a **POM** (metadata). Together they are identified by three coordinates: - -| Coordinate | This project (example) | Meaning | -|-------------|-------------------------|---------| -| `groupId` | `com.supaship` | Your organization or product namespace (like an npm scope). | -| `artifactId`| `supaship-sdk` | The library name. | -| `version` | `1.0.0` | Semantic version; **release** versions must not end in `-SNAPSHOT`. | - -**Snapshot** versions (for example `1.0.0-SNAPSHOT`) are mutable development builds. **Release** versions (for example `1.0.0`) are immutable once on Maven Central. - -The **de facto** public registry for open source Java is **Maven Central** (synced from Sonatype). Alternatives include **GitHub Packages**, **JitPack**, or a private Nexus; this doc focuses on **Maven Central**, which is what most developers expect. - -## 2. Prerequisites on your machine - -1. **JDK 11+** (this project targets Java 11). -2. **Apache Maven** (3.9+ recommended). -3. **GnuPG** (`gpg`) for **signing** artifacts. Maven Central requires cryptographic signatures for releases. - -Install GnuPG and create a key (one time): - -```bash -gpg --full-generate-key -# Choose RSA, 4096 bits, expiry as you prefer, use your real name and the email you will register with Sonatype. -gpg --list-secret-keys --keyid-format=long -``` - -Publish the **public** key to a keyserver (Ubuntu keyserver is commonly used): - -```bash -gpg --keyserver keyserver.ubuntu.com --send-keys YOUR_KEY_ID_LONG -``` - -## 3. Register a namespace with Sonatype (Maven Central) - -Modern flow uses the **Central Portal**: https://central.sonatype.com/ - -1. **Sign up** and sign in. -2. **Choose a namespace** that matches your `groupId`: - - **Reverse DNS** you control: for example if you own `supaship.com`, you can use `com.supaship` after proving domain ownership (DNS TXT record they specify). - - **`io.github.your-org`** if you publish from GitHub and verify the namespace they require for that pattern. - -3. **Open a namespace / verify** following the portal wizard (DNS TXT, or GitHub org verification, depending on the namespace type). - -Until the namespace is approved, you **cannot** publish under that `groupId` to Maven Central. - -> If your current `groupId` is `com.supaship`, you must prove you control a domain that authorizes that group id, **or** change `groupId` in `pom.xml` to a namespace Sonatype assigns you (for example `io.github.supashiphq`). Coordinate this before the first release. - -## 4. Credentials in `settings.xml` (one time) - -Maven needs a **token** (or username/password) to upload. In the Central Portal, create a **User token** and add a `` entry. - -Edit `~/.m2/settings.xml` (create the file if missing). **Do not commit tokens to git.** - -```xml - - - - central - - - - - -``` - -The `` must match the `` repository `` in your `pom.xml` (Sonatype’s docs often use `central` or `ossrh`; follow the exact id from their publishing guide for the plugin you use). - -## 5. POM changes for publishing - -For Maven Central you typically need: - -1. **Project metadata**: `name`, `description`, `url`, `licenses`, `scm`, `developers`. -2. **Source and Javadoc JARs** (consumers and indexes expect them). -3. **GPG signing** of artifacts. -4. **distributionManagement** pointing at Sonatype’s deployment endpoint **or** use the official **Central Publishing** Maven plugin they document for new projects. - -Exact plugin coordinates change over time; always cross check https://central.sonatype.org/publish/publish-maven/ for the **current** recommended `pom.xml` fragment. - -A minimal **conceptual** checklist: - -- `maven-source-plugin` → attaches `-sources.jar` -- `maven-javadoc-plugin` → attaches `-javadoc.jar` -- `maven-gpg-plugin` → signs all attached artifacts -- **Staging / publishing** plugin as per Sonatype (classic OSSRH `nexus-staging-maven-plugin` vs newer Central Publishing) - -Add a **profile** `-P release` so day to day `mvn test` does not require GPG. - -## 6. First release (high level) - -1. Set **release version** in the **root** `pom.xml` property **`revision`** (remove `-SNAPSHOT`), for example `1.0.0`. All modules inherit this version; you can instead pass **`-Drevision=…`** on the Maven command line for a one-off release build. -2. Ensure `CHANGES` / git tag strategy is decided (`v1.0.0`). -3. Run locally: - - ```bash - mvn clean verify - ``` - - With signing profile (example): - - ```bash - mvn clean verify -P release - ``` - -4. **Deploy** (from the repository root). Only **library modules** are uploaded: the aggregator POM `com.supaship:supaship-sdks` is skipped (`maven.deploy.skip`). Each published module’s **flattened** `pom.xml` is deployed (no parent reference on Central). The Central Publishing plugin is configured with **`autoPublish`** so a valid deployment can finish without a manual “release” click in the portal (you can still confirm status in the [Central Portal](https://central.sonatype.com/) if you want). - - ```bash - mvn clean deploy -P release - ``` - -5. Wait until artifacts propagate to **Maven Central** (often tens of minutes the first time). If you disabled automatic publishing or validation fails, use the portal to fix or publish the deployment. - -6. Verify in a browser: `https://repo1.maven.org/maven2/com/supaship/supaship-sdk/1.0.0/` (adjust `groupId` path: `com.supaship` → `com/supaship`). - -## 7. Later releases (updates) - -1. **Bump** the **`revision`** property in the **root** `pom.xml` (or use **`-Drevision=…`**), for example `1.0.1` or `1.1.0`. -2. Commit and **tag** (`git tag v1.0.1`). -3. Run `mvn clean deploy -P release` again. -4. Confirm the deployment in the Central Portal if needed (automatic publishing is enabled in the project POM). - -**Semantic versioning** (recommended): - -- **PATCH** (`1.0.1`): bug fixes, documentation, internal refactors with no API change. -- **MINOR** (`1.1.0`): backward compatible API additions. -- **MAJOR** (`2.0.0`): breaking API changes. - -## 8. Snapshots (optional) - -If you want public `-SNAPSHOT` builds, configure a **snapshotRepository** in `distributionManagement` and publish snapshot versions. Many teams skip snapshots and only use Git commit hashes plus local `mvn install` for integration. - -## 9. Simpler alternative: GitHub Packages - -If Maven Central is too heavy for an early preview: - -1. Create a **Personal Access Token** with `write:packages`. -2. Add a `` in `distributionManagement` pointing at - `https://maven.pkg.github.com/OWNER/REPO`. -3. In `~/.m2/settings.xml`, add `` with your GitHub username and token. - -Consumers must add the same repository block in their `pom.xml` (GitHub Packages are not on the default Central mirror). - -## 10. Checklist before announcing a release - -- [ ] `mvn test` passes. -- [ ] Version is **not** `-SNAPSHOT` for a public release tag. -- [ ] `LICENSE` file matches `pom.xml` `licenses`. -- [ ] `groupId` is **approved** in Sonatype for your namespace. -- [ ] Javadoc builds (`mvn javadoc:javadoc` or via release profile). -- [ ] You can resolve the artifact from a **fresh** machine or project using only Central (or document extra repositories if using GitHub Packages). - -## 11. Where to get help - -- Sonatype: https://central.sonatype.org/ -- Maven Central status and search: https://central.sonatype.com/ - -When in doubt, Sonatype’s current **“Publish Maven”** article is the source of truth for XML snippets and plugin versions. From fa88fa73e89c41ab8832bd0a15725ab071aa197c Mon Sep 17 00:00:00 2001 From: Madhu Dollu Date: Fri, 3 Apr 2026 19:06:39 +0530 Subject: [PATCH 7/7] update readme --- README.md | 503 ++++++++++++++++++++++++++++++++------------------- settings.xml | 2 +- 2 files changed, 314 insertions(+), 191 deletions(-) diff --git a/README.md b/README.md index 2724f70..c6166c7 100644 --- a/README.md +++ b/README.md @@ -1,212 +1,255 @@ # Supaship Java SDK -Small, production oriented client for [Supaship](https://supaship.com) feature flags. It mirrors the public behavior of [`@supashiphq/javascript-sdk`](https://www.npmjs.com/package/@supashiphq/javascript-sdk) (`SupaClient`): same default endpoints, request shape, retry policy, timeouts, sensitive-context hashing, and fallback rules when the API is unavailable. +> Lightweight, production-ready Java client for [Supaship](https://supaship.com) feature flags. -**Runtime:** Java 11 or newer (bytecode 11; use a Java 11+ target in Kotlin too). **Dependencies:** [Gson](https://github.com/google/gson) (JSON) and [JetBrains Annotations](https://github.com/JetBrains/java-annotations) (small JAR; helps Kotlin null-safety). HTTP uses `java.net.http`. +**Minimum runtime:** Java 11+ +**Dependencies:** [Gson](https://github.com/google/gson) · [JetBrains Annotations](https://github.com/JetBrains/java-annotations) +**HTTP:** `java.net.http` (no extra HTTP library needed) +**Kotlin:** Fully supported — see [Kotlin usage](#kotlin-usage) -**Kotlin:** You can depend on this artifact from any JVM Kotlin project—no Kotlin-specific module is required. Public API is annotated with `@NotNull` / `@Nullable` for Kotlin. See [Kotlin usage](#kotlin-usage). +--- -Browser only features from the JS SDK (for example the toolbar plugin) are not applicable here. +## Table of Contents -## Install +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Configuration Reference](#configuration-reference) +- [Async API](#async-api) +- [Listeners](#listeners) +- [Custom HttpClient](#custom-httpclient) +- [Framework Integration](#framework-integration) + - [Spring Boot](#spring-boot) + - [Quarkus / Micronaut](#quarkus--micronaut) +- [Kotlin Usage](#kotlin-usage) +- [Error Handling and Fallbacks](#error-handling-and-fallbacks) +- [Building from Source](#building-from-source) +- [License](#license) -### Maven +--- + +## Installation +### Maven ```xml com.supaship supaship-sdk - 1.0.0 + VERSION ``` -Replace the version with the latest release you publish (see [publish.md](publish.md) if you are publishing this library yourself). - ### Gradle (Kotlin DSL) - ```kotlin dependencies { - implementation("com.supaship:supaship-sdk:1.0.0") + implementation("com.supaship:supaship-sdk:VERSION") } ``` -Ensure the Kotlin/JVM target is **11 or newer** (for example in `kotlin { jvmToolchain(11) }` or matching `JavaPluginExtension`). - -### Kotlin usage - -Use the same types as in Java; `CompletableFuture` can be blocked with `.get()`, or adapted with coroutines: - -```kotlin -import com.supaship.SupaClient -import com.supaship.SupaClientConfig -import kotlinx.coroutines.future.await -import kotlinx.coroutines.runBlocking - -fun main() = runBlocking { - val client = SupaClient( - SupaClientConfig.builder() - .sdkKey(System.getenv("SUPASHIP_SDK_KEY")) - .environment("production") - .features(mapOf("dark-mode" to false, "max-items" to 10L)) - .context(mapOf("region" to "eu")) - .build() - ) - - val dark = client.getFeature("dark-mode").await() as Boolean - val batch = client.getFeatures(listOf("dark-mode", "max-items")).await() - client.updateContext(mapOf("plan" to "pro"), mergeWithExisting = true) +### Gradle (Groovy DSL) +```groovy +dependencies { + implementation 'com.supaship:supaship-sdk:VERSION' } ``` -The `await()` example needs `kotlinx-coroutines-core` and `kotlinx-coroutines-jdk8` on your classpath (`implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.8.1")` or newer). Without coroutines, use `client.getFeature("dark-mode").get()` instead. +Replace `VERSION` with the [latest release](https://central.sonatype.com/artifact/com.supaship/supaship-sdk). -## Quick start +--- +## Quick Start ```java import com.supaship.SupaClient; import com.supaship.SupaClientConfig; import java.util.List; import java.util.Map; -import java.util.concurrent.ExecutionException; public class Example { - public static void main(String[] args) throws ExecutionException, InterruptedException { - Map fallbacks = - Map.of( - "dark-mode", false, - "max-items", 10L); - - SupaClient client = - new SupaClient( + public static void main(String[] args) throws Exception { + + // 1. Define fallback values — used when the API is unreachable + Map fallbacks = Map.of( + "dark-mode", false, + "max-items", 10L, + "theme", "light" + ); + + // 2. Build the client + SupaClient client = new SupaClient( SupaClientConfig.builder() .sdkKey(System.getenv("SUPASHIP_SDK_KEY")) .environment("production") .features(fallbacks) .context(Map.of("region", "eu")) - .build()); + .build() + ); - boolean dark = - (Boolean) client.getFeature("dark-mode").get(); + // 3. Evaluate a single flag (blocks until resolved) + boolean darkMode = (Boolean) client.getFeature("dark-mode").get(); - Map batch = - client.getFeatures(List.of("dark-mode", "max-items")).get(); + // 4. Evaluate multiple flags in one call + Map flags = client.getFeatures( + List.of("dark-mode", "max-items", "theme") + ).get(); - client.updateContext(Map.of("plan", "pro"), true); - } + // 5. Update the evaluation context at runtime + client.updateContext(Map.of("plan", "pro"), true /* merge with existing */); + } } ``` -### Async API +--- + +## Configuration Reference -All network calls return `CompletableFuture`. Use `thenApply`, `whenComplete`, or block with `get()` / `join()` as appropriate. +Pass all options through `SupaClientConfig.builder()`: +| Option | Type | Required | Description | +|---|---|---|---| +| `sdkKey` | `String` | ✅ | Your Supaship SDK key. Sent as `Authorization: Bearer …` | +| `environment` | `String` | ✅ | Target environment, e.g. `production`, `staging` | +| `features` | `Map` | ✅ | Fallback values used when the API is unavailable. Supported types: `Boolean`, `Number`, `String`, `List`, `Map`, `null` | +| `context` | `Map` | — | Default evaluation context merged into every request | +| `sensitiveContextProperties` | `Set` | — | Context keys whose values are hashed with SHA-256 before the request is sent | +| `networkConfig` | `NetworkConfig` | — | Custom timeout, retry, base URLs, or `HttpClient` | +| `addListener` | `SupaClientListener` | — | Lifecycle hooks: errors, retries, fallbacks, context updates | + +### Defaults + +| Setting | Default | +|---|---| +| Features URL | `https://edge.supaship.com/v1/features` | +| Events URL | `https://edge.supaship.com/v1/events` | +| Request timeout | 10 seconds | +| Retry attempts | 3 | +| Retry base backoff | 1000 ms (exponential: `base × 2^(attempt-1)`) | + +--- + +## Async API + +Every network call returns a `CompletableFuture`. You can block, chain, or handle errors asynchronously: ```java -client - .getFeature("dark-mode", Map.of("userId", "u-123")) - .thenAccept( - value -> { - // value is the flag value or the configured fallback type - }); +// Non-blocking — chain a callback +client.getFeature("dark-mode") + .thenAccept(value -> System.out.println("dark-mode = " + value)); + +// Non-blocking with per-call context override +client.getFeature("dark-mode", Map.of("userId", "u-123")) + .thenAccept(value -> renderUI((Boolean) value)); + +// Evaluate multiple flags non-blocking +client.getFeatures(List.of("dark-mode", "max-items")) + .thenAccept(flags -> { + boolean dark = (Boolean) flags.get("dark-mode"); + long limit = (Long) flags.get("max-items"); + }); + +// Blocking (useful in tests or CLI tools) +boolean dark = (Boolean) client.getFeature("dark-mode").get(); + +// Blocking with timeout +boolean dark = (Boolean) client + .getFeature("dark-mode") + .get(5, TimeUnit.SECONDS); ``` -## Configuration +--- -| Area | Java type | Notes | -|------|-----------|--------| -| SDK key | `SupaClientConfig.Builder.sdkKey` | Same as JS `sdkKey`; sent as `Authorization: Bearer …`. | -| Environment | `environment` | Same as JS `environment` (for example `production`, `staging`). | -| Fallbacks | `features(Map)` | Same role as JS `features` / `FeaturesWithFallbacks`. Used when the API fails or a variation is absent. Values may be `Boolean`, `Number`, `String`, `List`, `Map`, or `null`. | -| Default context | `context` | Merged into every evaluation unless you pass a per-call override. | -| Sensitive fields | `sensitiveContextProperties(Set)` | Names of context keys whose values are replaced with a **SHA-256** hex digest before the request is sent (same idea as the JS client). | -| Network | `NetworkConfig` | Optional: `featuresApiUrl`, `eventsApiUrl` (reserved for future use), `retry`, `requestTimeout`, `HttpClient`. | +## Listeners -Defaults match the JS SDK: +`SupaClientListener` provides optional lifecycle hooks. All methods have default no-op implementations — implement only what you need. +```java +import com.supaship.SupaClientConfig; +import com.supaship.SupaClientListener; -- Features URL: `https://edge.supaship.com/v1/features` -- Events URL: `https://edge.supaship.com/v1/events` (not used by evaluate yet; kept for parity) -- Retry: enabled, 3 attempts, base backoff 1000 ms, exponential factor \(2^{attempt-1}\) -- Request timeout: 10 seconds +SupaClientConfig config = SupaClientConfig.builder() + .sdkKey(System.getenv("SUPASHIP_SDK_KEY")) + .environment("production") + .features(Map.of("dark-mode", false)) + .addListener(new SupaClientListener() { + + @Override + public void onError(Throwable error, Map context) { + // Log or report errors + logger.error("Supaship error: {}", error.getMessage()); + } + + @Override + public void onFallbackUsed(String feature, Object fallbackValue) { + // Track when fallbacks are served — useful for metrics + metrics.increment("supaship.fallback", "feature:" + feature); + } + + @Override + public void onRetry(int attempt, Throwable cause) { + logger.warn("Supaship retry attempt {}: {}", attempt, cause.getMessage()); + } + }) + .build(); +``` -### Custom `HttpClient` +--- -Use your own `java.net.http.HttpClient` for TLS settings, proxy, or HTTP version: +## Custom HttpClient +Use your own `java.net.http.HttpClient` to control TLS settings, proxy, connection pool, or HTTP version: ```java import com.supaship.NetworkConfig; +import com.supaship.SupaClient; import com.supaship.SupaClientConfig; +import java.net.InetSocketAddress; +import java.net.ProxySelector; import java.net.http.HttpClient; import java.time.Duration; import java.util.Map; -HttpClient http = - HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(5)) - .version(HttpClient.Version.HTTP_1_1) - .build(); +HttpClient http = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .version(HttpClient.Version.HTTP_2) + .proxy(ProxySelector.of(new InetSocketAddress("proxy.internal", 8080))) + .build(); -NetworkConfig network = - NetworkConfig.builder() - .httpClient(http) - .featuresApiUrl("https://edge.supaship.com/v1/features") - .build(); +NetworkConfig network = NetworkConfig.builder() + .httpClient(http) + .requestTimeout(Duration.ofSeconds(8)) + .featuresApiUrl("https://edge.supaship.com/v1/features") + .build(); -SupaClientConfig config = +SupaClient client = new SupaClient( SupaClientConfig.builder() - .sdkKey(key) + .sdkKey(System.getenv("SUPASHIP_SDK_KEY")) .environment("production") - .features(Map.of("flag", false)) + .features(Map.of("dark-mode", false)) .networkConfig(network) - .build(); + .build() +); ``` -### Listeners +--- -`SupaClientListener` provides optional hooks similar in spirit to JS plugins (before/after fetch, retries, errors, fallbacks, context updates). Implement only what you need; default methods are no-ops. +## Framework Integration -```java -import com.supaship.SupaClientConfig; -import com.supaship.SupaClientListener; +### Spring Boot -import java.util.Map; - -SupaClientConfig config = - SupaClientConfig.builder() - .sdkKey(key) - .environment("production") - .features(Map.of("flag", false)) - .addListener( - new SupaClientListener() { - @Override - public void onError(Throwable error, Map context) { - // log, metrics, etc. - } - }) - .build(); -``` - -## Spring Boot - -Use a single application scoped `SupaClient` bean and inject it where needed. Prefer configuration properties for the SDK key and environment. - -### `application.properties` +Declare a single application-scoped `SupaClient` bean and inject it wherever needed. +#### `application.properties` ```properties supaship.sdk-key=${SUPASHIP_SDK_KEY} supaship.environment=production ``` -### Configuration bean - +#### Configuration class ```java -import com.supaship.NetworkConfig; import com.supaship.SupaClient; import com.supaship.SupaClientConfig; +import com.supaship.NetworkConfig; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.boot.context.properties.EnableConfigurationProperties; import java.util.Map; @@ -214,106 +257,186 @@ import java.util.Map; @EnableConfigurationProperties(SupashipConfiguration.SupashipProps.class) public class SupashipConfiguration { - @Bean - public SupaClient supaClient(SupashipProps props) { - Map features = - Map.of( - "new-checkout", false, - "banner-message", "Welcome"); - - SupaClientConfig config = - SupaClientConfig.builder() - .sdkKey(props.getSdkKey()) - .environment(props.getEnvironment()) - .features(features) - .context(Map.of("service", "api")) - .networkConfig(NetworkConfig.builder().build()) - .build(); - - return new SupaClient(config); - } - - @ConfigurationProperties(prefix = "supaship") - public static class SupashipProps { - private String sdkKey; - private String environment; - - public String getSdkKey() { - return sdkKey; + @Bean + public SupaClient supaClient(SupashipProps props) { + return new SupaClient( + SupaClientConfig.builder() + .sdkKey(props.getSdkKey()) + .environment(props.getEnvironment()) + .features(Map.of( + "new-checkout", false, + "banner-message", "Welcome" + )) + .context(Map.of("service", "api")) + .networkConfig(NetworkConfig.builder().build()) + .build() + ); } - public void setSdkKey(String sdkKey) { - this.sdkKey = sdkKey; + @ConfigurationProperties(prefix = "supaship") + public static class SupashipProps { + private String sdkKey; + private String environment; + + public String getSdkKey() { return sdkKey; } + public void setSdkKey(String sdkKey) { this.sdkKey = sdkKey; } + public String getEnvironment() { return environment; } + public void setEnvironment(String env) { this.environment = env; } } +} +``` + +#### Injecting into a service +```java +import com.supaship.SupaClient; +import org.springframework.stereotype.Service; - public String getEnvironment() { - return environment; +import java.util.Map; + +@Service +public class CheckoutService { + + private final SupaClient supaship; + + public CheckoutService(SupaClient supaship) { + this.supaship = supaship; } - public void setEnvironment(String environment) { - this.environment = environment; + public boolean isNewCheckoutEnabled(String userId) throws Exception { + Object value = supaship + .getFeature("new-checkout", Map.of("userId", userId)) + .get(); + return Boolean.TRUE.equals(value); } - } } ``` -### Using the client in a service +--- +### Quarkus / Micronaut + +No framework-specific integration is required. Create one `SupaClient` instance at startup and expose it as a CDI bean or singleton: ```java +// Quarkus — ApplicationScoped CDI bean import com.supaship.SupaClient; -import org.springframework.stereotype.Service; +import com.supaship.SupaClientConfig; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; import java.util.Map; -@Service -public class FeatureService { - private final SupaClient supaship; +@ApplicationScoped +public class SupashipProducer { + + @Produces + @ApplicationScoped + public SupaClient supaClient() { + return new SupaClient( + SupaClientConfig.builder() + .sdkKey(System.getenv("SUPASHIP_SDK_KEY")) + .environment("production") + .features(Map.of("feature-x", false)) + .build() + ); + } +} +``` + +Then inject `SupaClient` into any resource or service with `@Inject`. - public FeatureService(SupaClient supaship) { - this.supaship = supaship; - } +--- - public boolean isNewCheckoutEnabled(String userId) throws Exception { - Object v = supaship.getFeature("new-checkout", Map.of("userId", userId)).get(); - return Boolean.TRUE.equals(v); - } +## Kotlin Usage + +The SDK works with any JVM Kotlin project. Public API methods are annotated with `@NotNull` / `@Nullable` for Kotlin null-safety. Ensure your JVM target is **11 or newer**: +```kotlin +// build.gradle.kts +kotlin { + jvmToolchain(11) } ``` -**Gradle note:** Spring Boot does not change how you add this SDK: use `implementation("com.supaship:supaship-sdk:…")` alongside your usual `org.springframework.boot` dependencies. +#### Blocking (no coroutines needed) +```kotlin +import com.supaship.SupaClient +import com.supaship.SupaClientConfig + +val client = SupaClient( + SupaClientConfig.builder() + .sdkKey(System.getenv("SUPASHIP_SDK_KEY")) + .environment("production") + .features(mapOf("dark-mode" to false, "max-items" to 10L)) + .context(mapOf("region" to "eu")) + .build() +) + +val darkMode = client.getFeature("dark-mode").get() as Boolean +val flags = client.getFeatures(listOf("dark-mode", "max-items")).get() +``` + +#### With coroutines -## Quarkus / Micronaut / other frameworks +Add to your dependencies: +```kotlin +implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.8.1") +``` +```kotlin +import com.supaship.SupaClient +import com.supaship.SupaClientConfig +import kotlinx.coroutines.future.await +import kotlinx.coroutines.runBlocking -There is no framework specific integration: create one `SupaClient` instance (or one per tenant) at startup with `SupaClientConfig`, expose it as a CDI bean / singleton, and inject it into resources or services the same way as any other HTTP client. +fun main() = runBlocking { + val client = SupaClient( + SupaClientConfig.builder() + .sdkKey(System.getenv("SUPASHIP_SDK_KEY")) + .environment("production") + .features(mapOf("dark-mode" to false, "max-items" to 10L)) + .build() + ) + + val darkMode = client.getFeature("dark-mode").await() as Boolean + val flags = client.getFeatures(listOf("dark-mode", "max-items")).await() -## Error handling and fallbacks + client.updateContext(mapOf("plan" to "pro"), mergeWithExisting = true) +} +``` -If the features HTTP call fails after retries, or the response is not successful, `getFeature` / `getFeatures` still **complete normally** with values from your configured `features` map (fallbacks). That matches the JavaScript client. +--- -Use `SupaClientListener.onError` and `onFallbackUsed` if you want metrics or logs when fallbacks are used. +## Error Handling and Fallbacks -Very rare failures (for example if SHA-256 is unavailable) complete the `CompletableFuture` exceptionally with `SupashipException`. Ordinary HTTP or parse errors follow the fallback path above instead of failing the future. +The SDK is designed to never block your application if the Supaship API is unavailable. -## Building from source +- If a request fails after all retry attempts, `getFeature` / `getFeatures` **complete normally** with the values from your configured `features` fallback map — no exception is thrown. +- Use `SupaClientListener.onError` and `onFallbackUsed` to observe when fallbacks are active. +- In rare cases where the JVM itself is in a degraded state (e.g. SHA-256 unavailable), the `CompletableFuture` completes exceptionally with `SupashipException`. Normal HTTP and parsing errors always follow the fallback path. +```java +client.getFeature("dark-mode") + .whenComplete((value, error) -> { + if (error != null) { + // Only SupashipException reaches here — handle critical failures + logger.error("Critical SDK failure", error); + } else { + // value is always non-null; may be the fallback + renderUI((Boolean) value); + } + }); +``` + +--- +## Building from Source ```bash -mvn test package +git clone https://github.com/SupashipHQ/java-sdk.git +cd java-sdk +mvn verify ``` -The resulting JAR is `java-sdk/target/supaship-sdk-*.jar` (run Maven from the repository root). - -## JavaScript parity (summary) +This compiles, runs tests, and packages the jar. No external services are required to build. -| JavaScript `SupaClient` | Java `SupaClient` | -|------------------------|-------------------| -| `getFeature`, `getFeatures` | Same, return `CompletableFuture` | -| `updateContext`, `getContext` | Same | -| `getFeatureFallback` | `getFeatureFallback` | -| `networkConfig` (URLs, retry, timeout, custom fetch) | `NetworkConfig` + optional `HttpClient` | -| `sensitiveContextProperties` | Same (SHA-256 hex) | -| Plugins | `SupaClientListener` (subset of hooks) | -| Toolbar plugin | Not applicable on the JVM | +--- ## License -See [LICENSE](LICENSE). \ No newline at end of file +[MIT](LICENSE) diff --git a/settings.xml b/settings.xml index a73b181..473cb3c 100644 --- a/settings.xml +++ b/settings.xml @@ -6,4 +6,4 @@ ${env.MAVEN_CENTRAL_PASSWORD} - \ No newline at end of file +