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:
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:
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:
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:
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")queriesandcommandshave one function per operation, named as in the schema. Arguments arrive as JSON, already checked against the schema,@rangeincluded. The helpers read them without checking again.- The
authorloader infieldsreceives 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
StockChangedevent 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:
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:
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:
./gradlew runOpen 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:
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:
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.contentwatch 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.
./gradlew runClientOn 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.