Skip to content

REST

Kairo is built on top of Ktor, so it supports all the features of Ktor.

Kairo also provides an advanced routing DSL, which is optional but highly recommended.

Install kairo-rest-feature. You don’t need to install Ktor separately — it’s included by default.

build.gradle.kts
dependencies {
implementation("software.airborne.kairo:kairo-rest-feature")
}

First, add the Feature to your Server.

val features = listOf(
RestFeature(
config = config.rest,
authConfig = null, // This Server won't support auth. See below for an auth example.
),
)

We recommend using kairo-config to configure the Feature.

rest {
connector.port = 8080
plugins {
defaultHeaders.serverName = "..."
}
}

First, define your endpoints in a type-safe way.

data class UserRep(
val id: UserId,
val createdAt: Instant,
val emailAddress: String,
) {
data class Creator(
val emailAddress: String,
)
}
object UserApi {
@Rest("GET", "/users/:userId")
@Rest.Accept("application/json")
data class Get(
@PathParam val userId: UserId,
) : RestEndpoint<Unit, UserRep>()
@Rest("GET", "/users")
@Rest.Accept("application/json")
data class GetByEmailAddress(
@QueryParam val emailAddress: String,
) : RestEndpoint<Unit, UserRep>()
@Rest("GET", "/users")
@Rest.Accept("application/json")
data object ListAll : RestEndpoint<Unit, List<UserRep>>()
@Rest("POST", "/users")
@Rest.ContentType("application/json")
@Rest.Accept("application/json")
data class Create(
override val body: UserRep.Creator,
) : RestEndpoint<UserRep.Creator, UserRep>()
}
@Single
class UserHandler(
private val userMapper: UserMapper,
private val userService: UserService,
) : HasRouting {
override fun Application.routing() {
@Suppress("LongMethod")
routing {
route(UserApi.Get::class) {
handle {
val user = userService.get(endpoint.userId)
?: throw UserNotFound(endpoint.userId)
userMapper.rep(user)
}
}
route(UserApi.GetByEmailAddress::class) {
handle {
val user = userService.getByEmailAddress(endpoint.emailAddress)
?: throw UserNotFound(null)
userMapper.rep(user)
}
}
route(UserApi.ListAll::class) {
handle {
val users = userService.listAll()
users.map { userMapper.rep(it) }
}
}
route(UserApi.Create::class) {
handle {
val user = userService.create(
creator = userMapper.creator(endpoint.body),
)
userMapper.rep(user)
}
}
}
}
}

If you want your Server to support auth, you must provide an AuthConfig.

class MyAuth : AuthConfig() {
/**
* Your implementation can be anything;
* this shows you how to use JWK for JWT.
*/
override fun AuthenticationConfig.configure() {
val jwkProvider = JwkProviderBuilder(URI.create(config.jwkUrl).toURL()).build()
jwt { credential ->
val decoded = JWT.decode(credential.token)
val jwk = jwkProvider.get(decoded.keyId ?: throw JwtVerificationFailed())
val algorithm = Algorithm.RSA256(jwk.publicKey as RSAPublicKey, null)
return@jwt JWT
.require(algorithm)
.withIssuer(config.issuer)
.acceptLeeway(config.leeway.inWholeSeconds)
.build()
}
}
}
RestFeature(
config = config.rest,
authConfig = MyAuth(),
)

This section documents all available configuration options.

The Parallelism config specifies how to manage thread pools.

  • runningLimit: The maximum number of concurrent requests. Defaults to 50.
  • shareWorkGroup: Whether to avoid creating a call group, sharing the worker group instead. Defaults to false.
  • connectionGroupSize: How many threads for accepting new connections and starting call processing. Defaults to 1. If explicitly null, uses Netty’s default.
  • workerGroupSize: How many threads for processing connections, parsing messages and doing Netty’s internal work. Defaults to 1. If explicitly null, uses Netty’s default.
  • callGroupSize: How many threads for processing application calls. Defaults to 10. If explicitly null, uses Netty’s default.

The Timeouts config specifies how timeouts work.

  • requestRead: Defaults to 0, which is infinite.
  • responseWrite: Defaults to 10 seconds.

The Lifecycle config specifies Server lifecycle.

  • shutdownGracePeriod: The minimum amount of time to wait for cooldown. Defaults to 0.
  • shutdownTimeout: The maximum amount of time to wait for cooldown. Defaults to 15 seconds.

The Connector config specifies REST connectivity.

  • host: Which host to listen on. Defaults to unmasked.
  • port: The port to listen on. No default (required).

Configures Ktor’s AutoHeadResponse plugin.

Defaults to null, which means the plugin is disabled.

Configures Ktor’s CallLogging plugin.

Enabled by default. Disable by setting to null.

  • useColors: Whether to use colors in the logs. Defaults to false.

Configures Ktor’s ContentNegotiation plugin.

Enabled by default. Disable by setting to null.

Configures Ktor’s Cors plugin.

Defaults to null, which means the plugin is disabled.

  • hosts: An array of allowed hosts.
  • hosts.host The host to allow.
  • hosts.schemes: The schemes to allow.
  • hosts.subdomains: The subdomains to allow.
  • headers: The headers to allow.
  • methods: The methods to allow.
  • allowCredentials: Whether to allow credentials.

Configures Ktor’s DefaultHeaders plugin.

Enabled by default. Disable by setting to null.

  • serverName: The server name for the Server header. No default (required).
  • headers: A map of default headers.

Configures Ktor’s DoubleReceive plugin.

Defaults to null, which means the plugin is disabled.

  • cacheRawRequest: Whether to cache the raw request.

Configures Ktor’s ForwardedHeaders plugin.

Enabled by default. Disable by setting to null.

Configures Ktor’s Sse plugin.

Defaults to null, which means the plugin is disabled.

If you use Kairo’s logical errors, they will automatically be mapped to semantic HTTP responses.

data class UserNotFound(
val userId: UserId?,
) : LogicalFailure("User not found") {
override val type: String = "UserNotFound"
override val status: HttpStatusCode = HttpStatusCode.NotFound
override fun Map<String, Any?>.buildJson() {
put("userId", userId)
}
}
// => {
// "type": "UserNotFound",
// "status": 404,
// "message": "User not found",
// "detail": null,
// "userId": "..."
// }

Deserialization errors will be mapped to semantic HTTP responses.

data class Rep(val value: Int)
// POST {}
// -> {
// "type": "MissingProperty",
// "status": 400,
// "message": "Missing property",
// "detail": null,
// "path": "/value"
// }
// POST {"value":"Hello, World!"}
// -> {
// "type": "InvalidProperty",
// "status": 400,
// "message": "Invalid property",
// "detail": null,
// "path": "/value"
// }
// POST {"value":42,"other":"Hello, World!"}
// -> {
// "type": "UnrecognizedProperty",
// "status": 400,
// "message": "Unrecognized property",
// "detail": null,
// "path": "/other"
// }

We recommend excluding logs below the INFO level for this library.

<Logger name="io.ktor.server.plugins.cors.CORS" level="warn"/>
<Logger name="kairo.rest" level="info"/>