A Kotlin Compiler Plugin that turns annotated interfaces into type-safe Ktor HTTP clients — inspired by Retrofit, powered by K2 FIR and IR.
Define your API as an interface, annotate methods with HTTP verbs, and let the compiler generate the implementation at compile time. No reflection, no code generation task, no annotation processor — just a compiler plugin.
@ContributesAPI
interface PostsApi {
@GET("posts/{id}")
suspend fun getPost(@Path("id") id: Int): Post
}
val api = KtorClient("https://example.com/", HttpClient(CIO)).create<PostsApi>()
val post = api.getPost(1)Add the plugin — it pulls in the runtime for you:
plugins {
id("com.adkhambek.ktor.kit") version "<latest-version>"
}- Compile-time implementation generation via K2 FIR + IR
- Kotlin Multiplatform: JVM, JS, and 12 Kotlin/Native targets
- All HTTP verbs:
@GET,@POST,@PUT,@DELETE,@PATCH,@HEAD,@OPTIONS - Path parameters with
@Path(percent-encoded automatically) - Query parameters with
@Queryand@QueryMap - Request headers with
@Header,@Headers, and@HeaderMap - Request bodies with
@Body—@Serializabletypes are encoded to JSON automatically - Form-encoded requests with
@FormUrlEncoded,@Field, and@FieldMap - File uploads and mixed form data with
@Multipartand@Part - Streaming responses as
Flow<T>— decoded line by line, nothing buffered up front - Dynamic URLs with
@Url - Per-API base URLs via
@ContributesAPI(baseUrl = "...") - Automatic JSON serialization and deserialization via
kotlinx.serialization - Rich return types:
T,T?,List<T>,Map<K, V>,Response<T>,Flow<T>,String,Unit - Twelve compile-time diagnostics for mistakes that would otherwise fail silently or at runtime
| Component | Version |
|---|---|
| KtorKIT | latest — see badge |
| Kotlin | 2.4.10 |
| Ktor | 3.5.1 |
| kotlinx-serialization-json | 1.11.0 |
| kotlinx-coroutines | 1.11.0 |
| Gradle | 9.6.1 |
| JVM Toolchain | 21 |
Published as com.adkhambek.ktor.kit on Maven Central.
Dependency versions live in gradle/libs.versions.toml; every module resolves them from
that catalog. The published coordinates are separate — GROUP and VERSION_NAME in
gradle.properties, which the root build script and the Gradle plugin's baked-in artifact
coordinates both read from.
Because the plugin binds to compiler internals, the Kotlin version is not a free choice: KtorKIT must be built against the same Kotlin version your project compiles with.
| Tier | Targets |
|---|---|
| JVM | jvm |
| JS | js(IR) — Node.js |
| Apple | iosX64, iosArm64, iosSimulatorArm64, macosX64, macosArm64, watchosArm64, watchosSimulatorArm64, tvosArm64, tvosSimulatorArm64 |
| Other | linuxX64, mingwX64 |
ktorkit/
├── runtime/ # What your code links against
│ ├── Annotations.kt # @GET, @Path, @Body, @Part, …
│ ├── KtorClient.kt # baseUrl + HttpClient pair
│ ├── Create.kt # create<T>() — rewritten at every call site
│ ├── Execute.kt # RequestBuilder: the API generated code targets
│ └── Response.kt # status + headers + decoded body
│
├── compiler/ # The K2 plugin itself
│ ├── KtorKitCompilerPluginRegistrar.kt # entry point (META-INF/services)
│ ├── KtorKitFirExtensionRegistrar.kt # registers the two FIR extensions
│ ├── KtorKitDeclarationGenerationExtension.kt # FIR: declares PostsApi.Impl
│ ├── KtorKitCheckers.kt # FIR: validation
│ ├── Diagnostics.kt # FIR: error definitions + messages
│ ├── GeneratedBodyFiller.kt # IR: writes the method bodies
│ ├── CreateCallTransformer.kt # IR: create<T>() → Impl(client)
│ └── KtorKitPluginKey.kt # tags every generated declaration
│
├── gradle-plugin/ # Wires the compiler plugin into kotlinc
├── sample/ # Runnable demo + JVM and diagnostic tests
└── tests-mp/ # The same suite on JVM, JS, and native
Applying the plugin is the whole installation — it adds the runtime dependency to your
main source set and puts the compiler plugin on kotlinc's classpath. Do not declare
com.adkhambek.ktor.kit:runtime yourself.
You supply two things it cannot choose for you: the kotlinx.serialization plugin (for
@Serializable request and response types) and a Ktor engine for each platform you
target.
// build.gradle.kts
plugins {
kotlin("jvm") version "2.4.10"
kotlin("plugin.serialization") version "2.4.10"
id("com.adkhambek.ktor.kit") version "<latest-version>"
}
dependencies {
implementation("io.ktor:ktor-client-cio:3.5.1") // any JVM engine
}Declare your API in commonMain; the plugin adds the runtime there, so every target
resolves its own variant. Engines are per-platform:
// build.gradle.kts
plugins {
kotlin("multiplatform") version "2.4.10"
kotlin("plugin.serialization") version "2.4.10"
id("com.adkhambek.ktor.kit") version "<latest-version>"
}
kotlin {
jvm()
androidTarget()
iosArm64()
iosSimulatorArm64()
sourceSets {
// No KtorKIT dependency here — the plugin adds it to commonMain.
androidMain.dependencies { implementation("io.ktor:ktor-client-okhttp:3.5.1") }
jvmMain.dependencies { implementation("io.ktor:ktor-client-cio:3.5.1") }
iosMain.dependencies { implementation("io.ktor:ktor-client-darwin:3.5.1") }
}
}Engines by platform:
| Platform | Engine artifact |
|---|---|
| JVM | io.ktor:ktor-client-cio · ktor-client-okhttp · ktor-client-java |
| Android | io.ktor:ktor-client-okhttp · ktor-client-android |
| iOS / macOS / watchOS / tvOS | io.ktor:ktor-client-darwin |
| JS (Node/browser) | io.ktor:ktor-client-js |
| Linux | io.ktor:ktor-client-curl |
| Windows | io.ktor:ktor-client-winhttp |
# gradle/libs.versions.toml
[versions]
kotlin = "2.4.10"
ktor = "3.5.1"
ktorkit = "<latest-version>"
[libraries]
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ktorkit = { id = "com.adkhambek.ktor.kit", version.ref = "ktorkit" }// build.gradle.kts
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ktorkit)
}
dependencies {
implementation(libs.ktor.client.cio)
}Everything resolves from Maven Central, so the defaults are enough. If your build
restricts repositories, mavenCentral() must be reachable from both blocks — the
first resolves the plugin, the second the runtime it adds:
// settings.gradle.kts
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
}
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}Important
Your Kotlin version must match the one KtorKIT was built against — 2.4.10. Compiler plugins bind to compiler internals that change between releases, so a mismatch fails the build rather than degrading gracefully. See Versions.
import com.adkhambek.ktor.kit.*
import kotlinx.serialization.Serializable
@Serializable
data class Post(val id: Int, val userId: Int, val title: String, val body: String)
@ContributesAPI
interface PostsApi {
@GET("posts")
suspend fun listPosts(): List<Post>
@GET("posts/{id}")
suspend fun getPost(@Path("id") id: Int): Post
@POST("posts")
suspend fun createPost(@Body post: Post): Post
}import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
val client = KtorClient(
baseUrl = "https://jsonplaceholder.typicode.com/",
httpClient = HttpClient(CIO),
)
val api = client.create<PostsApi>()
val posts = api.listPosts() // List<Post>
val post = api.getPost(id = 1) // PostApplied to functions inside a @ContributesAPI interface. Each function must have exactly one verb annotation.
| Annotation | Description |
|---|---|
@GET |
HTTP GET request |
@POST |
HTTP POST request |
@PUT |
HTTP PUT request |
@DELETE |
HTTP DELETE request |
@PATCH |
HTTP PATCH request |
@HEAD |
HTTP HEAD request |
@OPTIONS |
HTTP OPTIONS request |
@GET("users/{id}")
suspend fun getUser(@Path("id") id: Int): User| Annotation | Target | Description |
|---|---|---|
@Path("name") |
Parameter | Replaces {name} in the URL path template. Values are percent-encoded. |
@Query("name") |
Parameter | Adds a query parameter ?name=value |
@QueryMap |
Parameter | Adds all entries from a Map<String, Any?> as query params |
@Body |
Parameter | Sets the request body |
@Header("name") |
Parameter | Adds a request header |
@HeaderMap |
Parameter | Adds all entries from a Map<String, Any?> as headers |
@Field("name") |
Parameter | Adds a form field (requires @FormUrlEncoded) |
@FieldMap |
Parameter | Adds all entries from a Map<String, Any?> as form fields (requires @FormUrlEncoded) |
@Part("name") |
Parameter | Adds a multipart part (requires @Multipart) |
@Part(value = "name", fileName = "f.txt") |
Parameter | Adds a multipart part as a file upload; pair with ByteArray |
@Url |
Parameter | Overrides the entire request URL |
Null values are dropped for @Query, @QueryMap, @Header, @HeaderMap, @Field, @FieldMap, and @Part.
| Annotation | Description |
|---|---|
@ContributesAPI |
Marks an interface for implementation generation |
@ContributesAPI(baseUrl = "…") |
Sets a per-API base URL (overrides KtorClient.baseUrl) |
| Annotation | Description |
|---|---|
@Headers("X-Key: value", …) |
Adds static headers to every call of this function |
@FormUrlEncoded |
Sends the request body as application/x-www-form-urlencoded |
@Multipart |
Sends the request body as multipart/form-data |
@Body behaves differently depending on the parameter type:
| Parameter type | Behavior |
|---|---|
String |
Sent verbatim; content type left to Ktor's default |
Any @Serializable T |
Encoded to JSON at compile time; Content-Type: application/json set |
List<T>, Map<K, V> |
Encoded to a JSON array / object |
T? |
Encoded as JSON, null becomes the literal null |
@ContributesAPI
interface ItemsApi {
// Sent as {"name":"widget","qty":3} with application/json
@POST("items")
suspend fun create(@Body item: CreateItem): Item
// Sent verbatim as text
@POST("raw")
suspend fun raw(@Body payload: String): String
}Serialization happens in the compiler, using the same kotlinx.serialization machinery as return types — no ContentNegotiation plugin is required on the HttpClient.
Annotate the function @Multipart and each part @Part. A part with a fileName is
sent as a file upload; pair it with a ByteArray. Everything else is sent as a text part
using its toString().
@ContributesAPI
interface UploadApi {
@Multipart
@POST("upload")
suspend fun upload(
@Part("meta") meta: String,
@Part(value = "file", fileName = "notes.txt") file: ByteArray,
): UploadResult
}Content-Type: multipart/form-data and the boundary are set for you. Null parts are dropped.
Return Flow<T> to consume a response incrementally instead of buffering it. The body is
read one line at a time — the newline-delimited JSON (NDJSON) shape used by most streaming
APIs — and each non-blank line is decoded as T.
@ContributesAPI
interface EventsApi {
@GET("stream/{n}")
fun records(@Path("n") n: Int): Flow<Record> // note: not suspend
@GET("logs")
fun logLines(): Flow<String> // raw lines, undecoded
}The request is issued lazily when collection starts, and the connection stays open for the
lifetime of the collection. Collecting the same Flow twice issues two requests.
Flow<String> emits raw lines verbatim; Flow<T> for any other T decodes each line and
skips blank ones.
Functions can return any of the following:
| Return Type | Behavior |
|---|---|
String |
Returns the raw response body as a string |
T |
Deserializes the response body as T using kotlinx.serialization |
T? |
Deserializes, accepting a JSON null |
List<T> |
Deserializes a JSON array |
Map<K, V> |
Deserializes a JSON object as a map |
Response<String> |
Wraps the raw body with HTTP status and headers |
Response<T> |
Wraps a deserialized body with HTTP status and headers |
Flow<String> |
Streams the body as raw lines |
Flow<T> |
Streams the body, decoding one JSON value per line |
Unit |
Performs the request and discards the body |
Generic types nest, so List<Map<String, Int>> and Response<List<Post>> both work.
Unit is for endpoints whose response carries nothing worth decoding:
@DELETE("posts/{id}")
suspend fun delete(@Path("id") id: Int)Functions returning Flow<T> are declared without suspend; every other return type
requires suspend.
The Response<T> wrapper gives access to:
response.status // HttpStatusCode
response.headers // Headers
response.body // T
response.isSuccess // true if status is 2xx@Serializable
data class HttpBinResponse(
val url: String,
val method: String,
val args: Map<String, String> = emptyMap(),
val headers: Map<String, String> = emptyMap(),
val data: String = "",
val form: Map<String, String> = emptyMap(),
)
@Serializable
data class CreateItem(val name: String, val qty: Int)
@ContributesAPI
interface HttpBinApi {
@GET("anything/{slug}")
suspend fun typedAnything(
@Path("slug") slug: String,
@Query("q") q: String,
): HttpBinResponse
@Headers("X-Static-A: alpha")
@GET("anything/withheaders")
suspend fun withHeaders(
@Header("X-Token") token: String,
@HeaderMap extra: Map<String, Any?>,
@QueryMap params: Map<String, Any?>,
): HttpBinResponse
@POST("anything")
suspend fun postJson(@Body payload: CreateItem): HttpBinResponse
@FormUrlEncoded
@POST("anything")
suspend fun submitForm(
@Field("name") name: String,
@FieldMap extra: Map<String, Any?>,
): HttpBinResponse
@GET("status/{code}")
suspend fun statusEndpoint(@Path("code") code: Int): Response<String>
}
fun main() = runBlocking {
val client = KtorClient(
baseUrl = "https://httpbin.org/",
httpClient = HttpClient(CIO),
)
val api = client.create<HttpBinApi>()
val result = api.typedAnything(slug = "hello", q = "world")
println(result.args["q"]) // "world"
val posted = api.postJson(CreateItem(name = "widget", qty = 3))
println(posted.data) // {"name":"widget","qty":3}
val form = api.submitForm(name = "ada", extra = mapOf("role" to "admin"))
println(form.form) // {name=ada, role=admin}
val status = api.statusEndpoint(code = 404)
println(status.isSuccess) // false
println(status.status.value) // 404
}The compiler plugin validates your API definitions and reports errors at compile time:
| Error | Cause |
|---|---|
@ContributesAPI may only be applied to an interface |
Applied to a class, object, or abstract class |
must declare exactly one HTTP verb annotation |
Function has no @GET/@POST/etc. |
declares more than one HTTP verb annotation |
Function has multiple verb annotations |
@Path("x") has no matching {x} placeholder |
@Path name doesn't match any {placeholder} in the URL |
URL template contains {x} but no @Path("x") |
URL has a {placeholder} with no corresponding @Path parameter |
@Field/@FieldMap requires the function to be annotated @FormUrlEncoded |
Form parameters would otherwise be silently dropped |
@Part requires the function to be annotated @Multipart |
Multipart parts would otherwise be silently dropped |
Function declares both @FormUrlEncoded and @Multipart |
Two conflicting body encodings on one function |
@Body cannot be combined with @FormUrlEncoded or @Multipart |
The encoded form wins, so the body would be silently dropped |
Parameter has no KtorKit annotation |
The argument would never be sent |
Parameter declares more than one KtorKit binding annotation |
Which one wins would be arbitrary |
cannot build a serializer for 'X' |
A return type or @Body type is not @Serializable |
The first eleven are FIR checkers, so they appear as you type. The last is raised during IR generation — serializability is only knowable once types are resolved — but it is still a normal compiler error with a file and line, not an internal exception.
Several of these exist because the alternative is a request that is silently wrong on the
wire: a @Field without @FormUrlEncoded, a @Body alongside an encoded form, or a
parameter with no annotation at all would each compile fine and quietly send nothing.
KtorKIT is a K2 compiler plugin. It never generates source files, never runs an annotation processor, and uses no reflection at runtime — the implementation is materialized inside the compiler itself, across two phases.
┌──────────────── FRONTEND (FIR) ────────────────┐ ┌──── BACKEND (IR) ────┐
your checkers declaration generation body filling call rewriting
code ─────────────▶ ────────────────────────────▶ ───────────────────▶ ─────────────────▶ .class
validate declare PostsApi.Impl fill in the create<T>()
annotations (signatures, no bodies) method bodies → Impl(client)
Why two phases? FIR is where the compiler decides what exists. The Impl class must be
declared there so that the rest of the frontend — and your own code calling create<PostsApi>()
— sees a real type with real members and type-checks against it. But FIR declaration generation
can only describe signatures, not bodies. So IR, which runs after type-checking, supplies the
actual code.
KtorKitDeclarationGenerationExtension registers a LookupPredicate for
@ContributesAPI and answers three questions the compiler asks it:
| Compiler asks | Plugin answers |
|---|---|
getNestedClassifiersNames |
"this interface has a nested class named Impl" |
generateNestedClassLikeDeclaration |
a final class Impl : PostsApi |
getCallableNamesForClass → generateConstructors / generateFunctions |
a constructor taking KtorClient, plus one override per abstract interface function |
Each generated function copies the interface function's name, value parameters, return type,
and suspend modifier. Given this input:
@ContributesAPI
interface PostsApi {
@GET("posts/{id}")
suspend fun getPost(@Path("id") id: Int): Post
}FIR produces the equivalent of:
interface PostsApi {
class Impl(client: KtorClient) : PostsApi {
override suspend fun getPost(id: Int): Post // declared, still bodiless
}
}Everything generated is tagged with KtorKitPluginKey, a GeneratedDeclarationKey. That tag is
how the IR phase later recognizes its own declarations — it never guesses from names.
If you declare your own nested Impl, the plugin backs off entirely and generates nothing.
Running alongside is KtorKitFirCheckersExtension, which contributes the
diagnostics. Validation happens here, in the frontend, which is why
a mismatched {placeholder} is a red squiggle rather than a runtime surprise.
GeneratedBodyFiller walks the IR tree and processes any class whose origin is
GeneratedByPlugin(KtorKitPluginKey). For each one it adds a private client field, writes the
constructor body, then writes each method body as a flat call chain against the runtime's
RequestBuilder. The example above becomes:
class Impl(client: KtorClient) : PostsApi {
private val client: KtorClient = client
override suspend fun getPost(id: Int): Post {
val rb = client.beginRequest("GET", "posts/{id}")
rb.path("id", id)
return rb.executeWithDeserializer(Post.serializer())
}
}Bindings are emitted in a fixed order: base-URL override, static @Headers, the
@FormUrlEncoded/@Multipart marker, then one call per annotated parameter, then the terminal
execute call.
You can confirm this on your own build — nothing here is hand-waving:
./gradlew :sample:compileKotlin
javap -p -c sample/build/classes/kotlin/main/sample/HttpBinApi\$Impl.classThe disassembly shows the client field, the constructor storing it, and each method opening
with ExecuteKt.beginRequest and closing with one of the RequestBuilder.execute* calls.
A subtlety worth knowing: the generated Impl.getPost override carries no annotations —
@GET and @Path live on the interface function. So every annotation lookup falls back
through overriddenSymbols to find them. This is why httpMethodOf, isFormEncoded,
isMultipart, and paramAnnotation all search the overridden declaration rather than the
function in hand.
The return type alone decides how the response is consumed:
| Return type | Emitted call |
|---|---|
String |
executeAsString() |
Unit |
executeIgnoringBody() |
Response<String> |
executeAsResponseString() |
Response<T> |
executeAsResponseWithDeserializer(ser) |
Flow<String> |
executeAsFlowOfString() |
Flow<T> |
executeAsFlow(ser) |
| anything else | executeWithDeserializer(ser) |
resolveSerializer builds a KSerializer expression by recursing on the type, which is what
lets nested generics work and what keeps the whole thing reflection-free:
| Type | Emitted expression |
|---|---|
T? |
resolve(T).nullable |
List<T> |
ListSerializer(resolve(T)) |
Map<K, V> |
MapSerializer(resolve(K), resolve(V)) |
Int, String, … |
Int.serializer() — the kotlinx.serialization.builtins extension |
any @Serializable |
T.Companion.serializer() |
So Response<List<Map<String, Int>>> resolves by peeling one layer at a time down to
primitives. Because the serializer is chosen during compilation, this works identically on
Kotlin/Native and Kotlin/JS, where runtime reflection isn't available.
When a type bottoms out with no serializer, the plugin reports a normal compiler error through
the MessageCollector and returns a kotlin.error(…) call as a placeholder. Since error
returns Nothing, that stub type-checks wherever a value was expected, so the build fails on the
readable diagnostic instead of an internal exception with a stack trace.
The runtime declaration is deliberately a trap:
inline fun <reified T : Any> KtorClient.create(): T =
error("KtorKit compiler plugin not applied — call site of create<${T::class.simpleName}>() was not rewritten")That body only ever runs if the plugin is missing, and says so. Normally CreateCallTransformer
finds every call to com.adkhambek.ktor.kit.create, reads the reified type argument, locates the nested
Impl, and replaces the entire call with a direct constructor invocation:
val api = client.create<PostsApi>() // you write this
val api = PostsApi.Impl(client) // the compiler emits thisNo service lookup, no proxy, no reflection — just a constructor call.
Emitting IR is verbose, so the plugin emits as little of it as possible. All the actual HTTP
work — URL assembly, percent-encoding, null-dropping, multipart assembly, line-by-line
streaming — lives in ordinary Kotlin inside RequestBuilder. The plugin only emits a linear
sequence of calls into it. That keeps the IR generator small enough to reason about, and means
most behavior can be tested as plain library code.
compiler declares KtorKitCompilerPluginRegistrar in
META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar, which is how
kotlinc discovers it. The registrar sets supportsK2 = true, registers both extensions, and
passes the build's MessageCollector down to the IR phase for diagnostics.
The gradle-plugin module is a KotlinCompilerPluginSupportPlugin: it adds
com.adkhambek.ktor.kit:runtime to your dependencies and tells the Kotlin Gradle Plugin to put
com.adkhambek.ktor.kit:compiler on the compiler classpath. Both coordinates come from
KTORKIT_GROUP / KTORKIT_VERSION constants generated at build time from GROUP and
VERSION_NAME, so the coordinates are declared exactly once.
./gradlew buildRun the sample (makes live requests to httpbin.org and jsonplaceholder.typicode.com):
./gradlew :sample:runRun tests:
./gradlew :sample:test # JVM tests + compiler diagnostic tests
./gradlew :tests-mp:allTests # Multiplatform tests (JVM + JS + macOS native)- Kotlin 2.4.10 (K2) — must match exactly; see Versions
- JDK 21+
- Gradle 9.6.1+ (wrapper included)
The suite runs on every supported platform:
| Suite | Count | Targets |
|---|---|---|
:sample:test |
139 | JVM — functional tests plus compiler-diagnostic tests driven through kotlin-compile-testing |
:tests-mp:allTests |
104 × 3 | JVM, JS (Node), macOS native — the same suite compiled and run per target |
integration-tests/ |
8 | Two standalone builds that apply the published Gradle plugin |
That is 451 test executions in the main build, plus the integration consumers.
The integration builds matter more than their size suggests. Everything else wires the
compiler with -Xplugin= directly, so nothing else exercises the Gradle plugin — the one
path every real consumer takes. They are separate Gradle builds that resolve KtorKIT from
a repository, one kotlin("jvm") and one kotlin("multiplatform"):
./gradlew publishToMavenLocal
(cd integration-tests/jvm-consumer && ../../gradlew build)
(cd integration-tests/kmp-consumer && ../../gradlew build)The diagnostic tests are worth a note: they run the real compiler in-process via
kotlin-compile-testing, feeding it a source snippet and asserting on the exit code and the
emitted message. That is how every error in the table above is verified — including the
negative cases, where a well-formed API must still compile cleanly.
:sample:run additionally exercises every feature end-to-end against live
httpbin.org and jsonplaceholder.typicode.com endpoints.
- Server-sent events (
text/event-stream) as a first-class return type —Flow<String>can consume the raw lines - Custom converters or interceptor pipelines beyond what the Ktor
HttpClientprovides - Per-call timeout or retry annotations — configure these on the
HttpClientinstead
MIT © Adkhambek
