Skip to content

Quick Start

This guide walks through building a RESTful CRUD API for the classic Jimmer Book entity, from an empty project to working endpoints.

Prerequisites

  • JDK 17+
  • Gradle 8+ (or use the wrapper included in the sample repository)
  • Optional: Docker (to start the PostgreSQL database used by the sample)

1. Add the dependency

Add the JitPack repository in settings.gradle.kts:

dependencyResolutionManagement {
    repositories {
        maven { url = uri("https://jitpack.io") }
        mavenCentral()
    }
}

Add the dependency in build.gradle.kts:

dependencies {
    implementation("com.github.eimsound:ktor-jimmer-rest")
    // Jimmer KSP code generation (required for entities / DTOs)
    ksp("org.babyfish.jimmer:jimmer-ksp:0.11.5")
}

2. Install the plugin

Install JimmerRest in your Ktor application and provide a KSqlClient:

fun Application.configureFrameworks() {
    install(JimmerRest) {
        jimmerSqlClientFactory {
            inject<KSqlClient>() // Koin injection here; use whatever provides your KSqlClient
        }
    }
}

3. Define an entity

@MappedSuperclass
interface BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long
}

@Entity
interface Book : BaseEntity {
    @Key
    val name: String

    @Key
    val edition: Int

    val price: BigDecimal

    @ManyToOne
    val store: BookStore?
}

4. Declare the API

A single api<Book>("/book") registers the full set of REST routes (the block runs once at registration):

routing {
    api<Book>("/book") {
        // Filtering: query parameters map to where conditions automatically
        filter {
            where(
                `ilike?`(table.name),
                `in?`(table.edition),
                `between?`(table.price)
            )
            // association filtering (EXISTS semantics, pagination-safe)
            where(table.store) {           // reference association
                `ilike?`(table.name)       // ?store_name__start=Amazon
            }
            where(Book::authors) {         // collection association
                `ilike?`(table.firstName)  // ?authors_firstName__start=Alex
            }
            sort()
            orderBy(table.id.desc())
        }

        // Projection: return only the fields you need
        fetcher {
            fetch.by {
                allScalarFields()
                store {
                    name()
                    website()
                }
            }
        }

        // Input: validate and transform on create / update
        input {
            validator {
                with(it) {
                    ::name.notBlank { "name must not be blank" }
                    ::price.range(0.toBigDecimal()..100.toBigDecimal()) { range ->
                        "price must be between ${range.start} and ${range.endInclusive}"
                    }
                }
            }
            transformer {
                it.copy { name = it.name.uppercase() }
            }
        }

        // Per-operation write config: save mode + response projection
        create {
            saveMode = SaveMode.UPSERT
            fetcher {
                fetch.by {
                    name()
                    edition()
                    price()
                }
            }
        }
        edit {
            fetcher {
                fetch.by {
                    name()
                    edition()
                    price()
                }
            }
        }
        patch { }   // enable PATCH partial update
        batch { }   // enable batch endpoints

        // Custom actions
        action {
            get("stats") {
                val count = sqlClient.createQuery(Book::class) {
                    select(rowCount())
                }.fetchUnlimitedCount()
                call.respond(mapOf("count" to count))
            }
        }
    }
}

5. Generated endpoints

api<Book>("/book") registers the following endpoints:

Method Path Description
GET /book/{id} Fetch one entity by id (404 when missing)
GET /book Filterable, pageable list
GET /book/count Filtered total count
GET /book/exists/{id} Whether the entity exists (true/false)
POST /book Create (INSERT_ONLY)
PUT /book Update (UPDATE_ONLY, the body carries the id)
PATCH /book Partial update (enabled by patch {})
POST /book/batch Batch create (enabled by batch {})
PUT /book/batch Batch update (enabled by batch {})
DELETE /book/batch?ids=1,2 Batch delete (enabled by batch {})
DELETE /book/{id} Delete by id
custom /book/... Routes registered via action {}
# Create
curl -X POST http://localhost:8081/book \
  -H "Content-Type: application/json" \
  -d '{"name":"Learning GraphQL","edition":1,"price":50}'

# Fetch one
curl http://localhost:8081/book/1

# List with filters and paging
curl "http://localhost:8081/book?name__start=GraphQL&price__ge=50&pageIndex=0&pageSize=10"

# Update
curl -X PUT http://localhost:8081/book \
  -H "Content-Type: application/json" \
  -d '{"id":1,"name":"Learning GraphQL","edition":1,"price":55}'

# Delete
curl -X DELETE http://localhost:8081/book/1

6. Query parameters & paging

Extension functions inside filter bind request parameters to predicates:

Extension Query parameter Example
eq? {field} (plain first, then __exact) ?name=GraphQL
notEq? {field} ?name=GraphQL
in? / notIn? {field} comma-separated or repeated ?id=1,2
lt? / gt? {field}__lt / {field}__gt ?price__lt=80
le? / ge? {field}__le / {field}__ge ?price__ge=50
ilike? {field} + __anywhere / __exact / __start / __end ?name__start=GraphQL
between? {field}__ge, {field}__le ?price__ge=50&price__le=80
isNull / noNull static predicates —
Association (EXISTS) association path joined with _ ?store_books_name=GraphQL
sort() sort=field,asc\|desc (repeatable) ?sort=price,desc&sort=id,asc

Separators are configurable via router:

install(JimmerRest) {
    router {
        extParameterSeparator = "__"
        subParameterSeparator = "_"
        defaultPathVariable = "{id}"
    }
}

List endpoints accept pageIndex (default 0) and pageSize (default 10). Configure them via pager, or return a custom page structure with pageFactory:

install(JimmerRest) {
    pager {
        defaultPageIndex = 0
        defaultPageSize = 10
        pageIndexParameterName = "pageIndex"
        pageSizeParameterName = "pageSize"
        // pageFactory = { rows, totalCount, source -> MyPage(rows, totalCount, ...) }
    }
}

Endpoint paths and query parameter names are centralized in endpoint:

install(JimmerRest) {
    endpoint {
        batchPath = "batch"              // batch endpoint path
        batchIdsParameterName = "ids"    // batch delete ids parameter
        sortParameterName = "sort"       // dynamic sort parameter
        countPath = "count"              // count endpoint path
        existsPath = "exists/{id}"       // exists endpoint path
    }
}

7. Validation & error handling

Validation failures throw ValidationException; parse failures throw ParseException. Wire a consistent ApiError envelope with a single call:

install(StatusPages) {
    jimmerRestErrors() // ValidationException → 400, ParseException → 400, Throwable → 500
}
{"status": 400, "code": "BAD_REQUEST", "message": "...", "errors": ["..."]}

The framework's own 404 (missing /{id}) also returns the envelope (code: NOT_FOUND).

Jimmer's UnloadedException (reading an unloaded field) is caught by the default catcher and turned into a validation error.

8. Run the sample

The ktor-jimmer-rest-sample repository contains a complete book-service / order-service example:

# 1. Start PostgreSQL (init-postgres.sql creates the schema)
docker compose -f .devcontainer/docker-compose.yml up -d db

# 2. Start book-service (default port 8081)
cd sample
./gradlew :book-service:run

See Data Preparation (简体中文) for the full DDL (PostgreSQL / MySQL).