From 73759d7a93335d2a47a2f8d4471939b16a868683 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 21 Oct 2026 16:24:39 +0100 Subject: [PATCH] feat: Add a Kotlin harness --- .github/workflows/compatibility.yml | 5 ++ HARNESS.md | 1 + harness/kotlin/Harness.kt | 120 ++++++++++++++++++++++++++++ harness/kotlin/README.md | 75 +++++++++++++++++ 4 files changed, 201 insertions(+) create mode 100644 harness/kotlin/Harness.kt create mode 100644 harness/kotlin/README.md diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index abd69f1..c4a59e8 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -77,6 +77,11 @@ jobs: ref: feat/sdk-spec language: swift tags: '' + - name: android + repository: Flagsmith/flagsmith-kotlin-android-client + ref: feat/sdk-spec + language: kotlin + tags: '' uses: ./.github/workflows/run-spec.yml with: spec-repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} diff --git a/HARNESS.md b/HARNESS.md index 9ad4945..29a8c20 100644 --- a/HARNESS.md +++ b/HARNESS.md @@ -9,6 +9,7 @@ A harness runs an SDK's steps file for the spec runner. There is one per languag | Elixir | [`harness/elixir`](harness/elixir) | | Go | [`harness/go`](harness/go) | | Java | [`harness/java`](harness/java) | +| Kotlin | [`harness/kotlin`](harness/kotlin) | | Node.js | [`harness/nodejs`](harness/nodejs) | | PHP | [`harness/php`](harness/php) | | Python | [`harness/python`](harness/python) | diff --git a/harness/kotlin/Harness.kt b/harness/kotlin/Harness.kt new file mode 100644 index 0000000..6bb1559 --- /dev/null +++ b/harness/kotlin/Harness.kt @@ -0,0 +1,120 @@ +// Kotlin harness for the Flagsmith SDK behavioural spec. See HARNESS.md. +// +// Usage: java -cp Harness path/to/steps.kt +// +// Steps are top-level functions of the file facade class compiled from the steps file. The harness +// reads the steps source to map each step's text to a function name, then invokes the function by +// reflection. +@file:JvmName("Harness") + +import com.google.gson.GsonBuilder +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.google.gson.ToNumberPolicy +import java.io.File +import java.io.PrintStream +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method +import java.lang.reflect.Modifier +import kotlin.system.exitProcess + +private val MARKER = Regex("""^\s*//+\s*(When .+?|Create the SDK client)\s*$""") +private val FUNCTION = Regex("""^\s*(?:[\w@]+\s+)*fun\s+(\w+)\s*\(""") +private val PACKAGE = Regex("""^\s*package\s+([\w.]+)""") +private val JVM_NAME = Regex("""^\s*@file:JvmName\("(\w+)"\)""") + +// Integers stay integers (Long) when converted to `Any` parameters, rather than Gson's default Double. +private val json = GsonBuilder() + .serializeNulls() + .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) + .create() + +fun loadSteps(file: File): Map { + val lines = file.readLines() + val packageName = lines.firstNotNullOfOrNull { PACKAGE.find(it)?.groupValues?.get(1) } + val facadeName = lines.firstNotNullOfOrNull { JVM_NAME.find(it)?.groupValues?.get(1) } + ?: (file.nameWithoutExtension.replaceFirstChar(Char::uppercaseChar) + "Kt") + val stepsClass = Class.forName(if (packageName == null) facadeName else "$packageName.$facadeName") + + val steps = LinkedHashMap() + var pending: String? = null + for (line in lines) { + val marker = MARKER.matchEntire(line) + val function = FUNCTION.find(line) + if (marker != null) { + pending = marker.groupValues[1] + } else if (pending != null && function != null) { + steps[pending] = staticMethod(stepsClass, function.groupValues[1]) + pending = null + } + } + return steps +} + +private fun staticMethod(stepsClass: Class<*>, name: String): Method = + stepsClass.declaredMethods + .firstOrNull { it.name == name && Modifier.isStatic(it.modifiers) } + ?.apply { isAccessible = true } + ?: throw IllegalStateException("No top-level function $name in ${stepsClass.name}") + +/** The client (if any), then the request's args, converted to the step's parameter types. */ +private fun arguments(step: Method, request: JsonObject, clients: Map): Array { + val values = mutableListOf() + val client = request.get("client") + if (client != null && !client.isJsonNull) { + values.add(clients[client.asString]) + } + request.getAsJsonArray("args")?.forEach { values.add(it) } + + val types = step.genericParameterTypes + require(types.size == values.size) { + "${step.name} takes ${types.size} arguments, but was given ${values.size}" + } + return Array(types.size) { i -> + val value = values[i] + if (value is JsonElement) json.fromJson(value, types[i]) else value + } +} + +/** Answers the runner's requests, one JSON line each, until stdin closes. */ +fun main(args: Array) { + // Keep stdout for the protocol: anything else printed by the SDK or steps goes to stderr. + val out = PrintStream(System.out, false, Charsets.UTF_8.name()) + System.setOut(System.err) + + val steps = loadSteps(File(args[0])) + val clients = HashMap() + + for (line in System.`in`.bufferedReader(Charsets.UTF_8).lineSequence()) { + val request = JsonParser.parseString(line).asJsonObject + val response = JsonObject() + response.add("id", request.get("id")) + val step = request.get("step")?.takeUnless { it.isJsonNull }?.let { steps[it.asString] } + if (request.get("command")?.asString == "hello") { + response.add("steps", json.toJsonTree(steps.keys)) + } else if (step == null) { + response.addProperty("missing", true) + } else { + try { + val result = step.invoke(null, *arguments(step, request, clients)) + if (request.get("returnsClient")?.asBoolean == true) { + val handle = (clients.size + 1).toString() + clients[handle] = result + response.addProperty("client", handle) + } else { + response.add("result", json.toJsonTree(result)) + } + } catch (e: Exception) { + // Reported to the runner. + val error = if (e is InvocationTargetException) e.cause ?: e else e + error.printStackTrace() + response.addProperty("error", "${error.javaClass.simpleName}: ${error.message}") + } + } + out.println(json.toJson(response)) + out.flush() + } + // SDK HTTP clients may hold non-daemon threads that would otherwise keep the JVM alive. + exitProcess(0) +} diff --git a/harness/kotlin/README.md b/harness/kotlin/README.md new file mode 100644 index 0000000..91472aa --- /dev/null +++ b/harness/kotlin/README.md @@ -0,0 +1,75 @@ +# Kotlin harness + +Runs a Kotlin SDK's [steps file](../../README.md#onboarding-an-sdk) for the spec runner, speaking the +[harness protocol](../../HARNESS.md) over stdin and stdout. + +## Finding steps + +The steps file is `sdk-spec/steps.kt`. At startup, the harness reads that source, maps each +step-marker comment to the name of the top-level function declared below it, and invokes the +function by reflection on the compiled file facade class: `StepsKt`, or the `@file:JvmName` if +there is one, plus the `package` declaration if there is one. + +## Writing steps + +- Steps are top-level functions of any visibility, one per name (no overloads). +- Arguments are converted from JSON to the parameter types with Gson, so declare them as you + would use them: `String` for placeholders, `Map` for data tables. Integers arrive + as `Long` and decimals as `Double`. +- Return any value Gson can serialise. Flag values keep their JSON types, and nulls are kept. +- Steps are called synchronously: wait for asynchronous SDK calls before returning. +- Let SDK exceptions propagate. The harness reports them as `": "` and + prints the stack trace to stderr. +- Anything written to `System.out` is redirected to stderr, so the SDK may log freely. + +```kotlin +// When the application gets the environment flags +fun getEnvironmentFlags(flagsmith: Flagsmith) = + await { flagsmith.getFeatureFlags(result = it) }.asSpecFlags() +``` + +## Using it from `sdk-spec/Dockerfile` + +The harness needs Gson (`com.google.code.gson:gson` 2.10 or later) on the classpath. Compile +`Harness.kt` together with the steps file and the SDK built from source, for example with a Gradle +`application` build in `sdk-spec/` whose main class is `Harness`: + +```kotlin +sourceSets.main { + kotlin.srcDirs("../src/main/kotlin", providers.gradleProperty("harness")) +} + +tasks.compileKotlin { + source("steps.kt") +} + +application { + mainClass.set("Harness") +} +``` + +```dockerfile +COPY --from=harness Harness.kt /harness/Harness.kt +RUN gradle --no-daemon -p sdk-spec installDist -Pharness=/harness +``` + +The harness reads the steps source at runtime, so ship `sdk-spec/steps.kt` in the final image too: + +```dockerfile +ENTRYPOINT ["app/bin/sdk-spec", "sdk-spec/steps.kt"] +``` + +See `flagsmith-kotlin-android-client` for a complete example. + +## Running locally + +```sh +docker build -t sdk-spec-sdk -f sdk-spec/Dockerfile --build-context harness=/harness/kotlin . +echo '{"id": 1, "command": "hello"}' | docker run -i --rm sdk-spec-sdk +``` + +Or without Docker, after building as above: + +```sh +sdk-spec/build/install/sdk-spec/bin/sdk-spec sdk-spec/steps.kt +```