From a515240531570352a121d6eeca77b138df5add1e Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 21 Oct 2026 12:49:27 +0200 Subject: [PATCH 1/5] ci: Run the Flagsmith SDK behavioural spec --- .github/workflows/sdk-spec.yml | 16 ++++ renovate.json | 14 +++ spec-adapter/Adapter.java | 151 +++++++++++++++++++++++++++++++++ spec-adapter/Dockerfile | 24 ++++++ 4 files changed, 205 insertions(+) create mode 100644 .github/workflows/sdk-spec.yml create mode 100644 spec-adapter/Adapter.java create mode 100644 spec-adapter/Dockerfile diff --git a/.github/workflows/sdk-spec.yml b/.github/workflows/sdk-spec.yml new file mode 100644 index 0000000..6cbf98b --- /dev/null +++ b/.github/workflows/sdk-spec.yml @@ -0,0 +1,16 @@ +name: SDK spec + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + sdk-spec: + # Keep the ref in `uses:` and `spec-ref` in sync. + uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.1.1 + with: + spec-ref: v0.1.1 diff --git a/renovate.json b/renovate.json index b7a5f2e..b1830e2 100644 --- a/renovate.json +++ b/renovate.json @@ -4,6 +4,15 @@ "ignorePresets": [":semanticPrefixFixDepsChoreOthers"], "semanticCommitType": "deps", "git-submodules": { "enabled": true }, + "customManagers": [ + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/workflows/sdk-spec\\.yml$/"], + "matchStrings": ["spec-ref: (?v[\\d.]+)"], + "depNameTemplate": "Flagsmith/sdk-spec", + "datasourceTemplate": "github-tags" + } + ], "packageRules": [ { "matchUpdateTypes": [ @@ -23,6 +32,11 @@ "matchManagers": ["git-submodules"], "versioning": "semver-coerced", "enabled": true + }, + { + "matchPackageNames": ["Flagsmith/sdk-spec"], + "groupName": "SDK spec", + "enabled": true } ] } diff --git a/spec-adapter/Adapter.java b/spec-adapter/Adapter.java new file mode 100644 index 0000000..58480b9 --- /dev/null +++ b/spec-adapter/Adapter.java @@ -0,0 +1,151 @@ +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.flagsmith.FlagsmithClient; +import com.flagsmith.Versions; +import com.flagsmith.models.BaseFlag; +import com.flagsmith.models.Flags; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Adapter that lets the Flagsmith SDK behavioural spec drive this SDK. + * + *

Protocol: https://github.com/Flagsmith/sdk-spec/blob/main/ADAPTER.md + */ +public class Adapter { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final TypeReference> JSON_OBJECT = + new TypeReference>() {}; + private static final Pattern CLIENT_ROUTE = + Pattern.compile("^/clients/(?[^/]+)(?/[a-z-]+)?$"); + + private static final Map clients = new ConcurrentHashMap<>(); + private static final AtomicLong clientIds = new AtomicLong(); + + public static void main(String[] args) throws IOException { + int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "8080")); + HttpServer server = HttpServer.create(new InetSocketAddress("0.0.0.0", port), 0); + server.createContext("/", Adapter::dispatch); + server.setExecutor(Executors.newCachedThreadPool()); + server.start(); + System.out.println("adapter: listening on :" + port); + } + + private static void dispatch(HttpExchange exchange) throws IOException { + try { + route(exchange); + } catch (Exception e) { + // Report SDK errors to the runner. + e.printStackTrace(); + send(exchange, 500, error(e.getClass().getSimpleName() + ": " + e.getMessage())); + } finally { + System.out.println("adapter: " + exchange.getRequestMethod() + " " + + exchange.getRequestURI() + " " + exchange.getResponseCode()); + exchange.close(); + } + } + + private static void route(HttpExchange exchange) throws Exception { + String method = exchange.getRequestMethod(); + String path = exchange.getRequestURI().getPath(); + + if (method.equals("GET") && path.equals("/health")) { + Map health = new LinkedHashMap<>(); + health.put("sdk", "flagsmith-java-client"); + health.put("version", Versions.getVersion()); + send(exchange, 200, health); + return; + } + + if (method.equals("POST") && path.equals("/clients")) { + Map body = body(exchange); + String clientId = String.valueOf(clientIds.incrementAndGet()); + clients.put(clientId, FlagsmithClient.newBuilder() + .setApiKey((String) body.get("environmentKey")) + .withApiUrl((String) body.get("apiUrl")) + .build()); + send(exchange, 201, Map.of("clientId", clientId)); + return; + } + + Matcher match = CLIENT_ROUTE.matcher(path); + FlagsmithClient client = match.matches() ? clients.get(match.group("clientId")) : null; + if (client == null) { + send(exchange, 404, error("No route for " + method + " " + path)); + return; + } + String action = match.group("action"); + + if (method.equals("DELETE") && action == null) { + clients.remove(match.group("clientId")).close(); + send(exchange, 204, null); + return; + } + + if (method.equals("POST") && "/environment-flags".equals(action)) { + send(exchange, 200, serialiseFlags(client.getEnvironmentFlags())); + return; + } + + if (method.equals("POST") && "/identity-flags".equals(action)) { + Map body = body(exchange); + @SuppressWarnings("unchecked") + Map traits = (Map) body.get("traits"); + Flags flags = client.getIdentityFlags( + (String) body.get("identifier"), traits != null ? traits : new HashMap<>()); + send(exchange, 200, serialiseFlags(flags)); + return; + } + + send(exchange, 404, error("No route for " + method + " " + path)); + } + + private static Map serialiseFlags(Flags flags) { + List> serialised = new ArrayList<>(); + for (BaseFlag flag : flags.getAllFlags()) { + Map entry = new LinkedHashMap<>(); + entry.put("featureName", flag.getFeatureName()); + entry.put("enabled", flag.getEnabled()); + // The SDK keeps API values as Jackson nodes, which serialise with their JSON type intact. + entry.put("value", flag.getValue()); + serialised.add(entry); + } + return Map.of("flags", serialised); + } + + private static Map error(String message) { + return Map.of("error", String.valueOf(message)); + } + + private static Map body(HttpExchange exchange) throws IOException { + byte[] bytes = exchange.getRequestBody().readAllBytes(); + return bytes.length > 0 ? MAPPER.readValue(bytes, JSON_OBJECT) : new HashMap<>(); + } + + private static void send(HttpExchange exchange, int status, Object body) throws IOException { + if (body == null) { + exchange.sendResponseHeaders(status, -1); + return; + } + byte[] payload = MAPPER.writeValueAsBytes(body); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, payload.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(payload); + } + } +} diff --git a/spec-adapter/Dockerfile b/spec-adapter/Dockerfile new file mode 100644 index 0000000..46e375e --- /dev/null +++ b/spec-adapter/Dockerfile @@ -0,0 +1,24 @@ +# Builds the SDK from source with the adapter for the Flagsmith SDK behavioural spec. +# Build from the repository root: docker build -f spec-adapter/Dockerfile . +FROM docker.io/library/maven:3.9-eclipse-temurin-11 AS build + +WORKDIR /sdk +COPY pom.xml lombok.config ./ +COPY src/main ./src/main +RUN mvn --batch-mode --no-transfer-progress \ + -Dmaven.test.skip=true -Dcheckstyle.skip=true -Djacoco.skip=true -Dmaven.source.skip=true \ + package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/lib \ + && cp target/flagsmith-java-client-*.jar target/lib/ + +COPY spec-adapter/Adapter.java ./spec-adapter/ +RUN javac -cp "target/lib/*" -d target/adapter spec-adapter/Adapter.java + +FROM docker.io/library/eclipse-temurin:11-jre + +WORKDIR /adapter +COPY --from=build /sdk/target/lib ./lib +COPY --from=build /sdk/target/adapter ./classes + +ENV PORT=8080 +EXPOSE 8080 +CMD ["java", "-cp", "classes:lib/*", "Adapter"] From 45ebb03eb4038731158428a3a6ec1f4503a7d82e Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 21 Oct 2026 13:07:14 +0200 Subject: [PATCH 2/5] ci: Implement the SDK spec as a steps file --- .github/workflows/sdk-spec.yml | 5 +- sdk-spec/Dockerfile | 24 ++++++ sdk-spec/Steps.java | 50 +++++++++++ spec-adapter/Adapter.java | 151 --------------------------------- spec-adapter/Dockerfile | 24 ------ 5 files changed, 77 insertions(+), 177 deletions(-) create mode 100644 sdk-spec/Dockerfile create mode 100644 sdk-spec/Steps.java delete mode 100644 spec-adapter/Adapter.java delete mode 100644 spec-adapter/Dockerfile diff --git a/.github/workflows/sdk-spec.yml b/.github/workflows/sdk-spec.yml index 6cbf98b..8aabf61 100644 --- a/.github/workflows/sdk-spec.yml +++ b/.github/workflows/sdk-spec.yml @@ -11,6 +11,7 @@ permissions: jobs: sdk-spec: # Keep the ref in `uses:` and `spec-ref` in sync. - uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.1.1 + uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@feat/v0.2.0 with: - spec-ref: v0.1.1 + spec-ref: feat/v0.2.0 + language: java diff --git a/sdk-spec/Dockerfile b/sdk-spec/Dockerfile new file mode 100644 index 0000000..4b5e7ed --- /dev/null +++ b/sdk-spec/Dockerfile @@ -0,0 +1,24 @@ +# Runs this SDK, built from source, under the Flagsmith SDK behavioural spec. +# docker build -f sdk-spec/Dockerfile --build-context harness=/harness/java . +FROM docker.io/library/maven:3.9-eclipse-temurin-11 AS build + +WORKDIR /sdk +COPY pom.xml lombok.config ./ +COPY src/main ./src/main +RUN mvn --batch-mode --no-transfer-progress \ + -Dmaven.test.skip=true -Dcheckstyle.skip=true -Djacoco.skip=true -Dmaven.source.skip=true \ + package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/lib \ + && cp target/flagsmith-java-client-*.jar target/lib/ + +COPY --from=harness Harness.java /harness/Harness.java +COPY sdk-spec/Steps.java ./sdk-spec/Steps.java +RUN javac -cp "target/lib/*" -d target/classes-spec /harness/Harness.java sdk-spec/Steps.java + +FROM docker.io/library/eclipse-temurin:11-jre + +WORKDIR /sdk +COPY --from=build /sdk/target/lib ./lib +COPY --from=build /sdk/target/classes-spec ./classes +COPY sdk-spec/Steps.java ./sdk-spec/Steps.java + +ENTRYPOINT ["java", "-XX:TieredStopAtLevel=1", "-cp", "classes:lib/*", "Harness", "sdk-spec/Steps.java"] diff --git a/sdk-spec/Steps.java b/sdk-spec/Steps.java new file mode 100644 index 0000000..4487c6e --- /dev/null +++ b/sdk-spec/Steps.java @@ -0,0 +1,50 @@ +import com.flagsmith.FlagsmithClient; +import com.flagsmith.exceptions.FlagsmithClientError; +import com.flagsmith.models.BaseFlag; +import com.flagsmith.models.Flags; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Steps for the Flagsmith SDK behavioural spec: https://github.com/Flagsmith/sdk-spec + * + *

Each method implements the Gherkin step in the comment above it. + */ +public class Steps { + + /** A flag in the shape the spec expects. */ + public static class SpecFlag { + public final String feature; + public final boolean enabled; + public final Object value; + + SpecFlag(BaseFlag flag) { + feature = flag.getFeatureName(); + enabled = flag.getEnabled(); + value = flag.getValue(); + } + } + + static List asSpecFlags(Flags flags) { + return flags.getAllFlags().stream().map(SpecFlag::new).collect(Collectors.toList()); + } + + // Given an SDK client for the environment + static FlagsmithClient createClient(String environmentKey, String apiUrl) { + return FlagsmithClient.newBuilder().setApiKey(environmentKey).withApiUrl(apiUrl).build(); + } + + // When the application gets the environment flags + static List getEnvironmentFlags(FlagsmithClient flagsmith) + throws FlagsmithClientError { + return asSpecFlags(flagsmith.getEnvironmentFlags()); + } + + // When the application gets the flags for identity "{identifier}" with traits: + static List getIdentityFlags( + FlagsmithClient flagsmith, String identifier, Map traits) + throws FlagsmithClientError { + return asSpecFlags(flagsmith.getIdentityFlags(identifier, traits)); + } +} diff --git a/spec-adapter/Adapter.java b/spec-adapter/Adapter.java deleted file mode 100644 index 58480b9..0000000 --- a/spec-adapter/Adapter.java +++ /dev/null @@ -1,151 +0,0 @@ -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.flagsmith.FlagsmithClient; -import com.flagsmith.Versions; -import com.flagsmith.models.BaseFlag; -import com.flagsmith.models.Flags; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpServer; -import java.io.IOException; -import java.io.OutputStream; -import java.net.InetSocketAddress; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicLong; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * Adapter that lets the Flagsmith SDK behavioural spec drive this SDK. - * - *

Protocol: https://github.com/Flagsmith/sdk-spec/blob/main/ADAPTER.md - */ -public class Adapter { - - private static final ObjectMapper MAPPER = new ObjectMapper(); - private static final TypeReference> JSON_OBJECT = - new TypeReference>() {}; - private static final Pattern CLIENT_ROUTE = - Pattern.compile("^/clients/(?[^/]+)(?/[a-z-]+)?$"); - - private static final Map clients = new ConcurrentHashMap<>(); - private static final AtomicLong clientIds = new AtomicLong(); - - public static void main(String[] args) throws IOException { - int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "8080")); - HttpServer server = HttpServer.create(new InetSocketAddress("0.0.0.0", port), 0); - server.createContext("/", Adapter::dispatch); - server.setExecutor(Executors.newCachedThreadPool()); - server.start(); - System.out.println("adapter: listening on :" + port); - } - - private static void dispatch(HttpExchange exchange) throws IOException { - try { - route(exchange); - } catch (Exception e) { - // Report SDK errors to the runner. - e.printStackTrace(); - send(exchange, 500, error(e.getClass().getSimpleName() + ": " + e.getMessage())); - } finally { - System.out.println("adapter: " + exchange.getRequestMethod() + " " - + exchange.getRequestURI() + " " + exchange.getResponseCode()); - exchange.close(); - } - } - - private static void route(HttpExchange exchange) throws Exception { - String method = exchange.getRequestMethod(); - String path = exchange.getRequestURI().getPath(); - - if (method.equals("GET") && path.equals("/health")) { - Map health = new LinkedHashMap<>(); - health.put("sdk", "flagsmith-java-client"); - health.put("version", Versions.getVersion()); - send(exchange, 200, health); - return; - } - - if (method.equals("POST") && path.equals("/clients")) { - Map body = body(exchange); - String clientId = String.valueOf(clientIds.incrementAndGet()); - clients.put(clientId, FlagsmithClient.newBuilder() - .setApiKey((String) body.get("environmentKey")) - .withApiUrl((String) body.get("apiUrl")) - .build()); - send(exchange, 201, Map.of("clientId", clientId)); - return; - } - - Matcher match = CLIENT_ROUTE.matcher(path); - FlagsmithClient client = match.matches() ? clients.get(match.group("clientId")) : null; - if (client == null) { - send(exchange, 404, error("No route for " + method + " " + path)); - return; - } - String action = match.group("action"); - - if (method.equals("DELETE") && action == null) { - clients.remove(match.group("clientId")).close(); - send(exchange, 204, null); - return; - } - - if (method.equals("POST") && "/environment-flags".equals(action)) { - send(exchange, 200, serialiseFlags(client.getEnvironmentFlags())); - return; - } - - if (method.equals("POST") && "/identity-flags".equals(action)) { - Map body = body(exchange); - @SuppressWarnings("unchecked") - Map traits = (Map) body.get("traits"); - Flags flags = client.getIdentityFlags( - (String) body.get("identifier"), traits != null ? traits : new HashMap<>()); - send(exchange, 200, serialiseFlags(flags)); - return; - } - - send(exchange, 404, error("No route for " + method + " " + path)); - } - - private static Map serialiseFlags(Flags flags) { - List> serialised = new ArrayList<>(); - for (BaseFlag flag : flags.getAllFlags()) { - Map entry = new LinkedHashMap<>(); - entry.put("featureName", flag.getFeatureName()); - entry.put("enabled", flag.getEnabled()); - // The SDK keeps API values as Jackson nodes, which serialise with their JSON type intact. - entry.put("value", flag.getValue()); - serialised.add(entry); - } - return Map.of("flags", serialised); - } - - private static Map error(String message) { - return Map.of("error", String.valueOf(message)); - } - - private static Map body(HttpExchange exchange) throws IOException { - byte[] bytes = exchange.getRequestBody().readAllBytes(); - return bytes.length > 0 ? MAPPER.readValue(bytes, JSON_OBJECT) : new HashMap<>(); - } - - private static void send(HttpExchange exchange, int status, Object body) throws IOException { - if (body == null) { - exchange.sendResponseHeaders(status, -1); - return; - } - byte[] payload = MAPPER.writeValueAsBytes(body); - exchange.getResponseHeaders().set("Content-Type", "application/json"); - exchange.sendResponseHeaders(status, payload.length); - try (OutputStream out = exchange.getResponseBody()) { - out.write(payload); - } - } -} diff --git a/spec-adapter/Dockerfile b/spec-adapter/Dockerfile deleted file mode 100644 index 46e375e..0000000 --- a/spec-adapter/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -# Builds the SDK from source with the adapter for the Flagsmith SDK behavioural spec. -# Build from the repository root: docker build -f spec-adapter/Dockerfile . -FROM docker.io/library/maven:3.9-eclipse-temurin-11 AS build - -WORKDIR /sdk -COPY pom.xml lombok.config ./ -COPY src/main ./src/main -RUN mvn --batch-mode --no-transfer-progress \ - -Dmaven.test.skip=true -Dcheckstyle.skip=true -Djacoco.skip=true -Dmaven.source.skip=true \ - package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/lib \ - && cp target/flagsmith-java-client-*.jar target/lib/ - -COPY spec-adapter/Adapter.java ./spec-adapter/ -RUN javac -cp "target/lib/*" -d target/adapter spec-adapter/Adapter.java - -FROM docker.io/library/eclipse-temurin:11-jre - -WORKDIR /adapter -COPY --from=build /sdk/target/lib ./lib -COPY --from=build /sdk/target/adapter ./classes - -ENV PORT=8080 -EXPOSE 8080 -CMD ["java", "-cp", "classes:lib/*", "Adapter"] From a9d9a74e1f6358550f217ae5a075383fef04cdb3 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 21 Oct 2026 13:34:32 +0200 Subject: [PATCH 3/5] ci: Pin the SDK spec to v0.2.0 --- .github/workflows/sdk-spec.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sdk-spec.yml b/.github/workflows/sdk-spec.yml index 8aabf61..c9330e3 100644 --- a/.github/workflows/sdk-spec.yml +++ b/.github/workflows/sdk-spec.yml @@ -11,7 +11,7 @@ permissions: jobs: sdk-spec: # Keep the ref in `uses:` and `spec-ref` in sync. - uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@feat/v0.2.0 + uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.2.0 with: - spec-ref: feat/v0.2.0 + spec-ref: v0.2.0 language: java From 651f94220a480493ae9c1ddca47fe9d2357d0863 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 21 Oct 2026 16:18:48 +0200 Subject: [PATCH 4/5] ci: Move to SDK spec v0.3.0 --- .github/workflows/sdk-spec.yml | 4 ++-- sdk-spec/Steps.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sdk-spec.yml b/.github/workflows/sdk-spec.yml index c9330e3..7ad90ac 100644 --- a/.github/workflows/sdk-spec.yml +++ b/.github/workflows/sdk-spec.yml @@ -11,7 +11,7 @@ permissions: jobs: sdk-spec: # Keep the ref in `uses:` and `spec-ref` in sync. - uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.2.0 + uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.3.0 with: - spec-ref: v0.2.0 + spec-ref: v0.3.0 language: java diff --git a/sdk-spec/Steps.java b/sdk-spec/Steps.java index 4487c6e..f5d80d4 100644 --- a/sdk-spec/Steps.java +++ b/sdk-spec/Steps.java @@ -30,7 +30,7 @@ static List asSpecFlags(Flags flags) { return flags.getAllFlags().stream().map(SpecFlag::new).collect(Collectors.toList()); } - // Given an SDK client for the environment + // Create the SDK client static FlagsmithClient createClient(String environmentKey, String apiUrl) { return FlagsmithClient.newBuilder().setApiKey(environmentKey).withApiUrl(apiUrl).build(); } From 42744cd22d0742ea006c7cd2dcb0d95e35a0106a Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 21 Oct 2026 16:38:05 +0200 Subject: [PATCH 5/5] ci: Run the SDK spec against the v0.3.0 tag