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. Add the Rayfold server and client:
dependencies {
implementation("dev.rayfold:rayfold-core:0.1.0")
implementation("dev.rayfold:rayfold-client:0.1.0")
testImplementation(kotlin("test"))
testImplementation("org.junit.jupiter:junit-jupiter:6.1.3")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}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 check validates it from the command line, and npx rayfold gen kotlin generates data classes from it if you want them.
3. Write the resolvers
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() } },
),
),
)queriesandcommandshave one function per operation, named as in the schema. Arguments arrive as JSON already checked against the schema,@rangeincluded, which is why the helpers can read them without checks.- 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, which becomes a patch for every client cache, and the
StockChangedevent the schema says it emits.
4. Say who is calling
// Two fixed tokens stand in for real authentication.
private val demoViewers = mapOf(
"Bearer customer" to buildJsonObject { put("id", "u1"); put("role", "customer") },
"Bearer staff" to buildJsonObject { put("id", "s1"); put("role", "staff") },
)
/** The viewer the schema's policies see as `viewer`, or null for an anonymous request. */
fun viewerOf(exchange: HttpExchange): JsonElement =
demoViewers[exchange.requestHeaders.getFirst("Authorization")] ?: JsonNullWhat this returns is viewer in the schema's @allow rules. The resolvers never check permissions themselves.
5. Start the server
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)
}./gradlew runOpen http://localhost:4000/rayfold/explorer to browse the operations and send requests; paste Bearer customer or Bearer staff as the token to try the commands. 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
fun main() = runBlocking {
val client = RayfoldClient(
HttpTransport("http://localhost:4000/rayfold", headers = { mapOf("Authorization" to "Bearer customer") }),
)
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()
}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.
./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.