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. Add the Rayfold server and client:

kotlin
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:

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 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

kotlin
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() } },
        ),
    ),
)
  • queries and commands have one function per operation, named as in the schema. Arguments arrive as JSON already checked against the schema, @range included, which is why the helpers can read them without checks.
  • 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, which becomes a patch for every client cache, and the StockChanged event the schema says it emits.

4. Say who is calling

kotlin
// 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")] ?: JsonNull

What this returns is viewer in the schema's @allow rules. The resolvers never check permissions themselves.

5. Start the server

kotlin
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)
}
sh
./gradlew run

Open 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:

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

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.

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.