Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,194 @@ Generated sources are automatically wired into Kotlin source sets, so `compileKo
| `apiPackage` | No | `$packageName.api` | Package for API client classes |
| `modelPackage` | No | `$packageName.model` | Package for model/data classes |

## Generated Client Usage

After running code generation, the plugin produces type-safe Kotlin client classes.
Here is how to use them.

### Dependencies

Add the required runtime dependencies to your consuming project:

```kotlin
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.1.2")
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dependency versions in this snippet are inconsistent with the versions used elsewhere in the repo (e.g. functional tests and core module use io.arrow-kt:arrow-core:2.2.1.1). Consider updating the README sample to match the repo’s validated versions to reduce copy/paste breakage.

Suggested change
implementation("io.arrow-kt:arrow-core:2.1.2")
implementation("io.arrow-kt:arrow-core:2.2.1.1")

Copilot uses AI. Check for mistakes.
}
```

### Creating the Client

Each generated client extends `ApiClientBase` and creates its own pre-configured `HttpClient` internally.
You only need to provide the base URL and authentication credentials:

```kotlin
val client = PetstoreApi(
baseUrl = "https://api.example.com",
token = { "your-bearer-token" },
)
```

Auth parameters are lambdas (`() -> String`), so you can supply a token provider that refreshes automatically:

```kotlin
val client = PetstoreApi(
baseUrl = "https://api.example.com",
token = { tokenStore.getAccessToken() },
)
```

The client implements `Closeable` -- call `client.close()` when done to release HTTP resources.

### Authentication

The generated constructor signature depends on the security schemes defined in your OpenAPI spec:

**Bearer Token** (single scheme):

```kotlin
val client = PetstoreApi(
baseUrl = "https://api.example.com",
token = { "your-bearer-token" },
)
```

**API Key** (sent as header or query parameter based on the spec):

```kotlin
val client = PetstoreApi(
baseUrl = "https://api.example.com",
myApiKey = { "your-api-key" },
)
```

**HTTP Basic**:

```kotlin
val client = PetstoreApi(
baseUrl = "https://api.example.com",
myAuthUsername = { "user" },
myAuthPassword = { "pass" },
)
```

**No Authentication** (spec has no security schemes):

```kotlin
val client = PetstoreApi(
baseUrl = "https://api.example.com",
)
```

When the spec defines multiple security schemes, the constructor includes a parameter for each one.

Comment on lines +102 to +143
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The authentication guidance here claims the generated constructor signature varies by OpenAPI security schemes (Bearer/API key/Basic/none) and that multiple schemes add multiple constructor params. In the current generator/runtime, the client constructor always includes a token: () -> String and applyAuth() always emits Authorization: Bearer ... (no API key/basic/no-auth support). Either implement the described auth behaviors or adjust this section to reflect the actual capabilities.

Copilot uses AI. Check for mistakes.
### Making Requests

Every endpoint becomes a `suspend` function on the client. The return type is `HttpResult<E, T>`, where `E` is the error body type and `T` is the success body type:

```kotlin
val result: HttpResult<JsonElement, List<Pet>> = client.listPets(limit = 10)
```

Path, query, and header parameters map to function arguments. Optional parameters default to `null`:

```kotlin
val result = client.findPets(status = "available", limit = 20)
```

### Error Handling

`HttpResult<E, T>` is a typealias for `Either<HttpError<E>, HttpSuccess<T>>` (using [Arrow](https://arrow-kt.io/)).
Every API call returns a result instead of throwing exceptions:

```kotlin
when (val result = client.getPet(petId = 123)) {
is Either.Right -> {
val pet = result.value.body
println("Found: ${pet.name}")
}
is Either.Left -> when (val error = result.value) {
is HttpError.NotFound -> println("Pet not found")
is HttpError.Unauthorized -> println("Auth required")
is HttpError.Network -> println("Connection failed: ${error.cause}")
else -> println("Error ${error.statusCode}: ${error.body}")
}
}
```
Comment on lines +144 to +176
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This README section describes generated endpoints as returning HttpResult<E, T> (Arrow Either) and demonstrates pattern matching on Either.Left/Right, but the current generator produces suspend fun ...: HttpSuccess<T> with a context(Raise<HttpError>) receiver and HttpError is a data class (see ApiClientBaseGenerator/ApiResponseGenerator). The usage guide should be updated to match the actual generated API (how to call endpoints inside a raise context, and how errors are represented).

Copilot uses AI. Check for mistakes.

`HttpError` covers specific HTTP status codes as sealed subtypes:

| Subtype | Status |
|------------------------|--------|
| `BadRequest` | 400 |
| `Unauthorized` | 401 |
| `Forbidden` | 403 |
| `NotFound` | 404 |
| `MethodNotAllowed` | 405 |
| `Conflict` | 409 |
| `Gone` | 410 |
| `UnprocessableEntity` | 422 |
| `TooManyRequests` | 429 |
| `InternalServerError` | 500 |
| `BadGateway` | 502 |
| `ServiceUnavailable` | 503 |
| `Network` | -- |
| `Other` | any |

Network errors (connection timeouts, DNS failures) are caught and wrapped in `HttpError.Network` instead of propagating exceptions.
Comment on lines +178 to +197
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HttpError is documented here as having many sealed subtypes for specific status codes (BadRequest/Unauthorized/etc) and a HttpError.Network subtype, but the generated runtime currently defines HttpErrorType enum + HttpError(code, message, type) data class (no per-status sealed hierarchy). This table/description should be rewritten to match the actual error model or the runtime should be extended to provide the documented subtypes.

Copilot uses AI. Check for mistakes.

### Serialization Setup

Generated models use `@Serializable` from kotlinx.serialization. The client sets up JSON content negotiation internally via the `createHttpClient()` method.

If your spec uses polymorphic types (`oneOf` / `anyOf` with discriminators), the generator produces a `SerializersModule` that is automatically registered with the internal JSON instance. No manual serialization configuration is needed.

If you need to customize the JSON configuration for external use (e.g., parsing API responses outside the client), use the same settings:

```kotlin
val json = Json {
ignoreUnknownKeys = true // recommended: specs may evolve
isLenient = true // optional: tolerant parsing
}
```

For polymorphic types, register the generated `SerializersModule`:

```kotlin
val json = Json {
ignoreUnknownKeys = true
serializersModule = com.example.petstore.model.generatedSerializersModule
}
```

### Multi-Spec Configuration

When your project consumes multiple APIs, register each spec separately.
The plugin generates independent client classes per spec:

```kotlin
justworks {
specs {
register("petstore") {
specFile = file("api/petstore.yaml")
packageName = "com.example.petstore"
}
register("payments") {
specFile = file("api/payments.yaml")
packageName = "com.example.payments"
apiPackage = "com.example.payments.client"
modelPackage = "com.example.payments.dto"
}
}
}
```

Each spec gets its own Gradle task (`justworksGenerate<Name>`) and output directory. The generated clients are independent and can be used side by side.

## Publishing

Releases are published to [Maven Central](https://central.sonatype.com/) automatically when a version tag (`v*`) is pushed. The CD pipeline runs CI checks first, then publishes signed artifacts via the [vanniktech maven-publish](https://github.com/vanniktech/gradle-maven-publish-plugin) plugin.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import com.squareup.kotlinpoet.KModifier
import com.squareup.kotlinpoet.LambdaTypeName
import com.squareup.kotlinpoet.MemberName
import com.squareup.kotlinpoet.ParameterSpec
import com.squareup.kotlinpoet.ParameterizedTypeName
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import com.squareup.kotlinpoet.PropertySpec
import com.squareup.kotlinpoet.STRING
Expand Down Expand Up @@ -114,11 +115,37 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage:
)
}

val kdocLines = buildList {
endpoint.summary?.let { add(it) }
endpoint.description?.let {
if (isNotEmpty()) add("")
add(it)
}
val paramDocs = endpoint.parameters.filter { it.description != null }
if (paramDocs.isNotEmpty() && isNotEmpty()) add("")
paramDocs.forEach { param ->
add("@param ${param.name.toCamelCase()} ${param.description}")
}
if (returnBodyType != UNIT) {
if (isNotEmpty()) add("")
add("@return [HttpSuccess] containing [${returnBodyType.simpleTypeName()}] on success")
}
}
if (kdocLines.isNotEmpty()) {
funBuilder.addKdoc("%L", kdocLines.joinToString("\n"))
}

funBuilder.addCode(buildFunctionBody(endpoint, params, returnBodyType))

return funBuilder.build()
}

private fun TypeName.simpleTypeName(): String = when (this) {
is ClassName -> simpleName
is ParameterizedTypeName -> rawType.simpleName
else -> toString()
}

private fun buildNullableParameter(
typeRef: TypeRef,
name: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ class ModelGenerator(private val modelPackage: String) {
.builder(kotlinName, type)
.initializer(kotlinName)
.addAnnotation(AnnotationSpec.builder(SERIAL_NAME).addMember("%S", prop.name).build())
.apply { if (prop.description != null) addKdoc("%L", prop.description) }
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The property/enum KDoc is added whenever description != null, which will also emit empty KDoc blocks for "" (or whitespace-only) descriptions coming from specs. Consider using !description.isNullOrBlank() before calling addKdoc(...) to avoid generating meaningless KDoc in the output.

Suggested change
.apply { if (prop.description != null) addKdoc("%L", prop.description) }
.apply { prop.description?.takeIf { it.isNotBlank() }?.let { addKdoc("%L", it) } }

Copilot uses AI. Check for mistakes.
.build()
}

Expand Down Expand Up @@ -415,6 +416,7 @@ class ModelGenerator(private val modelPackage: String) {
val anonymousClass = TypeSpec
.anonymousClassBuilder()
.addAnnotation(AnnotationSpec.builder(SERIAL_NAME).addMember("%S", value).build())
.apply { enum.valueDescriptions[value]?.let { addKdoc("%L", it) } }
.build()
typeSpec.addEnumConstant(value.toEnumConstantName(), anonymousClass)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ data class Endpoint(
val method: HttpMethod,
val operationId: String,
val summary: String?,
val description: String? = null,
val tags: List<String>,
val parameters: List<Parameter>,
val requestBody: RequestBody?,
Expand Down Expand Up @@ -95,6 +96,7 @@ data class EnumModel(
val description: String?,
val type: EnumBackingType,
val values: List<String>,
val valueDescriptions: Map<String, String> = emptyMap(),
)

enum class EnumBackingType {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ object SpecParser {
method = method,
operationId = operationId,
summary = operation.summary,
description = operation.description,
tags = operation.tags.orEmpty(),
parameters = mergedParams,
requestBody = requestBody,
Expand Down Expand Up @@ -233,12 +234,21 @@ object SpecParser {
)
}

private fun extractEnumModel(name: String, schema: Schema<*>): EnumModel = EnumModel(
name = name,
description = schema.description,
type = EnumBackingType.parse(schema.type) ?: EnumBackingType.STRING,
values = schema.enum.map { it.toString() },
)
private fun extractEnumModel(name: String, schema: Schema<*>): EnumModel {
val enumValues = schema.enum.map { it.toString() }
val valueDescriptions = when (val ext = schema.extensions?.get("x-enum-descriptions")) {
is List<*> -> enumValues.zip(ext.map { it.toString() }).toMap()
is Map<*, *> -> ext.entries.associate { (k, v) -> k.toString() to v.toString() }
Comment on lines +240 to +241
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The x-enum-descriptions handling for list values silently zips by index and will truncate on length mismatch (and also converts nulls to the literal string "null"). This can easily mis-assign descriptions to enum values without any signal. Consider validating that the list size matches schema.enum (fail parsing or at least ignore the extension when sizes differ) and filtering out null description entries instead of toString()-ing them.

Suggested change
is List<*> -> enumValues.zip(ext.map { it.toString() }).toMap()
is Map<*, *> -> ext.entries.associate { (k, v) -> k.toString() to v.toString() }
is List<*> -> {
if (ext.size != enumValues.size) {
emptyMap()
} else {
enumValues.indices
.mapNotNull { idx ->
val description = ext[idx]
description?.toString()?.let { enumValues[idx] to it }
}
.toMap()
}
}
is Map<*, *> -> ext.entries
.mapNotNull { (k, v) ->
v?.toString()?.let { k.toString() to it }
}
.toMap()

Copilot uses AI. Check for mistakes.
else -> emptyMap()
}
return EnumModel(
name = name,
description = schema.description,
type = EnumBackingType.parse(schema.type) ?: EnumBackingType.STRING,
values = enumValues,
valueDescriptions = valueDescriptions,
)
}

// --- allOf property merging ---

Expand Down
Loading
Loading