J
.claude-plugin
feat: add ktor-toolkit-filter module
.github
chore(deps): bump actions/setup-java from 5 to 6
.idea
feat: add ktor-toolkit-filter module
gradle
feat: add ktor-toolkit-filter module
ktor-toolkit-cache
chore(deps): bump Kotlin to 2.4.20
ktor-toolkit-expander
fix(build): stop emitting the DefaultImpls trampolines
ktor-toolkit-filter
feat: add ktor-toolkit-filter module
ktor-toolkit-hateoas
fix(build): stop emitting the DefaultImpls trampolines
ktor-toolkit-paginator
docs: fix unresolved KDoc links flagged by Dokka
ktor-toolkit-problem-details
fix(problem-details): read a binding failure from the exception, not its message
ktor-toolkit-state-machine
fix(state-machine): drop stray imports from tests
ktor-toolkit-validator
fix(build): stop emitting the DefaultImpls trampolines
report
feat: add ktor-toolkit-filter module
skills
feat: add ktor-toolkit-filter module
.editorconfig
chore(build): unify on Java 21, fix publishing and add lint/API gates
.gitignore
chore(build): unify on Java 21, fix publishing and add lint/API gates
build.gradle.kts
fix(build): stop emitting the DefaultImpls trampolines
CHANGELOG.md
feat: add ktor-toolkit-filter module
CONTRIBUTING.md
chore: set up Maven Central publishing
gradle.properties
chore: set up Maven Central publishing
gradlew
chore(deps): bump gradle-wrapper from 9.4.0 to 9.7.0
gradlew.bat
chore(deps): bump gradle-wrapper from 9.7.1 to 9.8.0
LICENSE
chore(build): unify on Java 21, fix publishing and add lint/API gates
Makefile
chore: make the Makefile self-documenting
mise.toml
chore(build): unify on Java 21, fix publishing and add lint/API gates
README.md
feat: add ktor-toolkit-filter module
RELEASING.md
docs: reflow prose and realign tables in two markdown files
settings.gradle.kts
feat: add ktor-toolkit-filter module

Ktor Toolkit

Eight small, independent libraries for building JSON APIs with Ktor: pagination, filtering, HATEOAS links, request validation, RFC 9457 errors, field expansion, response caching and aggregate state machines.

Take one or take all eight. Nothing pulls in the others except hateoas, which builds on paginator.

ModuleWhat it does
ktor-toolkit-paginatorParses ?page/?pageSize/?sortBy, and shapes the paged response
ktor-toolkit-filterParses ?filterBy=price:gte:1000, and applies it through an allow-list
ktor-toolkit-hateoasWraps a response in a _links envelope, with pagination links built for you
ktor-toolkit-validatorA type-safe should be / should notBe DSL over Ktor's RequestValidation
ktor-toolkit-problem-detailsTurns exceptions into RFC 9457 application/problem+json responses
ktor-toolkit-expanderResolves ?expand=author.books, batching one query per field
ktor-toolkit-cacheCaches a response by request path and query, over any key-value store
ktor-toolkit-state-machineDeclares an aggregate's legal moves, and publishes the open ones as links

The repository also ships 26 agent skills that record how these APIs are meant to be used, so an agent working in your service follows the same conventions you would.

Install

Java 21 or newer. On an older toolchain the dependency resolves and the compile fails with class file has wrong version, which reads like a corrupt artifact and is not one.

Published to Maven Central, so a build that already resolves anything else needs no repository change:

dependencies {
    implementation("io.github.joaoseidel:ktor-toolkit-paginator:1.0.0")
    implementation("io.github.joaoseidel:ktor-toolkit-problem-details:1.0.0")
}

The modules are released together. Give them one shared version key in gradle/libs.versions.toml rather than one per module — mixing versions across them is unsupported.

[versions]
ktor-toolkit = "1.0.0"

[libraries]
ktor-toolkit-paginator = { module = "io.github.joaoseidel:ktor-toolkit-paginator", version.ref = "ktor-toolkit" }
ktor-toolkit-problem-details = { module = "io.github.joaoseidel:ktor-toolkit-problem-details", version.ref = "ktor-toolkit" }

The package root is not the group id

Coordinates are io.github.joaoseidel, because that is the only namespace Maven Central verifies from a GitHub account. The Kotlin packages are com.github.joaoseidel, so an import guessed from the dependency line does not resolve:

import com.github.joaoseidel.ktor.toolkit.paginator.pagination          // ApplicationCall.pagination
import com.github.joaoseidel.ktor.toolkit.paginator.web.PagedResponse
import com.github.joaoseidel.ktor.toolkit.problemdetails.problemDetails // one word, unlike the artifact
import com.github.joaoseidel.ktor.toolkit.validator.rulesFor
import com.github.joaoseidel.ktor.toolkit.validator.validators.blank    // one import per rule you use

Domain types sit under data, wire types under web, and the call extensions and plugin installers at the module root.

What you also need

Ktor and kotlinx-serialization arrive transitively — they appear in these modules' public signatures, so they are api dependencies. ContentNegotiation does not, and every module needs it to turn responses into JSON:

implementation("io.ktor:ktor-server-content-negotiation:3.4.1")
implementation("io.ktor:ktor-serialization-kotlinx-json:3.4.1")

Without it a route returning PagedResponse fails at runtime with "Response pipeline couldn't transform".

A dependency on its own changes nothing. problem-details needs StatusPages with problemDetails { } inside it, and validator needs RequestValidation. paginator, filter, hateoas, expander and cache need no plugin — they are call extensions and plain objects.

Five features compile against libraries their module deliberately does not carry, so a project that skips the feature does not pay for it. Add the dependency only alongside the feature, or it fails at runtime with NoClassDefFoundError:

UsingAdd
Sort.toExposedQueryExpression(…)org.jetbrains.exposed:exposed-core
Sort.toMongoSortExpression(…)org.mongodb:mongodb-driver-core — already transitive to any MongoDB driver
Filter.toExposedCondition(…)org.jetbrains.exposed:exposed-core
Filter.toMongoFilter(…)org.mongodb:mongodb-driver-core — already transitive to any MongoDB driver
LettuceCacheio.lettuce:lettuce-core

Snapshots

Every merge to main publishes 1.0.0-SNAPSHOT, so an unreleased change can be consumed without building it:

repositories {
    mavenCentral()
    maven("https://central.sonatype.com/repository/maven-snapshots/")
}
dependencies {
    implementation("io.github.joaoseidel:ktor-toolkit-paginator:1.0.0-SNAPSHOT")
}

Gradle caches a snapshot for 24 hours. To pick up a fresh one, either run with --refresh-dependencies or narrow the window for these modules alone:

configurations.all {
    resolutionStrategy.cacheChangingModulesFor(0, TimeUnit.SECONDS)
}

For a change that is not merged yet, make publish_local puts the current tree in your local Maven repository; add mavenLocal() for that session and remove it in the same one.

Let the agent do it

The skills ship as a Claude Code plugin, from a marketplace this repository hosts itself. Both commands are interactive and run inside a session:

/plugin marketplace add joaoseidel/ktor-toolkit
/plugin install ktor-toolkit@ktor-toolkit

The marketplace step only points Claude Code at this repository; nothing is installed until the second command. Installing this way is what keeps the /ktor-toolkit: prefix on every skill. For any other agent, or to vendor the files into your repository, the skills CLI reads the same collection:

npx skills add joaoseidel/ktor-toolkit --skill '*'

Take the whole collection rather than a subset — the skills route to each other, and a partial install turns those references into dead ends. Prefer project scope over global: these are one project's conventions, and installed globally they follow you into projects that do not share them.

With the collection in place, /ktor-toolkit:install does the whole setup — picks the modules from what your API actually does, adds the catalog entries and dependencies, installs the Ktor plugins each selected module requires, adds the compiler opt-ins the DSLs ask for, and verifies the result compiles before reporting back.

/ktor-toolkit:install

Use it to add another module to a project already on the toolkit, and when Could not find io.github.joaoseidel:... or an error response arrives as HTML instead of problem+json.


Paginator

Reads the pagination off the call, carries it through to your repository, and shapes what comes back. call.pagination never fails the request: an unparseable ?page=abc falls back to the default, a negative page becomes 0, and the page size is clamped to 1..100. Use call.paginationRequest(…) for other bounds.

get("/books") {
    val pagination = call.pagination
    val books = repository.findAll(pagination)
    val paged = Paged(pagination.page, pagination.sortBy, books, repository.count())

    call.respond(PagedResponse.from(paged) { it.toResponse() })
}

?sortBy=name,-createdAt parses into [Sort("name", ASC), Sort("createdAt", DESC)]. Build a default ordering from property references, so a rename cannot leave a stale sort key behind:

val ordering = sortBy {
    desc(Book::publishedAt)
    asc(Book::title)
}

Turn a sort into a query with an explicit allow-list. Anything outside it raises IllegalArgumentException rather than reaching the database:

Books.selectAll().orderBy(*pagination.sortBy.toExposedQueryExpression(Books.title, Books.createdAt).toTypedArray())

collection.find().sort(pagination.sortBy.toMongoSortExpression(BookDocument::title, BookDocument::createdAt))

The response carries a metadata block: page, pageSize, totalPages (a count, so the last page index is totalPages - 1), totalElements, hasNext, hasPrevious, isSorted and sortCriteria.

Filter

Reads ?filterBy= off the call and applies it in your repository. Each condition is property:operator:value; conditions are separated by , (or by repeating the parameter) and must all hold:

?filterBy=price:gte:1000,title:like:kotlin
?filterBy=status:in:draft|published
?filterBy=subtitle:exists:false
OperatorMeans
eq notEqEqual, not equal
gt gte lt lteOrdering
like notLikeContains the text, ignoring case
in notInAny or none of a |-separated list
existstrue: has a value; false: has none

The value is everything after the second :, and \ makes the next character literal, so title:eq:Hello\, world is one condition. notEq, notLike and notIn also match a row with no value, as MongoDB does; on its own, SQL's NULL <> x would drop it.

Unlike paging, a bad filter fails the request. A filter is data: dropping one would answer with rows the client asked to exclude. Malformed syntax, a property outside the allow-list and a value of the wrong type all raise InvalidFilterException, an IllegalArgumentException, so one problemDetails { } mapping turns them into a 400.

get("/books") {
    val filters = call.filters
    val pagination = call.pagination
    val books = repository.findAll(filters, pagination)
    val paged = Paged(pagination.page, pagination.sortBy, books, repository.count(filters))

    call.respond(PagedResponse.from(paged) { it.toResponse() })
}

Apply the filters with an explicit allow-list. In SQL each value is typed by the column it targets; MongoDB has no schema, so the allow-list declares the types:

Books.selectAll().where(filters.toExposedCondition(Books.title, Books.price, Books.status))

val filterable = filterableFields {
    field(BookDocument::title)
    field(BookDocument::price)
}
collection.find(filters.toMongoFilter(filterable))

HATEOAS page links carry filterBy over, so a filtered listing stays filtered as the client follows next.

HATEOAS

Makes a response advertise where a client can go next, instead of the client hardcoding URLs. toResource(call) attaches self, next, prev, first and last, skipping any that would point at a page that does not exist. Every other query parameter of the current request is carried over, correctly percent-encoded.

get("/books") {
    call.respond(PagedResponse.from(paged) { it.toResponse() }.toResource(call))
}
{
    "metadata": {
        "page": 0,
        "pageSize": 10,
        "totalPages": 3,
        "hasNext": true
    },
    "content": [
        …
    ],
    "_links": [
        { "rel": "self", "href": "/books?page=0&pageSize=10", "method": "GET" },
        { "rel": "next", "href": "/books?page=1&pageSize=10", "method": "GET" },
        { "rel": "last", "href": "/books?page=2&pageSize=10", "method": "GET" }
    ]
}

For anything that is not a page, resource wraps content and declares its links in a block:

call.respond(
    resource(book.toResponse()) {
        link("self", "/books/${book.id}")
        link("delete", "/books/${book.id}", HttpMethod.Delete)
    },
)

Validator

Rules read as sentences and attach to properties by reference, so a rename is a compile error rather than a silently dead rule. A rule that does not fit does not compile: should be email() on an Int is an unresolved reference, and completion inside property { } offers only the rules that apply.

install(RequestValidation) {
    rulesFor<CreateBookRequest> {
        property(CreateBookRequest::title) {
            should notBe blank()
            should be size(min = 3, max = 200)
        }
        property(CreateBookRequest::authorEmail) { should be email() }
        nested(CreateBookRequest::publisher) {
            property(Publisher::name) { should notBe blank() }
        }
    }
}
RuleApplies to
blank, email, patternString
uuidString, UUID, Uuid
sizeString, Collection, Map, Array
min, max, inRange, positive, negativeany Number
past, future, before, after, withinLocalDate, LocalDateTime, Instant
nil, satisfyingany

Rules are values: combine them with and, or and !, write a one-off with satisfying, and override the default message with describedAs. Note the parentheses around a composed rule — infix calls associate to the left.

property(CreateBookRequest::isbn) {
    should be (uuid() or satisfying("should be an ISBN") { it.isValidIsbn() })
}

property(CreateBookRequest::authorEmail) {
    should be email() describedAs "should be a work email address"
}

A rule stays silent on a null property, so requiring a field and constraining it are two separate assertions — should notBe nil(), then should be email(). each validates collection elements as values and eachNested as objects, whenever makes a group conditional, and invariant states a rule no single property owns:

rulesFor<CreateBookRequest> {
    each(CreateBookRequest::tags) { should notBe blank() }
    eachNested(CreateBookRequest::authors) { property(Author::email) { should be email() } }

    whenever(!target.draft) {
        property(CreateBookRequest::publishedAt) { should notBe nil() }
    }

    invariant("should not be dated before its author was born") { it.publishedAt > it.authorBornAt }
}

target is the object under validation, so a rule can depend on a sibling field: should be after(target.startsAt). Temporal rules take an explicit timeZone (the system zone by default) and a now, so a comparison need not depend on where the server runs and a test can pin the clock.

Problem Details

One installer turns validation failures, malformed bodies, deliberate status exceptions and anything otherwise unhandled into application/problem+json.

install(StatusPages) {
    problemDetails {
        namingStrategy = JsonNamingStrategy.SnakeCase
        on<BookNotFoundException> { ProblemDetail.fromStatus(HttpStatusCode.NotFound, "No book ${it.id}") }
    }
}

throw HttpStatusException(HttpStatusCode.NotFound, "Book not found", mapOf("id" to id))
{
    "type": "about:blank",
    "title": "Not Found",
    "status": 404,
    "detail": "Book not found",
    "instance": "/books/42",
    "properties": {
        "id": "42"
    }
}

StatusPages resolves by nearest ancestor class, so an on<E> mapping wins over the catch-all whatever order it was declared in, and the request path fills in as instance unless you name one.

An unhandled exception logs its stack trace and answers with a fixed message, because exception text routinely names the database. Set includeExceptionMessage = true to echo it while developing, and register exception<T> handlers after problemDetails() to override any of them.

Install it before you have any mappings. Covering the unmapped cases is the module's whole point, and a service that adds it later has already shipped a different error shape to its clients.

Expander

Lets a client pull in related resources without a second round trip, and without an N+1. Declare once what a response can expand:

val bookSpec = ExpandSpec.build<BookResponse> {
    field("author", get = { it.author }, set = { copy(author = it) }) {
        batch { ids, _ -> authorRepository.findAllById(ids).associateBy { it.id } }
    }
}

get("/books") {
    call.respond(bookSpec.apply(repository.findAll(), call.expand))
}

?expand=author resolves a whole page in one call to batch. ?expand=author.name projects: batch receives setOf("name") so it can issue a narrower query, and only that field is serialized. ?expand=author.books nests, via a nested block:

field("author", get = { it.author }, set = { copy(author = it) }) {
    batch { ids, fields -> authorRepository.findAllById(ids, fields).associateBy { it.id } }
    nested {
        listField("books", get = { it.books }, set = { copy(books = it) }) {
            batch { ids, _ -> bookRepository.findAllById(ids).associateBy { it.id } }
        }
    }
}

An Expandable<T> serializes as a bare "author-id" string while unresolved and as a full object once expanded, so the wire format tells the client which it got. field covers nullable fields, listField covers lists, and polymorphicField with one case per discriminator covers fields whose type varies per row.

Cache

Caches a route's result under the request path plus its query parameters, sorted so parameter order does not matter, then hashed. Headers stay out of the key until varyHeaders names them, the way Vary does for an HTTP cache. A cache failure is logged and the request is served from the origin, though a coroutine cancellation still propagates.

val cache = InMemoryCache(maxSize = 1_000, ttl = 5.minutes)

get("/books") {
    val books = call.request.withCache("books", cache, excludeQueryKeys = setOf("traceId"), varyHeaders = setOf("Accept-Language")) {
        repository.findAll()
    }
    call.respond(books)
}

Invalidate on write:

cache.invalidateNamespace("books")   // everything under the namespace
cache.invalidateContaining(bookId)   // entries whose payload mentions this id

InMemoryCache is LRU-bounded with an optional TTL, which suits a single node. Once more than one instance serves the same traffic, use LettuceCache over Lettuce — otherwise each node holds, and invalidates, its own copy:

val connection = RedisClient.create("redis://localhost:6379").connect(LettuceCache.Codec)
val cache = LettuceCache(connection.async(), ttl = 5.minutes)

Nothing else changes: withCache and both invalidation helpers behave the same, and Redis applies the TTL itself. You own the connection, so share it — it is thread-safe and multiplexes — and close it on shutdown. Every key sits under keyPrefix ("ktor-toolkit:" by default), which bounds what the invalidation helpers scan. They walk the keyspace with SCAN, so keep them to writes rather than requests.


State Machine

States an aggregate's legal moves once, in the domain, instead of leaving them spread across whichever use cases happen to touch the status field. The module has no dependencies at all — not even Ktor — so a framework-free -core can depend on it.

val orderFlow = stateMachine<OrderState, OrderEvent, Order> {
    initial(DRAFT)
    final(SHIPPED, CANCELLED)

    state(DRAFT) {
        on<Place>(PLACED) {
            guard("must have at least one line") { it.lines.isNotEmpty() }
            effect { order, event -> audit.record(order, event) }
        }
        on<Cancel>(CANCELLED)
    }

    state(PLACED) {
        on<Pay>(PAID)
        on<Cancel>(CANCELLED)
    }

    state(PAID) {
        onEnter { order, _ -> receipts.issue(order) }
        on<Ship>(SHIPPED) { rel = "dispatch" }
    }

    onTransition { audit.log(it) }
}

The definition is checked when it is built, so an unreachable state, a dead end, a move to a state nobody declared or two moves one event could both trigger is a StateMachineDefinitionException at startup rather than a bug found in production.

The machine never touches the subject — it reports where to go, and the caller applies it in the same unit of work that persists it:

when (val result = orderFlow.fire(order, order.state, Pay)) {
    is Accepted -> repository.save(order.copy(state = result.to))
    is Rejected -> log.info { result.reason.message }
}

Where a rejection is always an error, fireOrThrow returns the state and raises IllegalTransitionException — map it to a 409 in problemDetails { } and the handler stays one line. Guards and effects may both suspend.

A guard asks about the subject, never the event. That is what lets the machine answer "what can this order do right now?" without being handed an event to test with — so the affordances and the rules that decide them cannot drift apart:

val moves = orderFlow.transitionLinks(order, order.state) { "/orders/${order.id}/${it.rel}" }

call.respond(
    resource(order.toResponse()) {
        link("self", "/orders/${order.id}")
        links(moves)
    },
)

The client stops reimplementing the lifecycle to decide whether to draw a button: it looks for a pay link, and the absence of one is the answer. transitionLinks is the only part that needs ktor-toolkit-hateoas, and it is compiled against it optionally.


Skills

The libraries leave the surrounding decisions open, and everyone resolves them differently: one parses ?page by hand, another invents its own error envelope, another puts the repository call in the route body. The skills record the intended answer to each, so the n-th endpoint in your service looks like the first one whoever wrote it.

They are written for the repository that installs them, not for this one. Install with the commands under Let the agent do it.

/ktor-toolkit:start runs first, before any Kotlin is written or changed. It establishes which toolkit modules are on your classpath and what your project's build gate is, then routes the task to the skills that apply — usually two or three. Running it after the code is written is too late to be useful.

SkillWhat it covers
startEntrypoint. Orients in the project, routes the task to the skills below
installAdds the toolkit to a project: modules, Gradle wiring, Ktor plugins, these skills
endpointA new or changed HTTP route, end to end across -core, -adapters and -app
architecturePorts & Adapters layout, the three-module split, where a file goes
pagination?page, ?pageSize, ?sortBy, carrying Pagination into the persistence adapter
filtering?filterBy=, the 400 contract, allow-lists, counting with the same filters
hateoas_links, page links, the resource { } builder
validationrulesFor<T>, syntactic vs business rules, the nullable-DTO convention
problem-detailsproblemDetails { }, status codes, mapping domain exceptions
expandExpandSpec, batched resolution, the Expandable wire contract
cachewithCache, choosing a store, TTLs, who invalidates and when
state-machinestateMachine { }, guards as affordances, transitions as _links
migrationsVersioned SQL under Flyway, where it lives, baselining, expand/contract schema changes
diKtor's native DI: provide<Port> { Impl(resolve()) }, lifetimes, test overrides
testsKotest ShouldSpec naming, MockK, testApplication, Testcontainers, acceptance tests
koverThe report aggregation module, ratcheting a threshold, the rule for exclusions
openapiKtor's comment-based OpenAPI generation, served through Scalar
loggingKotlinLogging idioms, correlation IDs via CallId/CallLogging, what must never be logged
healthcheckLiveness and readiness probes with Cohort, and what a probe may depend on
gradlelibs.versions.toml as the single source of versions, choosing a dependency scope
makefileThe canonical target set, and a self-documenting help
containerMulti-stage jlink Dockerfile, container-aware JVM ergonomics, graceful shutdown
codestyleWhat a formatter cannot check: naming, immutability, invariants, coroutine failure
commentsThe best comment is none; KDoc states the contract, and stale comments get deleted
changelogWhat earns an entry, written from the caller's side, in the commit that caused it
commitConventional Commits with a body that says what was wrong, and how to split work

Each skill also says what to do when it describes something your repository does not have yet — a Makefile, a Dockerfile, a CHANGELOG.md, a coverage module. It proposes the file and waits for you, rather than scaffolding it unasked or quietly working around its absence.

Development

make build      # compile, test and lint every module
make test       # tests only
make coverage   # report/build/reports/kover/html/index.html
make format     # apply ktlint
make api        # refresh the public API dumps after an intentional change
make verify     # everything a release must pass
make docs       # Dokka HTML per module

make verify is lint, api_check, build and coverage, in the order a failure is cheapest to read. Coverage is gated at 100% line and 100% branch, and a changed public signature carries its refreshed api/ dump in the same commit.

See CONTRIBUTING.md, RELEASING.md and the CHANGELOG.

License

MIT