Skip to content

Kotlin and Android ​

ArtifactFor
dev.rayfold:rayfold-coreA Rayfold server on the JVM (Java 21). It reads .rayfold schema files itself, with no Node.js step.
dev.rayfold:rayfold-clientA client for Kotlin on the JVM and for Android (Java 17 bytecode, no server code).
dev.rayfold:rayfold-client-okhttpThe WebSocket transport on OkHttp, for live queries on Android.
dev.rayfold:rayfold-opentelemetryOpenTelemetry tracing for the server (Tracing).
dev.rayfold:rayfold-jdbcA SQL database behind resolvers, with read policies pushed into the query (JDBC).
dev.rayfold:rayfold-testUnit tests for your server without a network: a blocking caller per viewer, and the client against the server in one process. Test scope.

Since 0.2.2

rayfold-test, the server's now clock, and live(..., onError) reopening a dropped live query came with 0.2.2.

kotlin
dependencies {
    implementation("dev.rayfold:rayfold-core:0.2.3")    // the server
    implementation("dev.rayfold:rayfold-client:0.2.3")  // the client
}

A server ​

kotlin
import dev.rayfold.core.*
import kotlinx.serialization.json.*
import java.io.File

val books = mutableMapOf("b1" to buildJsonObject { put("id", "b1"); put("title", "Dune"); put("stock", 3); put("authorId", "a1") })
val authors = mapOf("a1" to buildJsonObject { put("id", "a1"); put("name", "Frank Herbert") })

val server = RayfoldServer(
    SchemaText.load(File("schema.rayfold").readText()).ir,
    Resolvers(
        queries = mapOf("book" to { args, _ -> books[args["id"]?.jsonPrimitive?.content] }),
        commands = mapOf("restock" to { args, ctx -> restock(args, ctx) }),
        // batch loader: one call per level, one value per parent, in order
        fields = mapOf("Book" to mapOf("author" to { parents, _, _ -> parents.map { authors[it["authorId"]?.jsonPrimitive?.content] } })),
    ),
)

// HTTP on the JDK's own server, JSON or the binary RB encoding, live queries included; the viewer comes from your authentication
RayfoldHttp(server, HttpOptions(allowedOrigins = setOf("https://app.example"))) { exchange ->
    userOf(exchange.requestHeaders.getFirst("Authorization"))?.let { buildJsonObject { put("id", it.id); put("role", it.role) } } ?: JsonNull
}.start(8080, host = "0.0.0.0") // start(8080) alone listens on loopback only

// live queries and many batches over one socket: ws://host:8081/rayfold/ws
RayfoldWebSocket(server) { request -> userOf(request.header("authorization"))?.let { buildJsonObject { put("id", it.id) } } ?: JsonNull }.start(8081, host = "0.0.0.0")

Resolvers are suspend functions over kotlinx.serialization JSON. A command returns the changed entity, or CommandResult(result, patch, emit) for extra cache patches and events. Throw RayfoldException.domain("OutOfStock", data, message) for an error the operation declares with throws. RayfoldServer(..., now = { epochMillis }) sets the clock the server tells the time by. It drives now() in a policy, ctx.now() in a resolver, the expiry of the default idempotency store's records, and the server's uptime. It is the system clock unless a test hands in its own.

allowedOrigins also answers a browser's CORS preflight from those origins with 204 and the Access-Control-Allow-* headers, and adds them to the responses that follow; other origins get none.

If you prefer plain Java types and functional interfaces, use rayfold-java (Java guide).

HttpOptions(explorer = true) serves the explorer at /rayfold/explorer, the same page @rayfold/explorer serves. RayfoldExplorer(endpoint, title).mount(http) adds it to any JDK server, and its html is the page for a server of another kind. It is off by default.

Any JVM server ​

The built-in JDK server above and the Spring Boot starter both host the same engine. They meet at HttpCall: implement it over whatever your server calls a request and a response, and RayfoldHttp.serve(call, base, viewer) handles batches, live queries, the manifest, caching, health and readiness.

Here is a sketch for Ktor; the Spring Boot starter's ServletCall is a complete one. serve blocks while it reads and writes, so on Ktor call it inside withContext(Dispatchers.IO):

kotlin
class KtorCall(private val call: ApplicationCall) : HttpCall {
    override val method get() = call.request.httpMethod.value
    override val path get() = call.request.path()
    override val rawQuery get() = call.request.queryString().ifEmpty { null }
    override fun header(name: String) = call.request.header(name)
    override val body: InputStream get() = runBlocking { call.receiveStream() }
    override val secure get() = call.request.origin.scheme == "https"
    override val localAddress: InetAddress? = null
    override fun setHeader(name: String, value: String) = call.response.header(name, value)
    override fun respond(status: Int, length: Long): OutputStream = TODO("your server's output stream")
    override fun abort() { /* end the exchange */ }
}

Two members need care. secure and localAddress feed the Origin and loopback-Host checks. A host that cannot answer them truthfully weakens those rules; it does not break them. Over TLS, or behind a proxy that sends X-Forwarded-Proto: https, a page served over plain http is not the server's own origin. A secure that answers false behind a TLS-terminating proxy still lets the server's own https pages write. respond(status, length) takes -1 for length when the body streams, so a live query or a stream stays open instead of being buffered.

The repository has two worked implementations to copy: JdkCall for the JDK HTTP server, and ServletCall in the Spring starter.

A client ​

kotlin
import dev.rayfold.client.*

val client = RayfoldClient(HttpTransport("https://api.example/rayfold", headers = { mapOf("Authorization" to "Bearer ${token()}") }))

val book = client.query("book", args("id" to "b1"), shape = "{ id title stock }")
client.command("restock", args("id" to "b1", "qty" to 5))

Everything the client reads goes into a normalized cache, and commands' patches keep it current:

kotlin
// the result now, then again whenever this book changes in the cache, with no refetch
client.watch("book", args("id" to "b1"), "{ id title stock }").collect { render(it) }

// pushed by the server whenever anyone changes it, over HTTP or WebSocket. A dropped connection or a server that is
// draining reopens it by itself; onError hears each failure, and one that would recur ends the flow with it
client.live("book", args("id" to "b1"), "{ id stock }", onError = { e, retrying -> log(e, retrying) }).collect { render(it) }

// stream ops
client.stream("ticks", args("n" to 10)).collect { println(it) }

Batches with references run in one round trip:

kotlin
val batch = client.batch()
val order = batch.command("placeOrder", args("input" to mapOf("lines" to listOf(mapOf("bookId" to "b1", "qty" to 1)))), "{ id }")
val shown = batch.query("order", args("id" to order.ref("id")), "{ id total }")
batch.run()
println(shown.await())

Errors the server reports arrive as RayfoldClientException with code, type and data:

kotlin
try { client.command("buy", args("id" to "b2", "qty" to 9)) }
catch (e: RayfoldClientException) { if (e.isType("OutOfStock")) showOnlyLeft(e.data) }

In a test, LocalTransport(server) { viewer } from rayfold-test is a transport over a RayfoldServer in the same process, so the client runs with no network (Testing).

Typed results ​

Generate data classes from the schema and decode into them:

sh
npx @rayfold/cli gen kotlin schema.rayfold --package com.example.api --out app/src/main/kotlin/com/example/api/Schema.kt
kotlin
val book: Book = client.queryAs("book", args("id" to "b1"), shape = "{ id title stock }")
client.watchAs<Book>("book", args("id" to "b1")).collect { render(it) }

Android ​

  • HttpTransport sends with java.net.http on the JVM and with HttpURLConnection on Android, which lacks java.net.http. Add <uses-permission android:name="android.permission.INTERNET" />.
  • Collect flows in a lifecycle-aware scope. In Compose: val book by client.watchAs<Book>("book", args("id" to id)).collectAsState(initial = null).
  • For a WebSocket on Android, use OkHttpWebSocketTransport("wss://api.example/rayfold/ws", headers) from rayfold-client-okhttp; JdkWebSocketTransport needs java.net.http, which Android lacks. Pass your app's OkHttpClient to share its pool, TLS settings and interceptors.
  • The build checks that rayfold-client and rayfold-client-okhttp call only APIs Android 8.0 (API level 26) has.
  • Commands can show their effect before the server answers, and wait offline in a queue that survives the app being killed: see Offline and optimistic updates.

Released under the Apache-2.0 license.