Skip to content

Commit 4ee6dec

Browse files
authored
Merge pull request #24 from AVSystem/feat/security-schemes
feat: API key and HTTP basic authentication support
2 parents 0fdb7f7 + 6a77be6 commit 4ee6dec

30 files changed

Lines changed: 1055 additions & 146 deletions

README.md

Lines changed: 73 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -111,10 +111,36 @@ A `SerializersModule` is auto-generated when discriminated polymorphic types are
111111
| `application/json` request body | Supported |
112112
| Form data / multipart | Not supported |
113113

114+
### Security Schemes
115+
116+
The plugin reads security schemes defined in the OpenAPI spec and generates authentication handling automatically.
117+
Only schemes referenced in the top-level `security` requirement are included.
118+
119+
Parameter names are derived as `{schemeName}{specTitle}{Suffix}` where `schemeName` and `specTitle` are camel/PascalCased
120+
from the OpenAPI scheme key and `info.title` respectively. This scoping prevents collisions when multiple specs define
121+
schemes with the same name.
122+
123+
| Scheme type | Location | Generated constructor parameter(s) |
124+
|-------------|----------|--------------------------------------------------------------------------------|
125+
| HTTP Bearer | Header | `{name}{title}Token: () -> String` |
126+
| HTTP Basic | Header | `{name}{title}Username: () -> String`, `{name}{title}Password: () -> String` |
127+
| API Key | Header | `{name}{title}: () -> String` |
128+
| API Key | Query | `{name}{title}: () -> String` |
129+
130+
All auth parameters are `() -> String` lambdas, called on every request. This lets you supply providers that refresh
131+
credentials automatically.
132+
133+
Each generated client overrides an `applyAuth()` method that applies all credentials to each request:
134+
135+
- Bearer tokens are sent as `Authorization: Bearer {token}` headers
136+
- Basic auth is sent as `Authorization: Basic {base64(username:password)}` headers
137+
- Header API keys are appended to request headers using the parameter name from the spec
138+
- Query API keys are appended to URL query parameters
139+
114140
### Not Supported
115141

116-
Callbacks, links, webhooks, XML content types, and OpenAPI vendor extensions (`x-*`) are not processed. The plugin logs
117-
warnings for callbacks and links found in a spec.
142+
Callbacks, links, webhooks, XML content types, OpenAPI vendor extensions (`x-*`), OAuth 2.0, OpenID Connect, and
143+
cookie-based API keys are not processed. The plugin logs warnings for callbacks and links found in a spec.
118144

119145
## Generated Code Structure
120146

@@ -221,60 +247,75 @@ Here is how to use them.
221247

222248
### Dependencies
223249

224-
Add the required runtime dependencies and enable the experimental context parameters compiler flag:
250+
Add the required runtime dependencies:
225251

226252
```kotlin
227-
kotlin {
228-
compilerOptions {
229-
freeCompilerArgs.add("-Xcontext-parameters")
230-
}
231-
}
232-
233253
dependencies {
234254
implementation("io.ktor:ktor-client-core:3.1.1")
235255
implementation("io.ktor:ktor-client-cio:3.1.1") // or another engine (OkHttp, Apache, etc.)
236256
implementation("io.ktor:ktor-client-content-negotiation:3.1.1")
237257
implementation("io.ktor:ktor-serialization-kotlinx-json:3.1.1")
238258
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")
239-
implementation("io.arrow-kt:arrow-core:2.2.1.1")
240259
}
241260
```
242261

243262
### Creating the Client
244263

245264
Each generated client extends `ApiClientBase` and creates its own pre-configured `HttpClient` internally.
246-
You only need to provide the base URL and authentication credentials.
265+
You only need to provide the base URL and authentication credentials (if the spec defines security schemes).
247266

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

270+
**Bearer token** (spec title "Petstore", scheme name "BearerAuth"):
271+
251272
```kotlin
252273
val client = PetsApi(
253274
baseUrl = "https://api.example.com",
254-
token = { "your-bearer-token" },
275+
bearerAuthPetstoreToken = { "your-bearer-token" },
255276
)
256277
```
257278

258-
The `token` parameter is a `() -> String` lambda called on every request and sent as a `Bearer` token in the
259-
`Authorization` header. This lets you supply a provider that refreshes automatically:
279+
Auth parameters are `() -> String` lambdas called on every request, so you can supply a provider that refreshes
280+
automatically:
260281

261282
```kotlin
262283
val client = PetsApi(
263284
baseUrl = "https://api.example.com",
264-
token = { tokenStore.getAccessToken() },
285+
bearerAuthPetstoreToken = { tokenStore.getAccessToken() },
265286
)
266287
```
267288

289+
**Multiple security schemes** -- parameters are scoped by scheme name and spec title:
290+
291+
```kotlin
292+
val client = PetsApi(
293+
baseUrl = "https://api.example.com",
294+
bearerAuthPetstoreToken = { tokenStore.getAccessToken() },
295+
internalApiKeyPetstore = { secrets.getApiKey() },
296+
)
297+
```
298+
299+
**Basic auth** (scheme name "BasicAuth"):
300+
301+
```kotlin
302+
val client = PetsApi(
303+
baseUrl = "https://api.example.com",
304+
basicAuthPetstoreUsername = { "user" },
305+
basicAuthPetstorePassword = { "pass" },
306+
)
307+
```
308+
309+
See [Security Schemes](#security-schemes) for the full mapping of scheme types to constructor parameters.
310+
268311
The client implements `Closeable` -- call `client.close()` when done to release HTTP resources.
269312

270313
### Making Requests
271314

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

276318
```kotlin
277-
// Inside a Raise<HttpError> context (e.g., within either { ... })
278319
val result: HttpSuccess<List<Pet>> = client.listPets(limit = 10)
279320
println(result.body) // the deserialized response body
280321
println(result.code) // the HTTP status code
@@ -288,27 +329,21 @@ val result = client.findPets(status = "available", limit = 20)
288329

289330
### Error Handling
290331

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

294335
```kotlin
295-
val result: Either<HttpError, HttpSuccess<Pet>> = either {
296-
client.getPet(petId = 123)
297-
}
298-
299-
result.fold(
300-
ifLeft = { error ->
301-
when (error.type) {
302-
HttpErrorType.Client -> println("Client error ${error.code}: ${error.message}")
303-
HttpErrorType.Server -> println("Server error ${error.code}: ${error.message}")
304-
HttpErrorType.Redirect -> println("Redirect ${error.code}")
305-
HttpErrorType.Network -> println("Connection failed: ${error.message}")
306-
}
307-
},
308-
ifRight = { success ->
309-
println("Found: ${success.body.name}")
336+
try {
337+
val success = client.getPet(petId = 123)
338+
println("Found: ${success.body.name}")
339+
} catch (e: HttpError) {
340+
when (e.type) {
341+
HttpErrorType.Client -> println("Client error ${e.code}: ${e.message}")
342+
HttpErrorType.Server -> println("Server error ${e.code}: ${e.message}")
343+
HttpErrorType.Redirect -> println("Redirect ${e.code}")
344+
HttpErrorType.Network -> println("Connection failed: ${e.message}")
310345
}
311-
)
346+
}
312347
```
313348

314349
`HttpError` is a data class with the following fields:
@@ -329,7 +364,7 @@ result.fold(
329364
| `Network` | I/O failures, timeouts, DNS issues |
330365

331366
Network errors (connection timeouts, DNS failures) are caught and reported as
332-
`HttpError(code = 0, ..., type = HttpErrorType.Network)` instead of propagating exceptions.
367+
`HttpError(code = 0, ..., type = HttpErrorType.Network)` instead of propagating raw exceptions.
333368

334369
## Publishing
335370

core/src/main/kotlin/com/avsystem/justworks/core/ArrowHelpers.kt

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,33 @@ import kotlin.contracts.InvocationKind.AT_MOST_ONCE
88
import kotlin.contracts.contract
99

1010
@OptIn(ExperimentalContracts::class)
11-
context(warnings: IorRaise<Nel<Error>>)
11+
context(iorRaise: IorRaise<Nel<Error>>)
1212
inline fun <Error> ensureOrAccumulate(condition: Boolean, error: () -> Error) {
1313
contract { callsInPlace(error, AT_MOST_ONCE) }
1414
if (!condition) {
15-
warnings.accumulate(nonEmptyListOf(error()))
15+
iorRaise.accumulate(nonEmptyListOf(error()))
1616
}
1717
}
1818

1919
@OptIn(ExperimentalContracts::class)
20-
context(warnings: IorRaise<Nel<Error>>)
21-
inline fun <Error, B : Any> ensureNotNullOrAccumulate(value: B?, error: () -> Error) {
20+
context(iorRaise: IorRaise<Nel<Error>>)
21+
inline fun <Error, B : Any> ensureNotNullOrAccumulate(value: B?, error: () -> Error): B? {
2222
contract { callsInPlace(error, AT_MOST_ONCE) }
2323
if (value == null) {
24-
warnings.accumulate(nonEmptyListOf(error()))
24+
iorRaise.accumulate(nonEmptyListOf(error()))
2525
}
26+
return value
27+
}
28+
29+
/** Accumulates a single error as a side effect, for use outside of expression context. */
30+
context(iorRaise: IorRaise<Nel<Error>>)
31+
fun <Error> accumulate(error: Error) {
32+
iorRaise.accumulate(nonEmptyListOf(error))
33+
}
34+
35+
/** Accumulates a single error and returns `null`, for use in `when` branches that must yield a nullable result. */
36+
context(iorRaise: IorRaise<Nel<Error>>)
37+
fun <Error> accumulateAndReturnNull(error: Error): Nothing? {
38+
accumulate(error)
39+
return null
2640
}

core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
@file:OptIn(ExperimentalRaiseAccumulateApi::class)
2+
13
package com.avsystem.justworks.core
24

35
import arrow.core.Nel
46
import arrow.core.raise.ExperimentalRaiseAccumulateApi
57
import arrow.core.raise.IorRaise
6-
import kotlin.contracts.ExperimentalContracts
78

89
object Issue {
910
data class Error(val message: String)

core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ object CodeGenerator {
2020
apiPackage: String,
2121
outputDir: File,
2222
): Result {
23-
val hierarchy = Hierarchy(ModelPackage(modelPackage)).apply { addSchemas(spec.schemas) }
23+
val hierarchy = Hierarchy(ModelPackage(modelPackage)).apply {
24+
addSchemas(spec.schemas)
25+
}
2426

2527
val (modelFiles, resolvedSpec) = context(hierarchy, NameRegistry()) {
2628
ModelGenerator.generateWithResolvedSpec(spec)

core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,26 @@ import com.avsystem.justworks.core.model.TypeRef
77
import com.squareup.kotlinpoet.ClassName
88

99
internal class Hierarchy(val modelPackage: ModelPackage) {
10-
private val schemas = mutableSetOf<SchemaModel>()
11-
10+
private val schemaModels = mutableSetOf<SchemaModel>()
1211
private val memoScope = MemoScope()
1312

1413
/**
1514
* Updates the underlying schemas and invalidates all cached derived views.
1615
* This is necessary when schemas are updated (e.g., after inlining types).
1716
*/
1817
fun addSchemas(newSchemas: List<SchemaModel>) {
19-
schemas += newSchemas
18+
schemaModels += newSchemas
2019
memoScope.reset()
2120
}
2221

2322
/** All schemas indexed by name for quick lookup. */
2423
val schemasById: Map<String, SchemaModel> by memoized(memoScope) {
25-
schemas.associateBy { it.name }
24+
schemaModels.associateBy { it.name }
2625
}
2726

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

3332
/** Maps parent schema name to its variant schema names (for both oneOf and anyOf). */

core/src/main/kotlin/com/avsystem/justworks/core/gen/NameUtils.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package com.avsystem.justworks.core.gen
22

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

66
/**
@@ -17,7 +17,9 @@ fun String.toCamelCase(): String = toPascalCase().replaceFirstChar { it.lowercas
1717
fun String.toPascalCase(): String = split(DELIMITERS)
1818
.filter { it.isNotEmpty() }
1919
.flatMap { it.split(CAMEL_BOUNDARY) }
20-
.joinToString("") { it.lowercase().replaceFirstChar { c -> c.uppercaseChar() } }
20+
.joinToString("") { segment ->
21+
segment.filter { it.isLetterOrDigit() }.lowercase().replaceFirstChar { it.uppercaseChar() }
22+
}
2123

2224
/**
2325
* Converts any string to UPPER_SNAKE_CASE for use as an enum constant name.

core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ val HTTP_SUCCESS = ClassName("com.avsystem.justworks", "HttpSuccess")
9494
// Kotlin stdlib
9595
// ============================================================================
9696

97+
val BASE64_CLASS = ClassName("java.util", "Base64")
9798
val CLOSEABLE = ClassName("java.io", "Closeable")
9899
val IO_EXCEPTION = ClassName("java.io", "IOException")
99100
val HTTP_REQUEST_TIMEOUT_EXCEPTION = ClassName("io.ktor.client.plugins", "HttpRequestTimeoutException")

0 commit comments

Comments
 (0)