Kotlin
Generate a native Kotlin client from your routes, using OkHttp and kotlinx.serialization.
The Kotlin client is new and still settling. The generated API surface may change while v2 is in beta, so pin your version if you depend on it.
@kizunajs/kotlin generates a native Kotlin client from your Kizuna routes. The generated client uses OkHttp for HTTP, kotlinx.serialization for JSON, and Kotlin coroutines for async.
pnpm add @kizunajs/kotlin@betabun add @kizunajs/kotlin@betanpm install @kizunajs/kotlin@betaAdd the dependencies
The generated client uses OkHttp, kotlinx.serialization, and coroutines. Add them to your build.gradle.kts:
dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
}Add it to your config
Point kotlinClient at the file you want written:
import { kotlinClient } from '@kizunajs/kotlin';
export default defineConfig({
adapter: expressAdapter(),
routes,
clients: [
kotlinClient({
output: './android/app/src/main/kotlin/com/example/APIClient.kt',
namespace: 'API',
package: 'com.example',
}),
],
});kizuna generate| Option | Description |
|---|---|
output | Where the generated .kt file goes |
namespace | Object wrapping all generated types, API by default |
package | Package declaration for the generated file, such as com.example.api |
camelCaseProperties | Convert wire field names to camelCase properties, mapped back via @SerialName |
unknownEnumCase | Emit enums as a sealed interface with an Unknown member so new server values don't break decoding |
Without the config
kizuna-kotlin generates one client on its own, for a repository that does not run kizuna generate:
kizuna-kotlin generate --config kizuna.config.ts --out android/app/src/main/kotlin/com/example/APIClient.kt --namespace-name API --package com.example--config takes the path to your kizuna.config.ts, suffixed with an export to read something other than the default. --camel-case and --unknown-enum-case are the flag forms of the options above, and --export names the export to read when none is suffixed, api by default. The CLI loads the config at runtime.
Add it to your project
Add the generated file to your Kotlin project as a regular source file. Set package so the file declares a package matching its directory, which Android and JVM projects expect.
Call a route
Request inputs are built inside a lambda: params (path), query, headers, and body. You name no types, and missing a required field is a compile error:
val client = APIClient(baseUrl = "https://api.example.com")// path param + header, groups chain in order
val user = client.users.getUser {
params(
id = "1",
).headers(
xRequestId = "trace-1",
)
}
// query, all-optional, so it can be omitted entirely
val page = client.users.listUsers {
query(
page = 1,
limit = 20,
)
}
val all = client.users.listUsers()
// object body
val created = client.users.createUser {
body(
name = "Ada",
email = "ada@example.com",
)
}Use it in a screen
A schema wrapped in Kizuna.model generates a named type, so a composable can name it in its signature and every route that returns a user hands back the same API.User:
@Composable
fun UserRow(user: API.User) {
Column {
Text(user.name)
Text(user.id, style = MaterialTheme.typography.bodySmall)
}
}@Composable
fun UserList(client: APIClient) {
var users by remember { mutableStateOf(emptyList<API.User>()) }
LaunchedEffect(Unit) {
users = client.users.listUsers().body.users
}
LazyColumn {
items(users, key = { it.id }) { user ->
UserRow(user)
}
}
}Property naming
By default, field names are emitted verbatim, so total_count on the wire stays total_count in the generated type. A @file:Suppress(...) header keeps the IDE green over the generated file.
Pass --camel-case to convert wire fields to camelCase properties (total_count becomes totalCount), preserving the wire name via @SerialName.
Unknown enum case (open enums)
By default a z.enum is a closed enum class that throws on a wire value it doesn't know, failing the whole response. Pass --unknown-enum-case to make enums forward-compatible: each becomes a sealed interface with a data object per known value plus an Unknown(wireValue) member, so an unrecognised value deserializes to Unknown("newValue") instead of throwing:
when (val kind = event.kind) {
EventKind.LOGIN, EventKind.LOGOUT, EventKind.SIGNUP -> render(event)
is EventKind.Unknown -> log("skipping unknown event kind: ${kind.wireValue}")
}The raw value is preserved as wireValue, so re-encoding writes it back unchanged. Discriminated unions still throw on an unknown discriminator.
Generated client
The generated file exports a typed class with suspend methods for each route and sub-clients for grouped routes. Each method takes a request-builder lambda whose receiver (Scope) exposes one factory per input group. The lambda must return the operation's Args, and only chains that provide every required group produce an Args, which is what makes missing required inputs a compile error. Each method returns the route's Response (or Unit for routes with no success body) and throws its Failure:
import kotlinx.serialization.*
import okhttp3.*
class APIClient(
private val baseUrl: String,
private val client: OkHttpClient = OkHttpClient(),
private val json: Json = Json { ignoreUnknownKeys = true },
private val requestInterceptor: (suspend (Request.Builder) -> Unit)? = null,
private val responseInterceptor: (suspend (Request, Response) -> Unit)? = null
) {
val users = APIUsersClient(...)
object UsersListUsers {
data class Query(
val page: Int? = null,
val limit: Int? = null
)
sealed interface Args {
val query: Query?
}
object Scope {
fun query(page: Int? = null, limit: Int? = null): AfterQuery = ...
}
// AfterQuery, Response, Result, Success, Failure ...
}
@Throws(UsersListUsers.Failure::class)
suspend fun listUsers(build: APIClient.UsersListUsers.Scope.() -> APIClient.UsersListUsers.Args = { query() }): APIClient.UsersListUsers.Result {
// ...
}
}Interceptors & auth
requestInterceptor runs before every request, so attach auth headers here. responseInterceptor observes every response. Both may suspend (e.g. to refresh a token):
val client = APIClient(
baseUrl = "https://api.example.com",
requestInterceptor = { builder ->
builder.header("Authorization", "Bearer $token")
},
responseInterceptor = { request, response ->
println("${request.method} ${request.url} -> ${response.code}")
}
)Handling responses
Success returns a Result with the decoded body (and headers when the route declares them). Routes with multiple success codes give you a sealed Success to when over; void routes return Unit.
Errors throw a sealed Failure with one named subtype per declared status (NotFound, BadRequest, …) carrying its typed body, plus Unexpected(statusCode, data) for undeclared statuses and Decoding(cause, statusCode, data) for bodies that don't parse. Catch one subtype, or when over the sealed class, and the compiler lists every case:
try {
val response = client.users.getUser {
params(
id = "1",
)
}
println(response.body)
} catch (error: APIClient.UsersGetUser.Failure) {
when (error) {
is APIClient.UsersGetUser.Failure.NotFound -> println("missing: ${error.body.detail}")
is APIClient.UsersGetUser.Failure.Unexpected -> println("status ${error.statusCode}")
is APIClient.UsersGetUser.Failure.Decoding -> throw error
}
}Methods throw, so runCatching works for a functional style:
val result = runCatching {
client.users.getUser {
params(
id = "1",
)
}
}
result.onSuccess { println(it.body) }.onFailure { println("failed: ${it.message}") }Deprecation
Routes with deprecated and fields with .meta({ deprecated: ... }) emit @Deprecated in the generated Kotlin code, with the message when one is set:
@Deprecated("use newRoute instead")
suspend fun deleteUser(build: APIClient.UsersDeleteUser.Scope.() -> APIClient.UsersDeleteUser.Args) {
// ...
}Streams
A route whose response streams returns its body as a Flow. Named events become a sealed Event interface with one class per event, each carrying its decoded payload as data:
val result = client.assistant.reply {
body(
prompt = "hello",
)
}
result.body.collect { event ->
when (event) {
is APIClient.AssistantReply.Event.Delta -> text.append(event.data.text)
is APIClient.AssistantReply.Event.Done -> println(event.data.outputTokens)
}
}A stream of one schema yields that type directly, a text/* stream yields one String per line, and a binary stream yields ByteArray chunks. The status and headers are checked before the method returns, so a 400 throws its Failure as on any route. The connection is open when the method returns, so collect the flow; cancelling the collector closes it. A route that mixes a streamed status with another 2xx is left out of the client, with a warning.
Tip: automate with a script
You can add a script to your package.json to regenerate the client whenever your routes change:
{
"scripts": {
"generate:kotlin": "kizuna-kotlin generate --config kizuna.config.ts --out ../android/app/src/main/kotlin/com/example/APIClient.kt --namespace-name API --package com.example"
}
}