Ktor Server Integration
The IDK provides a Ktor server plugin, KotlinInjectPlugin, that bridges the IDK's Metro-powered three-scope architecture (App, User, Session) with Ktor's request handling pipeline. On every incoming request, the plugin automatically resolves the tenant and principal, creates the matching DI scopes, and makes all scoped services accessible from your route handlers.
Installation
Add the Ktor server plugin to your dependencies:
dependencies {
implementation("com.sphereon.idk:ktor-server-kotlin-inject:0.25.0")
}
Plugin Setup
Install the KotlinInjectPlugin in your Ktor application and provide your root AppGraph:
import com.sphereon.ktor.server.inject.KotlinInjectPlugin
import io.ktor.server.application.*
import io.ktor.server.cio.*
import io.ktor.server.engine.*
fun main() {
embeddedServer(CIO, port = 8080) {
configureKotlinInject()
configureRouting()
}.start(wait = true)
}
fun Application.configureKotlinInject() {
val appGraph = MyAppGraph.init(
application = this,
appId = "my-app",
profile = "production",
version = "1.0.0"
)
install(KotlinInjectPlugin) {
this.appGraph = appGraph
}
}
Configuration Options
The plugin is configured through KotlinInjectConfiguration:
| Option | Default | Description |
|---|---|---|
appGraph | (required) | Your root IDK AppGraph |
tenantHeader | "X-Tenant-ID" | HTTP header from which the tenant ID is extracted |
principalHeader | "X-User-ID" | HTTP header from which the principal ID is extracted |
tenantResolver | DefaultTenantResolver | Custom strategy for resolving the tenant from a request |
principalResolver | DefaultPrincipalResolver | Custom strategy for resolving the principal from a request |
Request Lifecycle
When a request arrives, the plugin's UserContextInterceptor runs before your route handlers:
- Resolve tenant: calls
tenantResolver.resolve(call)to extract aTenantInput - Resolve principal: calls
principalResolver.resolve(call)to extract aPrincipalInput - Create or retrieve UserContext: finds or creates the
UserContextInstancefor this tenant+principal pair - Create Session: generates a unique session ID and creates a
SessionInstance - Attach to call: stores both as a
RequestScopedContextin the call attributes
All of this happens automatically. By the time your handler runs, scoped services are ready.
Accessing Services in Routes
The plugin provides extension properties and functions on ApplicationCall for accessing services at each scope level.
Extension Properties
routing {
get("/info") {
// Root DI component
val appGraph = call.appGraph
// User context for this request (tenant + principal)
val user = call.userInstance
val tenant = user.context.tenant
val principal = user.context.principal
// Session context for this request
val session = call.sessionInstance
val sessionId = session.sessionId
call.respondText("Tenant: $tenant, Principal: $principal, Session: $sessionId")
}
}
Scope-Typed Service Access
Use the generic extension functions to retrieve any service from the appropriate DI scope:
routing {
get("/config") {
// App-scoped singleton
val configService = call.getAppService<AppConfigService>()
val appName = configService.getAppName()
call.respondText("App: $appName")
}
get("/user-info") {
// User-scoped service (tenant + principal isolated)
val logManager = call.getUserService<UserContextLogManager>()
logManager.withTag("API").info("User endpoint accessed")
val tenantConfig = call.getUserService<TenantConfigService>()
val apiKey = tenantConfig.getProperty("api.subscription.key", String::class)
call.respondText("API Key: $apiKey")
}
get("/session-info") {
// Session-scoped service (per-request)
val keyManager = call.getSessionService<KeyManagerService>()
val httpClientFactory = call.getSessionService<HttpClientFactory>()
// ...
}
}
If a service is not found in the requested scope, the functions throw IllegalStateException with a diagnostic message listing available services.
Tenant and Principal Resolvers
Default Resolvers
The DefaultTenantResolver extracts the tenant ID from an HTTP header (default: X-Tenant-ID). If the header is missing, it falls back to "default".
The DefaultPrincipalResolver checks three sources in order:
- The configured HTTP header (default:
X-User-ID) - Ktor's authentication plugin (
UserIdPrincipal, JVM only) - Falls back to
"anonymous"
Custom Resolvers
Implement the TenantResolver or PrincipalResolver interfaces to resolve from tokens, cookies, path segments, or any other source:
class JwtTenantResolver : TenantResolver {
override fun resolve(call: ApplicationCall): TenantInput {
val jwt = call.request.headers["Authorization"]
?.removePrefix("Bearer ")
?.let { decodeJwt(it) }
val tenantId = jwt?.getClaim("tenant_id")?.asString() ?: "default"
return DefaultTenantInputString(tenantId)
}
}
class JwtPrincipalResolver : PrincipalResolver {
override fun resolve(call: ApplicationCall): PrincipalInput {
val jwt = call.request.headers["Authorization"]
?.removePrefix("Bearer ")
?.let { decodeJwt(it) }
val principalId = jwt?.subject ?: "anonymous"
return DefaultPrincipalInputString(principalId)
}
}
Install them when configuring the plugin:
install(KotlinInjectPlugin) {
appGraph = myAppGraph
tenantResolver = JwtTenantResolver()
principalResolver = JwtPrincipalResolver()
}
Universal HTTP Adapters
For API endpoints that need to work across multiple frameworks, use the Universal HTTP Adapter pattern. The adapter system converts between Ktor's request/response model and a framework-agnostic GenericHttpRequest/GenericHttpResponse.
Application-Level Installation
Mount adapters at the application root or under a path prefix:
fun Application.module() {
install(KotlinInjectPlugin) {
appGraph = myAppGraph
}
installUniversalHttpAdapters {
pathPrefix = "/api"
verboseLogging = true
errorHandler = { call, error ->
call.respond(HttpStatusCode.InternalServerError, mapOf("error" to error.message))
}
}
}
Route-Level Installation
Or mount adapters within a specific route:
routing {
route("/api/v1") {
installUniversalHttpAdapters()
}
}
How It Works
- A catch-all route captures all unmatched requests under the configured prefix
- The Ktor
ApplicationRequestis converted to aGenericHttpRequest(with lazy header/body evaluation for performance) - The
HttpAdapterDispatcher(from SessionScope) routes the request to the matching registeredHttpAdapter - The adapter's
GenericHttpResponseis converted back to a Ktor response
Configuration Options
| Option | Default | Description |
|---|---|---|
pathPrefix | null | Optional prefix for the catch-all route |
verboseLogging | false | Log incoming requests |
errorHandler | null | Custom error handler for adapter failures |
See the EDK Universal HTTP Adapter documentation for details on creating adapter implementations.
YAML Configuration Integration
On JVM, the plugin automatically registers Ktor YAML property sources that load application.yml from the working directory, config directory, or classpath. These sources participate in the IDK configuration system at all three scope levels.
The YAML file uses direct config keys. Scope is determined by the directory the file lives in (config/ for app-level, config/tenant/{id}/ for tenant-level, etc.):
api:
base-url: https://api.example.com
timeout-ms: 30000
api:
subscription-key: acme-key-123
ui:
theme: dark
| Config location | Scope | Resolved as |
|---|---|---|
config/ | APP | Available to all tenants |
config/tenant/{tenantId}/ | TENANT | Available within that tenant's context |
config/tenant/{tenantId}/principal/{principalId}/ | PRINCIPAL | Available within that principal's context |
The YAML sources are registered via the PropertySourceContribution mechanism with Order.HIGH priority and are auto-discovered at startup.
Next Steps
- HTTP Client: Creating HTTP clients with TLS and mTLS support
- Dependency Injection: Understanding the App/User/Session scope architecture
- EDK Universal HTTP Adapters: Framework-agnostic API endpoints