A Gradle plugin that generates type-safe Kotlin Ktor client code from OpenAPI 3.0 specifications.
Add the plugin to your build.gradle.kts:
plugins {
id("com.avsystem.justworks") version "<version>"
}The plugin and core library are published to Maven Central:
com.avsystem.justworks:plugin-- Gradle plugincom.avsystem.justworks:core-- OpenAPI parser and code generator
Configure one or more OpenAPI specs in the justworks extension:
justworks {
specs {
register("petstore") {
specFile = file("api/petstore.yaml")
packageName = "com.example.petstore"
}
register("payments") {
specFile = file("api/payments.yaml")
packageName = "com.example.payments"
// Optional: override default sub-packages
apiPackage = "com.example.payments.client"
modelPackage = "com.example.payments.dto"
}
}
}Each spec gets its own Gradle task (justworksGenerate<Name>) and output directory. Run all generators at once with:
./gradlew justworksGenerateAllGenerated sources are automatically wired into Kotlin source sets, so compileKotlin depends on code generation -- no
extra configuration needed.
| Property | Required | Default | Description |
|---|---|---|---|
specFile |
Yes | -- | Path to the OpenAPI spec (.yaml/.json) |
packageName |
Yes | -- | Base package for generated code |
apiPackage |
No | $packageName.api |
Package for API client classes |
modelPackage |
No | $packageName.model |
Package for model/data classes |
| OpenAPI type | Format | Kotlin type |
|---|---|---|
string |
(default) | String |
string |
date |
LocalDate |
string |
date-time |
Instant |
string |
uuid |
Uuid |
string |
byte |
ByteArray |
string |
binary |
ByteArray |
integer |
int32 |
Int |
integer |
int64 |
Long |
number |
float |
Float |
number |
double |
Double |
boolean |
-- | Boolean |
array |
-- | List<T> |
object |
-- | data class |
additionalProperties |
-- | Map<String, T> |
Other string formats (email, uri, hostname, etc.) are kept as String.
| Feature | Support | Generated Kotlin |
|---|---|---|
allOf |
Full | Merged data class (properties from all schemas) |
oneOf with discriminator |
Full | sealed interface + variant data classes with @JsonClassDiscriminator |
anyOf with discriminator |
Full | Same as oneOf |
anyOf without discriminator |
Partial | sealed interface + JsonContentPolymorphicSerializer (field-presence heuristic) |
| Discriminator mapping | Full | @SerialName on variants |
A SerializersModule is auto-generated when discriminated polymorphic types are present.
- Enums -- generated as
enum classwith@SerialNameper constant. String and integer backing types supported. - Required properties -- non-nullable constructor parameters.
- Optional properties -- nullable with
= nulldefault. - Default values -- supported for primitives, dates, and enum references.
- Inline schemas -- auto-named from context (e.g.
CreatePetRequest) and deduplicated by structure.
| Feature | Status |
|---|---|
| Path parameters | Supported |
| Query parameters | Supported |
| Header parameters | Supported |
| Cookie parameters | Not yet supported |
application/json request body |
Supported |
| Form data / multipart | Not supported |
Callbacks, links, webhooks, XML content types, and OpenAPI vendor extensions (x-*) are not processed. The plugin logs
warnings for callbacks and links found in a spec.
The plugin produces two categories of output: shared types (generated once) and per-spec types (generated per registered spec).
build/generated/justworks/
├── shared/kotlin/
│ └── com/avsystem/justworks/
│ ├── ApiClientBase.kt # Abstract base class + helper extensions
│ ├── HttpError.kt # HttpErrorType enum + HttpError data class
│ └── HttpSuccess.kt # HttpSuccess<T> data class
│
└── specName/
└── com/example/
├── model/
│ ├── Pet.kt # @Serializable data class
│ ├── PetStatus.kt # @Serializable enum class
│ ├── Shape.kt # sealed interface (oneOf/anyOf)
│ ├── Circle.kt # variant data class : Shape
│ ├── UuidSerializer.kt # (if spec uses UUID fields)
│ └── SerializersModule.kt # (if spec has polymorphic types)
└── api/
└── PetsApi.kt # Client class per OpenAPI tag
- Data classes -- one per named schema. Properties annotated with
@SerialName, sorted required-first. - Enums -- constants in
UPPER_SNAKE_CASEwith@SerialNamefor the wire value. - Sealed interfaces -- for
oneOf/anyOfschemas. Variants are separate data classes implementing the interface. - SerializersModule -- top-level
val generatedSerializersModuleregistering all polymorphic hierarchies. Only generated when needed.
One client class per OpenAPI tag (e.g. pets tag -> PetsApi). Untagged endpoints go to DefaultApi.
Each endpoint becomes a suspend function with context(Raise<HttpError>) that returns HttpSuccess<T>.
| Task | Description |
|---|---|
justworksSharedTypes |
Generates shared types (once per build) |
justworksGenerate<Name> |
Generates code for one spec (depends on shared types) |
justworksGenerateAll |
Aggregate -- triggers all spec tasks |
compileKotlin depends on justworksGenerateAll, so generation runs automatically.
Override the default api / model sub-packages per spec:
justworks {
specs {
register("petstore") {
specFile = file("api/petstore.yaml")
packageName = "com.example.petstore"
apiPackage = "com.example.petstore.client" // default: packageName.api
modelPackage = "com.example.petstore.dto" // default: packageName.model
}
}
}The generated HttpClient is created without an explicit engine, so Ktor uses whichever engine is on the classpath.
Switch engines by changing your dependency:
dependencies {
// Pick one:
implementation("io.ktor:ktor-client-cio:3.1.1") // CIO (default, pure Kotlin)
implementation("io.ktor:ktor-client-okhttp:3.1.1") // OkHttp
implementation("io.ktor:ktor-client-apache:3.1.1") // Apache
}The internal Json instance uses default settings (ignoreUnknownKeys = false, isLenient = false). If you need a
custom Json for use outside the client, configure it yourself and include the generated SerializersModule when your
spec has polymorphic types:
val json = Json {
ignoreUnknownKeys = true
serializersModule = com.example.petstore.model.generatedSerializersModule
}After running code generation, the plugin produces type-safe Kotlin client classes. Here is how to use them.
Add the required runtime dependencies and enable the experimental context parameters compiler flag:
kotlin {
compilerOptions {
freeCompilerArgs.add("-Xcontext-parameters")
}
}
dependencies {
implementation("io.ktor:ktor-client-core:3.1.1")
implementation("io.ktor:ktor-client-cio:3.1.1") // or another engine (OkHttp, Apache, etc.)
implementation("io.ktor:ktor-client-content-negotiation:3.1.1")
implementation("io.ktor:ktor-serialization-kotlinx-json:3.1.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")
implementation("io.arrow-kt:arrow-core:2.2.1.1")
}Each generated client extends ApiClientBase and creates its own pre-configured HttpClient internally.
You only need to provide the base URL and authentication credentials.
Class names are derived from OpenAPI tags as <Tag>Api (e.g., a pets tag produces PetsApi). Untagged endpoints go
to DefaultApi.
val client = PetsApi(
baseUrl = "https://api.example.com",
token = { "your-bearer-token" },
)The token parameter is a () -> String lambda called on every request and sent as a Bearer token in the
Authorization header. This lets you supply a provider that refreshes automatically:
val client = PetsApi(
baseUrl = "https://api.example.com",
token = { tokenStore.getAccessToken() },
)The client implements Closeable -- call client.close() when done to release HTTP resources.
Every endpoint becomes a suspend function on the client. Functions use
Arrow's Raise for structured error handling -- they require a
context(Raise<HttpError>) and return HttpSuccess<T> on success:
// Inside a Raise<HttpError> context (e.g., within either { ... })
val result: HttpSuccess<List<Pet>> = client.listPets(limit = 10)
println(result.body) // the deserialized response body
println(result.code) // the HTTP status codePath, query, and header parameters map to function arguments. Optional parameters default to null:
val result = client.findPets(status = "available", limit = 20)Generated endpoints use Arrow's Raise -- errors are raised, not returned as
Either. Use Arrow's either { ... } block to obtain an Either<HttpError, HttpSuccess<T>>:
val result: Either<HttpError, HttpSuccess<Pet>> = either {
client.getPet(petId = 123)
}
result.fold(
ifLeft = { error ->
when (error.type) {
HttpErrorType.Client -> println("Client error ${error.code}: ${error.message}")
HttpErrorType.Server -> println("Server error ${error.code}: ${error.message}")
HttpErrorType.Redirect -> println("Redirect ${error.code}")
HttpErrorType.Network -> println("Connection failed: ${error.message}")
}
},
ifRight = { success ->
println("Found: ${success.body.name}")
}
)HttpError is a data class with the following fields:
| Field | Type | Description |
|---|---|---|
code |
Int |
HTTP status code (or 0 for network errors) |
message |
String |
Response body text or exception message |
type |
HttpErrorType |
Category of the error |
HttpErrorType categorizes errors:
HttpErrorType value |
Covered statuses / scenario |
|---|---|
Client |
HTTP 4xx client errors |
Server |
HTTP 5xx server errors |
Redirect |
HTTP 3xx redirect responses |
Network |
I/O failures, timeouts, DNS issues |
Network errors (connection timeouts, DNS failures) are caught and reported as
HttpError(code = 0, ..., type = HttpErrorType.Network) instead of propagating exceptions.
Releases are published to Maven Central automatically when a version tag (v*) is
pushed. The CD pipeline runs CI checks first, then publishes signed artifacts via
the vanniktech maven-publish plugin.
To trigger a release:
git tag v1.0.0
git push origin v1.0.0The version is derived from the tag (stripping the v prefix). Without a tag, the version defaults to 0.0.1-SNAPSHOT.
Requires JDK 21+.
# Run tests and linting
./gradlew check
# Run only unit tests
./gradlew test
# Run functional tests (plugin module)
./gradlew plugin:functionalTest
# Lint check
./gradlew ktlintCheckApache License 2.0