Home/Tutorials/Build a REST API

Build a REST API
from scratch.

Start with an empty project and finish with a running, tested REST API: two entities, their relationship loaded in a single query, CRUD routes over Ktor, and a test that asserts the exact SQL. About twenty minutes end to end.

KotlinKtor 3~20 min
New to Storm? The five-minute quickstart covers just install, entity, and query. This tutorial builds the whole app around them.

1Create the project

Start a plain Kotlin/Gradle project and add Ktor and Storm. The Ktor plugin manages the Ktor artifact versions, and the Storm BOM manages Storm's, so most dependencies need no version. We use H2 so there is nothing to install.

build.gradle.kts Gradle · Kotlin DSL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// build.gradle.kts
plugins {
    kotlin("jvm") version "2.0.21"
    id("io.ktor.plugin") version "3.0.3"
    id("com.google.devtools.ksp") version "2.0.21-1.0.28"
}

dependencies {
    implementation(platform("st.orm:storm-bom:1.12.0"))
    implementation("st.orm:storm-kotlin")
    implementation("st.orm:storm-ktor")
    runtimeOnly("st.orm:storm-core")
    runtimeOnly("st.orm:storm-h2")
    runtimeOnly("com.h2database:h2:2.2.224")
    ksp("st.orm:storm-metamodel-ksp")
    kotlinCompilerPluginClasspath("st.orm:storm-compiler-plugin-2.0")

    implementation("io.ktor:ktor-server-netty")
    implementation("io.ktor:ktor-server-content-negotiation")
    implementation("io.ktor:ktor-serialization-jackson")
    implementation("st.orm:storm-jackson2")

    testImplementation("st.orm:storm-ktor-test")
    testImplementation("com.h2database:h2")
}

2Create the schema

Storm maps to an existing schema rather than generating one, which keeps migrations under your control. For this tutorial a small script is enough; H2 runs it on startup, so there is no migration tool to set up yet.

schema.sql SQL
1
2
3
4
5
6
7
8
9
10
11
12
13
-- src/main/resources/schema.sql
CREATE TABLE folder (
    id    INT PRIMARY KEY AUTO_INCREMENT,
    name  VARCHAR(100) NOT NULL
);
CREATE TABLE bookmark (
    id         INT PRIMARY KEY AUTO_INCREMENT,
    url        VARCHAR(2000) NOT NULL,
    title      VARCHAR(200) NOT NULL,
    folder_id  INT NOT NULL REFERENCES folder(id)
);
-- one folder to start with
INSERT INTO folder (name) VALUES ('Reading');

3Model the domain

Two immutable data classes. A Bookmark belongs to a Folder; marking that reference @FK is the whole relationship. Field names map to columns automatically, so folder becomes the folder_id column.

model.kt Kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
// Folder.kt
data class Folder(
    @PK val id: Int = 0,
    val name: String,
) : Entity<Int>

// Bookmark.kt — @FK means the folder is loaded in the same query
data class Bookmark(
    @PK val id: Int = 0,
    val url: String,
    val title: String,
    @FK val folder: Folder,
) : Entity<Int>

4Point Storm at the database

The Storm Ktor plugin reads its DataSource from application.conf. No wiring code: install(Storm) builds a connection pool and, because the metamodel processor already indexed them, registers your repositories.

application.conf HOCON
1
2
3
4
5
6
7
8
9
# src/main/resources/application.conf
storm {
    datasource {
        jdbcUrl = "jdbc:h2:mem:bookmarks;DB_CLOSE_DELAY=-1;INIT=RUNSCRIPT FROM 'classpath:schema.sql'"
        username = "sa"
        password = ""
    }
    validation { schemaMode = "warn" }   # log any entity/schema mismatch
}

5Add a repository

Extend EntityRepository and every CRUD method comes for free. Add your own one-line queries on top; Bookmark_ is the compile-time metamodel, so a typo in a field name fails to compile.

BookmarkRepository.kt Kotlin
1
2
3
4
5
// BookmarkRepository.kt — CRUD is inherited; add only your own queries
interface BookmarkRepository : EntityRepository<Bookmark, Int> {
    fun findByFolderName(name: String): List<Bookmark> =
        findAll(Bookmark_.folder.name eq name)
}

6Wire up the application

A standard Ktor main and module. Install Storm, register Storm's Jackson module so entities serialize cleanly, and mount the routes.

Application.kt Kotlin
1
2
3
4
5
6
7
8
9
10
11
12
// Application.kt
fun main() {
    embeddedServer(Netty, port = 8080) { module() }.start(wait = true)
}

fun Application.module() {
    install(Storm)                     // reads storm.datasource; auto-registers repositories
    install(ContentNegotiation) {
        jackson { registerModule(StormModule()) }
    }
    routing { bookmarkRoutes() }
}

7Write the routes

Read straight from the ORM, write inside transaction { }. Because Ktor and Storm are both coroutine-based, the transaction rides the request's coroutine with no proxies or annotations. call.orm and repository<T>() are extensions available right in the handler.

Routes.kt Kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Routes.kt
@JsonIgnoreProperties(ignoreUnknown = true)
data class NewBookmark(val url: String, val title: String, val folderId: Int)

fun Route.bookmarkRoutes() {
    get("/bookmarks") {
        call.respond(repository<BookmarkRepository>().findAll())
    }

    get("/bookmarks/{id}") {
        val id = call.parameters.getOrFail("id").toInt()
        val bookmark = call.orm.entity<Bookmark>().findById(id)
        call.respond(bookmark ?: HttpStatusCode.NotFound)
    }

    post("/bookmarks") {
        val body = call.receive<NewBookmark>()
        val folder = call.orm.entity<Folder>().findById(body.folderId)
        if (folder == null) { call.respond(HttpStatusCode.BadRequest, "unknown folder"); return@post }
        val created = transaction {
            call.orm insert Bookmark(url = body.url, title = body.title, folder = folder)
        }
        call.respond(HttpStatusCode.Created, created)
    }

    delete("/bookmarks/{id}") {
        val id = call.parameters.getOrFail("id").toInt()
        transaction { call.orm.entity<Bookmark>().removeById(id) }
        call.respond(HttpStatusCode.NoContent)
    }
}

The list endpoint is where Storm earns its keep. One call, one query: the folder for every bookmark is joined in, so there is no N+1 to discover later. Toggle Show SQL to see exactly what runs.

BookmarkRepository.kt Kotlin Show SQL
1
2
// GET /bookmarks returns every bookmark, each with its folder
repository<BookmarkRepository>().findAll()
generated sql
-- one query, no N+1: the folder is joined in
SELECT b.id, b.url, b.title, f.id, f.name
FROM bookmark b
INNER JOIN folder f ON b.folder_id = f.id

8Run it

Start the server and exercise it with curl. The created bookmark comes back with its full folder object, loaded in the same query that fetched the bookmark.

terminal shell
1
2
3
4
5
6
7
8
9
10
# start the app
./gradlew run

# create a bookmark in folder 1
curl -s -X POST localhost:8080/bookmarks \
  -H 'content-type: application/json' \
  -d '{"url":"https://orm.st","title":"Storm","folderId":1}'

# list them back — each bookmark includes its folder object
curl -s localhost:8080/bookmarks

9Test it

storm-ktor-test spins up an in-memory database from your schema script and captures the SQL. Here we assert the create path is a single INSERT, so an accidental N+1 or extra round-trip fails the build rather than slipping into production.

BookmarkRoutesTest.kt Kotlin · test
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// BookmarkRoutesTest.kt
@Test
fun `POST creates a bookmark with a single insert`() = testStormApplication(
    scripts = listOf("/schema.sql"),
) { scope ->
    application {
        install(Storm) { dataSource = scope.stormDataSource }
        install(ContentNegotiation) { jackson { registerModule(StormModule()) } }
        routing { bookmarkRoutes() }
    }

    scope.stormSqlCapture.run {
        client.post("/bookmarks") {
            contentType(ContentType.Application.Json)
            setBody("""{"url":"https://orm.st","title":"Storm","folderId":1}""")
        }
    }
    assertEquals(1, scope.stormSqlCapture.count(Operation.INSERT))
}

You built it

An empty folder to a running, tested API: two entities, a joined relationship, four routes, and a repository, with no persistence context, no proxies, and no N+1. From here: