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)
}
API-key resolver checklist: hashed storage (Argon2/HMAC, never plaintext), constant-time comparison via 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
Authorization rules are also used for access estimation — the admin UI can grey out actions a user cannot perform, without making the actual API call.

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