REST
Type-safe API routes, authorization DSL, endpoint documentation, and SSE support.
Typed API routes
Every route carries its parameter types, request body type, and response type at compile time. Four route variants cover all REST patterns:
| Variant | Use case |
|---|---|
ApiRoute.Plain | GET without parameters |
ApiRoute.WithParams | GET/DELETE with path or query parameters |
ApiRoute.WithBody | POST/PUT with a request body |
ApiRoute.WithBodyAndParams | POST/PUT with both params and body |
// Define endpoints in shared code (client + server)
companion object {
val GetUsers = TypedApiEndpoint.Get(
uri = "/api/users",
response = UserModel.serializer().apiList(),
)
val CreateUser = TypedApiEndpoint.Post(
uri = "/api/users",
body = CreateUserRequest.serializer(),
response = UserModel.serializer().api(),
)
}
// Mount on the server with handler, docs, and auth
val getUsers = GetUsers.mount {
docs { name = "List users" }
.codeGen { funcName = "getUsers" }
.authorize { public() }
.handle {
ApiResponse.ok(userService.listAll())
}
} Authentication pipeline
Funktor ships three Ktor auth providers that together produce a typed
Caller principal for every request. Install them in
authentication { } and list them on the route's
authenticate(...) block in the same order:
authentication {
jwtCaller(AUTH_JWT, realm = "MyApp") // Caller.JwtCaller on a valid JWT
apiKeyCaller(AUTH_API_KEY, realm = "MyApp") { // Caller.ApiKeyCaller on a valid key
token -> kontainer.get<ApiKeyService>().tryResolve(token)
}
anonymous(AUTH_ANON) // Caller.AnonymousCaller — terminal fallback
}
routing {
authenticate(AUTH_JWT, AUTH_API_KEY, AUTH_ANON) {
// ...routes
}
} Order matters — under Ktor's default FirstSuccessful strategy, the first
provider that returns a principal wins. anonymous() always succeeds, so it
must be listed last. Invalid credentials (expired JWT, revoked key) silently
fall through to AnonymousCaller rather than 401-ing — routes that require
real auth check User.isAnonymous().
UserRecord variants
currentUserProvider() dispatches on the Caller and produces a
sealed UserRecord:
when (record) {
is UserRecord.Anonymous -> // no credentials
is UserRecord.System -> // internal system actor (background jobs)
is UserRecord.LoggedIn -> // JWT-authenticated end-user
is UserRecord.ApiKey -> // API-key authenticated; carries keyId + keyName for audit
}
// Wire the provider into the per-call kontainer
installKontainer { call ->
app.kontainers.create {
with { call.currentUserProvider() }
}
} The default jwtCaller validates the bearer token via the kontainer-bound
JwtGenerator. Pass a custom validate lambda when you need extra
checks (revocation list, audience switching, multi-tenant verifier selection):
jwtCaller(AUTH_JWT) { token ->
val payload = jwtGenerator.tryVerify(token) ?: return@jwtCaller null
if (kontainer.get<RevocationList>().isRevoked(payload.subject)) return@jwtCaller null
Caller.JwtCaller(payload)
} MessageDigest.isEqual, fast-reject by prefix before
any DB lookup, never log the raw token. The permissions on
Caller.ApiKeyCaller defaults to UserPermissions.anonymous —
derive privileges from your authoritative store, never from the caller's input.
Authorization DSL
Every route declares its authorization rules. Rules compose with and / or.
.authorize {
// Single rule
isSuperUser()
// Combine rules
forRole("admin") or forGroup("staff")
// Custom rule
forCall("owns resource") { params ->
user.id == params.ownerId
}
}
// Built-in rules:
// public() — allow everyone (including anonymous)
// authenticated() — any logged-in user (non-anonymous)
// forbidden() — deny all
// isSuperUser() — super user check
// forRole("admin") — role-based
// forGroup("staff") — group-based
// forPermission("edit") — permission-based
// forOrganisation("x") — org membership SSE routes
Server-Sent Events routes stream data from server to client. Same type-safe pattern,
but the handler receives a ServerSSESession.
val SseEvents = TypedApiEndpoint.Sse(uri = "/api/events")
val sseEvents = SseEvents.mount(Unit::class) {
authorize { public() }
.handle {
while (true) {
send(ServerSentEvent(data = Json.encodeToString(payload), event = "update"))
delay(1000)
}
}
} API features
Group related routes into ApiFeature implementations. Register them in Kontainer and they
are auto-discovered and mounted.
class UserApiFeature : ApiFeature {
override val name = "Users"
override val description = "User management endpoints"
val users = UserApi()
val profiles = ProfileApi()
override fun getRouteGroups() = listOf(users, profiles)
}