Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/compatibility.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
1 change: 1 addition & 0 deletions HARNESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
120 changes: 120 additions & 0 deletions harness/kotlin/Harness.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Kotlin harness for the Flagsmith SDK behavioural spec. See HARNESS.md.
//
// Usage: java -cp <harness, steps and SDK classes> 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<String, Method> {
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<String, Method>()
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<String, Any?>): Array<Any?> {
val values = mutableListOf<Any?>()
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<Any?>(value, types[i]) else value
}
}

/** Answers the runner's requests, one JSON line each, until stdin closes. */
fun main(args: Array<String>) {
// 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<String, Any?>()

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)
}
75 changes: 75 additions & 0 deletions harness/kotlin/README.md
Original file line number Diff line number Diff line change
@@ -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<String, Any>` 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 `"<SimpleClassName>: <message>"` 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=<path to sdk-spec>/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
```
Loading