Skip to content

Get started with Kotlin ​

A bookshop server with four operations, and a client that reads a book, watches it and buys a copy. The client library also runs on Android. The finished project is examples/kotlin. You need JDK 21.

1. Create the project ​

A Kotlin JVM project with Gradle's application plugin, the Rayfold server and client, Nimbus to verify tokens, and rayfold-test for the unit tests. build.gradle.kts, with a runClient task for step 6 and a token task for step 5:

kotlin
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
    kotlin("jvm") version "2.4.20"
    application
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("dev.rayfold:rayfold-core:0.2.3")
    implementation("dev.rayfold:rayfold-client:0.2.3")
    // verifies the tokens your identity provider signs
    implementation("com.nimbusds:nimbus-jose-jwt:10.9.1")

    // calls the server from a unit test, with no network between them
    testImplementation("dev.rayfold:rayfold-test:0.2.3")
    testImplementation(kotlin("test"))
    testImplementation("org.junit.jupiter:junit-jupiter:6.1.3")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

java {
    sourceCompatibility = JavaVersion.VERSION_21
    targetCompatibility = JavaVersion.VERSION_21
}

kotlin {
    compilerOptions {
        jvmTarget = JvmTarget.JVM_21
    }
}

application {
    mainClass = "com.example.bookshop.ServerKt"
}

tasks.register<JavaExec>("runClient") {
    group = "application"
    description = "Runs the client app against the server that `run` started."
    classpath = sourceSets.main.get().runtimeClasspath
    mainClass = "com.example.bookshop.ClientKt"
}

tasks.register<JavaExec>("token") {
    group = "application"
    description = "Prints a development token: --args=staff for a member of staff, a customer otherwise."
    classpath = sourceSets.main.get().runtimeClasspath
    mainClass = "com.example.bookshop.TokenKt"
}

tasks.test {
    useJUnitPlatform()
}

Since 0.2.2

rayfold-test came with 0.2.2. On 0.2.1, leave out its testImplementation line.

2. Describe the API ​

Save the schema as src/main/resources/bookshop.rayfold, so it ships inside the jar:

rayfold
entity Author {
  id: ID
  name: String
}

entity Book @cache(maxAge: 60s, scope: public) {
  id: ID
  title: String
  stock: Int
  author: Author
  """What the shop paid. Only staff can see it."""
  costPrice: Decimal? @allow(read: viewer.role == "staff")
}

error OutOfStock { bookId: ID, available: Int }

event StockChanged { bookId: ID, stock: Int }

query book(id: ID): Book?
query books(page: PageArgs = { first: 20 }): Page<Book>

"""Take copies off the shelf. Fails if there are not enough."""
command buy(bookId: ID, qty: Int = 1 @range(min: 1, max: 10)): Book
  throws OutOfStock
  emits StockChanged
  @allow(write: viewer != null)

command restock(bookId: ID, qty: Int @range(min: 1, max: 1000)): Book
  emits StockChanged
  @allow(write: viewer.role == "staff")

The server reads this file when it starts and checks every request against it. npx @rayfold/cli check validates it from the command line, and npx @rayfold/cli gen kotlin generates data classes from it if you want them.

3. Write the resolvers ​

The books live in memory, in Store.kt:

kotlin
package com.example.bookshop

import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger

data class Author(val id: String, val name: String)

data class Book(val id: String, val title: String, val stock: Int, val authorId: String, val costPrice: BigDecimal)

/** The shop's shelves, in memory. Each server gets a fresh one. */
class Store {
    private val authors = listOf(
        Author("a1", "Ursula K. Le Guin"),
        Author("a2", "Frank Herbert"),
    ).associateBy { it.id }

    private val books = ConcurrentHashMap(
        listOf(
            Book("b1", "A Wizard of Earthsea", stock = 3, authorId = "a1", costPrice = BigDecimal("4.20")),
            Book("b2", "The Left Hand of Darkness", stock = 0, authorId = "a1", costPrice = BigDecimal("5.10")),
            Book("b3", "Dune", stock = 7, authorId = "a2", costPrice = BigDecimal("6.00")),
        ).associateBy { it.id },
    )

    /** How many times authors were looked up, to see batching at work. */
    val authorLookups = AtomicInteger()

    fun book(id: String): Book? = books[id]

    fun books(): List<Book> = books.values.sortedBy { it.id }

    /** Many authors in one lookup, the way one `WHERE id IN (...)` query would fetch them. */
    fun authors(ids: List<String>): List<Author?> {
        authorLookups.incrementAndGet()
        return ids.map { authors[it] }
    }

    /**
     * Replaces a book with what [change] makes of it, atomically, and returns the new copy; null when there is no such
     * book. An exception thrown by [change] leaves the book as it was.
     */
    fun update(id: String, change: (Book) -> Book): Book? = books.computeIfPresent(id) { _, book -> change(book) }
}

Server.kt starts the server and holds one resolver per operation, with the small helpers they share at the bottom:

kotlin
package com.example.bookshop

import com.sun.net.httpserver.HttpServer
import dev.rayfold.core.Code
import dev.rayfold.core.CommandResult
import dev.rayfold.core.HttpOptions
import dev.rayfold.core.RayfoldException
import dev.rayfold.core.RayfoldHttp
import dev.rayfold.core.RayfoldServer
import dev.rayfold.core.Resolvers
import dev.rayfold.core.SchemaText
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.int
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put

fun main() {
    startServer(port = 4000)
    println("Bookshop API at http://localhost:4000/rayfold, explorer at http://localhost:4000/rayfold/explorer")
}

fun startServer(port: Int, store: Store = Store()): HttpServer {
    val schema = SchemaText.load(resourceText("/bookshop.rayfold")).ir
    val server = RayfoldServer(schema, resolvers(store))
    val options = HttpOptions(explorer = true, explorerTitle = "Bookshop")
    return RayfoldHttp(server, options, ::viewerOf).start(port)
}

fun resolvers(store: Store) = Resolvers(
    queries = mapOf(
        "book" to { args, _ -> store.book(args.string("id"))?.toJson() },
        "books" to { args, _ -> page(store.books(), args.getValue("page").jsonObject) },
    ),
    commands = mapOf(
        "buy" to { args, _ ->
            val bookId = args.string("bookId")
            val qty = args.int("qty")
            val book = store.update(bookId) { book ->
                if (qty > book.stock) {
                    throw RayfoldException.domain(
                        "OutOfStock",
                        buildJsonObject {
                            put("bookId", book.id)
                            put("available", book.stock)
                        },
                        "Only ${book.stock} left of ${book.title}",
                    )
                }
                book.copy(stock = book.stock - qty)
            } ?: notFound(bookId)
            stockChanged(book)
        },
        "restock" to { args, _ ->
            val bookId = args.string("bookId")
            val qty = args.int("qty")
            val book = store.update(bookId) { it.copy(stock = it.stock + qty) } ?: notFound(bookId)
            stockChanged(book)
        },
    ),
    fields = mapOf(
        "Book" to mapOf(
            // called once per level with every book in the result, and answered with one lookup
            "author" to { books, _, _ -> store.authors(books.map { it.string("authorId") }).map { it?.toJson() } },
        ),
    ),
)

/** The changed book as the command's result, plus the event the schema says the command emits. */
private fun stockChanged(book: Book) = CommandResult(
    book.toJson(),
    emit = listOf("StockChanged" to buildJsonObject { put("bookId", book.id); put("stock", book.stock) }),
)

/** One page of books sorted by id, so a cursor is the id of the last book on the page before. */
private fun page(books: List<Book>, page: JsonObject): JsonObject {
    val first = page.int("first")
    val after = page["after"]?.jsonPrimitive?.contentOrNull
    val start = after?.let { cursor -> books.count { it.id <= cursor } }
        ?: page["offset"]?.jsonPrimitive?.intOrNull
        ?: 0
    val items = books.drop(start).take(first)
    return buildJsonObject {
        put("items", JsonArray(items.map { it.toJson() }))
        put("cursor", items.lastOrNull()?.id)
        put("hasMore", start + items.size < books.size)
        put("total", books.size)
    }
}

private fun Book.toJson() = buildJsonObject {
    put("id", id)
    put("title", title)
    put("stock", stock)
    put("authorId", authorId)
    // Decimal travels as text, so the amount stays exact
    put("costPrice", costPrice.toPlainString())
}

private fun Author.toJson() = buildJsonObject {
    put("id", id)
    put("name", name)
}

private fun notFound(bookId: String): Nothing = throw RayfoldException(Code.NOT_FOUND, "No book $bookId")

// the runtime has checked the arguments against the schema before a resolver sees them
private fun JsonObject.string(name: String): String = getValue(name).jsonPrimitive.content

private fun JsonObject.int(name: String): Int = getValue(name).jsonPrimitive.int

private fun resourceText(path: String): String =
    Store::class.java.getResource(path)?.readText() ?: error("$path is missing from the classpath")
  • queries and commands have one function per operation, named as in the schema. Arguments arrive as JSON, already checked against the schema, @range included. The helpers read them without checking again.
  • The author loader in fields receives every book in the result at once and returns their authors in the same order. A page of 50 books looks up authors once.
  • RayfoldException.domain("OutOfStock", ...) is the error the schema declared. Clients receive it by name, with its data.
  • A command returns the book it changed and the StockChanged event the schema says it emits. The book becomes a patch for every client cache.

4. Say who is calling ​

Your identity provider signs the user in and gives the client an access token (a JWT). The server verifies it with Nimbus JOSE + JWT against the provider's published keys, and turns its claims into the viewer:

kotlin
package com.example.bookshop

import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.JWSHeader
import com.nimbusds.jose.crypto.MACSigner
import com.nimbusds.jose.jwk.source.ImmutableSecret
import com.nimbusds.jose.jwk.source.JWKSourceBuilder
import com.nimbusds.jose.proc.JWSVerificationKeySelector
import com.nimbusds.jose.proc.SecurityContext
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.SignedJWT
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier
import com.nimbusds.jwt.proc.DefaultJWTProcessor
import com.sun.net.httpserver.HttpExchange
import dev.rayfold.core.Code
import dev.rayfold.core.RayfoldException
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.net.URI
import java.util.Date

/** Signs development tokens when no identity provider is configured. Never set in production. */
private val DEV_SECRET = "bookshop development key, not a secret".toByteArray()
private const val DEV_ISSUER = "http://localhost:4000/dev"

// In production, AUTH_JWKS_URL and AUTH_ISSUER name your identity provider (Auth0, Entra ID, Keycloak, Cognito, ...).
// Nimbus fetches its published signing keys, caches them and follows its key rotation.
private val jwks: String? = System.getenv("AUTH_JWKS_URL")

private val tokens = DefaultJWTProcessor<SecurityContext>().apply {
    jwsKeySelector = if (jwks != null) {
        JWSVerificationKeySelector(JWSAlgorithm.RS256, JWKSourceBuilder.create<SecurityContext>(URI(jwks).toURL()).build())
    } else {
        JWSVerificationKeySelector(JWSAlgorithm.HS256, ImmutableSecret(DEV_SECRET))
    }
    val issuer = if (jwks != null) System.getenv("AUTH_ISSUER") ?: error("AUTH_ISSUER must be set with AUTH_JWKS_URL") else DEV_ISSUER
    // the audience, the issuer, a subject and an expiry are required; the expiry is checked against the clock
    jwtClaimsSetVerifier = DefaultJWTClaimsVerifier(setOf("bookshop"), JWTClaimsSet.Builder().issuer(issuer).build(), setOf("sub", "exp"), null)
}

/** The viewer the schema's policies see as `viewer`, or null for an anonymous request. */
fun viewerOf(exchange: HttpExchange): JsonElement {
    val authorization = exchange.requestHeaders.getFirst("Authorization") ?: return JsonNull
    val token = Regex("""^Bearer (\S+)$""").find(authorization)?.groupValues?.get(1)
        ?: throw RayfoldException(Code.UNAUTHENTICATED, "Expected Authorization: Bearer <token>")
    val claims = try {
        tokens.process(token, null) // checks the signature and the claims above; nothing is read before both pass
    } catch (e: Exception) {
        throw RayfoldException(Code.UNAUTHENTICATED, "Invalid or expired token")
    }
    // `role` is the claim this provider carries roles in; the schema's rules compare against what it maps to
    return buildJsonObject {
        put("id", claims.subject)
        put("role", if (claims.getStringClaim("role") == "staff") "staff" else "customer")
    }
}

/** A token as the identity provider would issue it, signed with the development key: for local runs and tests. */
fun devToken(subject: String, role: String): String {
    val now = Date()
    val claims = JWTClaimsSet.Builder().subject(subject).issuer(DEV_ISSUER).audience("bookshop").claim("role", role)
        .issueTime(now).expirationTime(Date(now.time + 8 * 3_600_000L)).build()
    return SignedJWT(JWSHeader(JWSAlgorithm.HS256), claims).apply { sign(MACSigner(DEV_SECRET)) }.serialize()
}

What viewerOf returns is viewer in the schema's @allow rules. The resolvers never check permissions themselves. Until you have a provider, the server signs and checks tokens with the development key at the top of the file. devToken makes a token as a provider would, and Token.kt prints one:

kotlin
package com.example.bookshop

/** Prints a token signed with the development key, as an identity provider would issue one: `./gradlew -q token --args=staff` */
fun main(args: Array<String>) {
    val role = if (args.firstOrNull() == "staff") "staff" else "customer"
    println(devToken(if (role == "staff") "s1" else "u1", role))
}

5. Start the server ​

main and startServer, at the top of Server.kt, serve the endpoint on port 4000 with the explorer beside it:

sh
./gradlew run

Open http://localhost:4000/rayfold/explorer to browse the operations and send requests. To try the commands, paste a token into the auth field. ./gradlew -q token prints a customer's token, and ./gradlew -q token --args=staff prints a staff member's. Or call it with curl:

sh
curl -s localhost:4000/rayfold -H 'content-type: application/rayfold+json' -H 'rayfold-safe: true' \
  -d '{"rayfold":"0.1","ops":[{"id":1,"op":"book","args":{"id":"b1"},"shape":"{ title stock author { name } }"}]}'

6. Call it from Kotlin ​

Client.kt:

kotlin
package com.example.bookshop

import dev.rayfold.client.HttpTransport
import dev.rayfold.client.RayfoldClient
import dev.rayfold.client.args
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive

fun main() = runBlocking {
    // the access token the user's sign-in produced; a local run signs a customer's with the development key
    val accessToken = System.getenv("TOKEN") ?: devToken("u1", "customer")
    val client = RayfoldClient(
        HttpTransport("http://localhost:4000/rayfold", headers = { mapOf("Authorization" to "Bearer $accessToken") }),
    )
    buyOneCopy(client, ::println)
}

suspend fun buyOneCopy(client: RayfoldClient, say: (String) -> Unit) = coroutineScope {
    val book = client.query("book", args("id" to "b1"), shape = "{ title stock author { name } }").jsonObject
    say("${book.text("title")} by ${book.getValue("author").jsonObject.text("name")}: ${book.text("stock")} in stock")

    // the book as the client's cache holds it: now, and again whenever the cache changes it
    val stock = Channel<String>(Channel.UNLIMITED)
    val watch = launch {
        client.watch("book", args("id" to "b1"), shape = "{ id stock }").collect { stock.send(it.jsonObject.text("stock")) }
    }
    say("watching: ${stock.receive()} in stock")

    client.command("buy", args("bookId" to "b1", "qty" to 1), shape = "{ id stock }")
    // no second read: the purchase came back with a patch for Book:b1, and the watch saw the cache apply it
    say("after buying one: ${stock.receive()} in stock")
    watch.cancel()
}

private fun JsonObject.text(name: String): String = getValue(name).jsonPrimitive.content

watch is a flow of the book as the client's cache holds it. The purchase comes back with a patch for Book:b1, the cache applies it, and the flow emits the new stock without a second request.

sh
./gradlew runClient

On Android, the same client works with an OkHttp transport; see Kotlin and Android.

Next ​

  • A web UI for the same server: React. Any Rayfold client works with any Rayfold server.
  • How shapes, loaders and pages work: Queries and shapes.
  • More of the Kotlin API, including live queries and WebSocket: the Kotlin guide.

Released under the Apache-2.0 license.