Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
2cd275d
feat(core): add security scheme extraction and auth-aware code genera…
halotukozak Mar 25, 2026
b89c553
Merge remote-tracking branch 'origin/master' into feat/security-schemes
halotukozak Apr 2, 2026
3a28795
refactor: streamline property handling in `ApiClientBaseGenerator` an…
halotukozak Apr 2, 2026
46cd5b8
refactor: simplify constructor migration and update context receiver …
halotukozak Apr 2, 2026
b496517
refactor: remove redundant test case and add validation for conflicti…
halotukozak Apr 2, 2026
9273462
refactor(core, test): enhance security scheme handling and simplify k…
halotukozak Apr 2, 2026
7170e61
refactor: extract shared parsing logic and add lightweight security s…
halotukozak Apr 2, 2026
cc2a256
Merge remote-tracking branch 'origin/master' into feat/security-schemes
halotukozak Apr 2, 2026
d4ced5a
Merge branch 'master' into feat/security-schemes
halotukozak Apr 7, 2026
4b76891
Merge branch 'master' into feat/security-schemes
halotukozak Apr 8, 2026
d385809
fmt
halotukozak Apr 8, 2026
d19d1ce
feat(core): add support for OpenAPI security schemes in API client ge…
halotukozak Apr 8, 2026
6aa147f
refactor(core): rename `accumulate` to `accumulateAndReturnNull` in `…
halotukozak Apr 9, 2026
22e4223
refactor(core): extract `AuthParam` sealed interface and refactor aut…
halotukozak Apr 9, 2026
4b9c7ab
refactor(core): remove security scheme handling in shared types gener…
halotukozak Apr 9, 2026
64e8a3f
refactor(core): move specTitle out of SecurityScheme model and saniti…
halotukozak Apr 9, 2026
f1e7668
fix: update README for per-client auth, remove dead code, add sanitiz…
halotukozak Apr 9, 2026
4c9e621
fix: review fixes — UTF-8 Basic auth encoding, applyAuth tests, accum…
halotukozak Apr 9, 2026
13900da
refactor(core): replace `paramNames` with `toAuthParam` for security …
halotukozak Apr 9, 2026
f454fa0
refactor(core): streamline `toAuthParam` usage and refactor `ClientGe…
halotukozak Apr 9, 2026
55ddfb0
fmt
halotukozak Apr 9, 2026
dea811b
refactor(core): update `AuthParam` constructors for consistent token …
halotukozak Apr 9, 2026
51ce217
refactor(core): update `ApiClientBase` and `ClientGenerator` to suppo…
halotukozak Apr 9, 2026
7b1db83
refactor(core): remove global token inheritance and update `applyAuth…
halotukozak Apr 9, 2026
abc18e7
test(core): add tests for unsupported and undefined security schemes
halotukozak Apr 9, 2026
6616d6f
fmt
halotukozak Apr 9, 2026
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
111 changes: 73 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,36 @@ A `SerializersModule` is auto-generated when discriminated polymorphic types are
| `application/json` request body | Supported |
| Form data / multipart | Not supported |

### Security Schemes

The plugin reads security schemes defined in the OpenAPI spec and generates authentication handling automatically.
Only schemes referenced in the top-level `security` requirement are included.

Parameter names are derived as `{schemeName}{specTitle}{Suffix}` where `schemeName` and `specTitle` are camel/PascalCased
from the OpenAPI scheme key and `info.title` respectively. This scoping prevents collisions when multiple specs define
schemes with the same name.

| Scheme type | Location | Generated constructor parameter(s) |
|-------------|----------|--------------------------------------------------------------------------------|
| HTTP Bearer | Header | `{name}{title}Token: () -> String` |
| HTTP Basic | Header | `{name}{title}Username: () -> String`, `{name}{title}Password: () -> String` |
| API Key | Header | `{name}{title}: () -> String` |
| API Key | Query | `{name}{title}: () -> String` |

All auth parameters are `() -> String` lambdas, called on every request. This lets you supply providers that refresh
credentials automatically.

Each generated client overrides an `applyAuth()` method that applies all credentials to each request:

- Bearer tokens are sent as `Authorization: Bearer {token}` headers
- Basic auth is sent as `Authorization: Basic {base64(username:password)}` headers
- Header API keys are appended to request headers using the parameter name from the spec
- Query API keys are appended to URL query parameters

### 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.
Callbacks, links, webhooks, XML content types, OpenAPI vendor extensions (`x-*`), OAuth 2.0, OpenID Connect, and
cookie-based API keys are not processed. The plugin logs warnings for callbacks and links found in a spec.

## Generated Code Structure

Expand Down Expand Up @@ -221,60 +247,75 @@ Here is how to use them.

### Dependencies

Add the required runtime dependencies and enable the experimental context parameters compiler flag:
Add the required runtime dependencies:

```kotlin
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")
}
```

### 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.
You only need to provide the base URL and authentication credentials (if the spec defines security schemes).

Class names are derived from OpenAPI tags as `<Tag>Api` (e.g., a `pets` tag produces `PetsApi`). Untagged endpoints go
to `DefaultApi`.

**Bearer token** (spec title "Petstore", scheme name "BearerAuth"):

```kotlin
val client = PetsApi(
baseUrl = "https://api.example.com",
token = { "your-bearer-token" },
bearerAuthPetstoreToken = { "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:
Auth parameters are `() -> String` lambdas called on every request, so you can supply a provider that refreshes
automatically:

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

**Multiple security schemes** -- parameters are scoped by scheme name and spec title:

```kotlin
val client = PetsApi(
baseUrl = "https://api.example.com",
bearerAuthPetstoreToken = { tokenStore.getAccessToken() },
internalApiKeyPetstore = { secrets.getApiKey() },
)
```

**Basic auth** (scheme name "BasicAuth"):

```kotlin
val client = PetsApi(
baseUrl = "https://api.example.com",
basicAuthPetstoreUsername = { "user" },
basicAuthPetstorePassword = { "pass" },
)
```

See [Security Schemes](#security-schemes) for the full mapping of scheme types to constructor parameters.

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

### Making Requests

Every endpoint becomes a `suspend` function on the client. Functions use
Arrow's [Raise](https://arrow-kt.io/docs/typed-errors/) for structured error handling -- they require a
`context(Raise<HttpError>)` and return `HttpSuccess<T>` on success:
Every endpoint becomes a `suspend` function on the client that returns `HttpSuccess<T>` on success and throws
`HttpError` on failure:

```kotlin
// 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 code
Expand All @@ -288,27 +329,21 @@ val result = client.findPets(status = "available", limit = 20)

### Error Handling

Generated endpoints use [Arrow's Raise](https://arrow-kt.io/docs/typed-errors/) -- errors are raised, not returned as
`Either`. Use Arrow's `either { ... }` block to obtain an `Either<HttpError, HttpSuccess<T>>`:
Generated endpoints throw `HttpError` (a `RuntimeException` subclass) for non-2xx responses and network failures.
Use standard `try`/`catch` to handle errors:

```kotlin
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}")
try {
val success = client.getPet(petId = 123)
println("Found: ${success.body.name}")
} catch (e: HttpError) {
when (e.type) {
HttpErrorType.Client -> println("Client error ${e.code}: ${e.message}")
HttpErrorType.Server -> println("Server error ${e.code}: ${e.message}")
HttpErrorType.Redirect -> println("Redirect ${e.code}")
HttpErrorType.Network -> println("Connection failed: ${e.message}")
}
)
}
```

`HttpError` is a data class with the following fields:
Expand All @@ -329,7 +364,7 @@ result.fold(
| `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.
`HttpError(code = 0, ..., type = HttpErrorType.Network)` instead of propagating raw exceptions.

## Publishing

Expand Down
24 changes: 19 additions & 5 deletions core/src/main/kotlin/com/avsystem/justworks/core/ArrowHelpers.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,33 @@ import kotlin.contracts.InvocationKind.AT_MOST_ONCE
import kotlin.contracts.contract

@OptIn(ExperimentalContracts::class)
context(warnings: IorRaise<Nel<Error>>)
context(iorRaise: IorRaise<Nel<Error>>)
inline fun <Error> ensureOrAccumulate(condition: Boolean, error: () -> Error) {
contract { callsInPlace(error, AT_MOST_ONCE) }
if (!condition) {
warnings.accumulate(nonEmptyListOf(error()))
iorRaise.accumulate(nonEmptyListOf(error()))
}
}

@OptIn(ExperimentalContracts::class)
context(warnings: IorRaise<Nel<Error>>)
inline fun <Error, B : Any> ensureNotNullOrAccumulate(value: B?, error: () -> Error) {
context(iorRaise: IorRaise<Nel<Error>>)
inline fun <Error, B : Any> ensureNotNullOrAccumulate(value: B?, error: () -> Error): B? {
contract { callsInPlace(error, AT_MOST_ONCE) }
if (value == null) {
warnings.accumulate(nonEmptyListOf(error()))
iorRaise.accumulate(nonEmptyListOf(error()))
}
return value
}

/** Accumulates a single error as a side effect, for use outside of expression context. */
context(iorRaise: IorRaise<Nel<Error>>)
fun <Error> accumulate(error: Error) {
iorRaise.accumulate(nonEmptyListOf(error))
}

/** Accumulates a single error and returns `null`, for use in `when` branches that must yield a nullable result. */
context(iorRaise: IorRaise<Nel<Error>>)
fun <Error> accumulateAndReturnNull(error: Error): Nothing? {
accumulate(error)
return null
}
3 changes: 2 additions & 1 deletion core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
@file:OptIn(ExperimentalRaiseAccumulateApi::class)

package com.avsystem.justworks.core

import arrow.core.Nel
import arrow.core.raise.ExperimentalRaiseAccumulateApi
import arrow.core.raise.IorRaise
import kotlin.contracts.ExperimentalContracts

object Issue {
data class Error(val message: String)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ object CodeGenerator {
apiPackage: String,
outputDir: File,
): Result {
val hierarchy = Hierarchy(ModelPackage(modelPackage)).apply { addSchemas(spec.schemas) }
val hierarchy = Hierarchy(ModelPackage(modelPackage)).apply {
addSchemas(spec.schemas)
}

val (modelFiles, resolvedSpec) = context(hierarchy, NameRegistry()) {
ModelGenerator.generateWithResolvedSpec(spec)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,26 @@ import com.avsystem.justworks.core.model.TypeRef
import com.squareup.kotlinpoet.ClassName

internal class Hierarchy(val modelPackage: ModelPackage) {
private val schemas = mutableSetOf<SchemaModel>()

private val schemaModels = mutableSetOf<SchemaModel>()
private val memoScope = MemoScope()

/**
* Updates the underlying schemas and invalidates all cached derived views.
* This is necessary when schemas are updated (e.g., after inlining types).
*/
fun addSchemas(newSchemas: List<SchemaModel>) {
schemas += newSchemas
schemaModels += newSchemas
memoScope.reset()
}

/** All schemas indexed by name for quick lookup. */
val schemasById: Map<String, SchemaModel> by memoized(memoScope) {
schemas.associateBy { it.name }
schemaModels.associateBy { it.name }
}

/** Schemas that define polymorphic variants via oneOf or anyOf. */
private val polymorphicSchemas: List<SchemaModel> by memoized(memoScope) {
schemas.filterNot { it.variants().isNullOrEmpty() }
schemaModels.filterNot { it.variants().isNullOrEmpty() }
}

/** Maps parent schema name to its variant schema names (for both oneOf and anyOf). */
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.avsystem.justworks.core.gen

private val DELIMITERS = Regex("[_\\-.]+")
private val DELIMITERS = Regex("[_\\-.\\s]+")
private val CAMEL_BOUNDARY = Regex("(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")

/**
Expand All @@ -17,7 +17,9 @@ fun String.toCamelCase(): String = toPascalCase().replaceFirstChar { it.lowercas
fun String.toPascalCase(): String = split(DELIMITERS)
.filter { it.isNotEmpty() }
.flatMap { it.split(CAMEL_BOUNDARY) }
.joinToString("") { it.lowercase().replaceFirstChar { c -> c.uppercaseChar() } }
.joinToString("") { segment ->
segment.filter { it.isLetterOrDigit() }.lowercase().replaceFirstChar { it.uppercaseChar() }
}

/**
* Converts any string to UPPER_SNAKE_CASE for use as an enum constant name.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ val HTTP_SUCCESS = ClassName("com.avsystem.justworks", "HttpSuccess")
// Kotlin stdlib
// ============================================================================

val BASE64_CLASS = ClassName("java.util", "Base64")
val CLOSEABLE = ClassName("java.io", "Closeable")
val IO_EXCEPTION = ClassName("java.io", "IOException")
val HTTP_REQUEST_TIMEOUT_EXCEPTION = ClassName("io.ktor.client.plugins", "HttpRequestTimeoutException")
Expand Down
Loading
Loading