Skip to content

Commit 2cd275d

Browse files
halotukozakclaude
andcommitted
feat(core): add security scheme extraction and auth-aware code generation
Parse security schemes (Bearer, Basic, ApiKey) from OpenAPI specs and generate auth-aware ApiClientBase with corresponding constructor parameters and header/query injection. Wire spec files into JustworksSharedTypesTask for security scheme extraction. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8682701 commit 2cd275d

16 files changed

Lines changed: 758 additions & 73 deletions

File tree

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

Lines changed: 129 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.avsystem.justworks.core.gen
22

3+
import com.avsystem.justworks.core.model.ApiKeyLocation
4+
import com.avsystem.justworks.core.model.SecurityScheme
35
import com.squareup.kotlinpoet.CodeBlock
46
import com.squareup.kotlinpoet.ContextParameter
57
import com.squareup.kotlinpoet.ExperimentalKotlinPoetApi
@@ -30,7 +32,7 @@ object ApiClientBaseGenerator {
3032
private const val SUCCESS_BODY = "successBody"
3133
private const val SERIALIZERS_MODULE_PARAM = "serializersModule"
3234

33-
fun generate(): FileSpec {
35+
fun generate(securitySchemes: List<SecurityScheme>): FileSpec {
3436
val t = TypeVariableName("T").copy(reified = true)
3537

3638
return FileSpec
@@ -39,7 +41,7 @@ object ApiClientBaseGenerator {
3941
.addFunction(buildMapToResult(t))
4042
.addFunction(buildToResult(t))
4143
.addFunction(buildToEmptyResult())
42-
.addType(buildApiClientBaseClass())
44+
.addType(buildApiClientBaseClass(securitySchemes))
4345
.build()
4446
}
4547

@@ -103,26 +105,33 @@ object ApiClientBaseGenerator {
103105
.addStatement("return %L { Unit }", MAP_TO_RESULT)
104106
.build()
105107

106-
private fun buildApiClientBaseClass(): TypeSpec {
108+
private fun buildApiClientBaseClass(securitySchemes: List<SecurityScheme>): TypeSpec {
107109
val tokenType = LambdaTypeName.get(returnType = STRING)
110+
val authParams = buildAuthConstructorParams(securitySchemes)
108111

109-
val constructor = FunSpec
112+
val constructorBuilder = FunSpec
110113
.constructorBuilder()
111114
.addParameter(BASE_URL, STRING)
112-
.addParameter(TOKEN, tokenType)
113-
.build()
115+
116+
val propertySpecs = mutableListOf<PropertySpec>()
114117

115118
val baseUrlProp = PropertySpec
116119
.builder(BASE_URL, STRING)
117120
.initializer(BASE_URL)
118121
.addModifiers(KModifier.PROTECTED)
119122
.build()
123+
propertySpecs.add(baseUrlProp)
120124

121-
val tokenProp = PropertySpec
122-
.builder(TOKEN, tokenType)
123-
.initializer(TOKEN)
124-
.addModifiers(KModifier.PRIVATE)
125-
.build()
125+
for ((paramName, _) in authParams) {
126+
constructorBuilder.addParameter(paramName, tokenType)
127+
propertySpecs.add(
128+
PropertySpec
129+
.builder(paramName, tokenType)
130+
.initializer(paramName)
131+
.addModifiers(KModifier.PRIVATE)
132+
.build(),
133+
)
134+
}
126135

127136
val clientProp = PropertySpec
128137
.builder(CLIENT, HTTP_CLIENT)
@@ -135,32 +144,125 @@ object ApiClientBaseGenerator {
135144
.addStatement("$CLIENT.close()")
136145
.build()
137146

138-
return TypeSpec
147+
val classBuilder = TypeSpec
139148
.classBuilder(API_CLIENT_BASE)
140149
.addModifiers(KModifier.ABSTRACT)
141150
.addSuperinterface(CLOSEABLE)
142-
.primaryConstructor(constructor)
143-
.addProperty(baseUrlProp)
144-
.addProperty(tokenProp)
151+
.primaryConstructor(constructorBuilder.build())
152+
153+
for (prop in propertySpecs) {
154+
classBuilder.addProperty(prop)
155+
}
156+
157+
return classBuilder
145158
.addProperty(clientProp)
146159
.addFunction(closeFun)
147-
.addFunction(buildApplyAuth())
160+
.addFunction(buildApplyAuth(securitySchemes))
148161
.addFunction(buildSafeCall())
149162
.addFunction(buildCreateHttpClient())
150163
.build()
151164
}
152165

153-
private fun buildApplyAuth(): FunSpec = FunSpec
154-
.builder(APPLY_AUTH)
155-
.addModifiers(KModifier.PROTECTED)
156-
.receiver(HTTP_REQUEST_BUILDER)
157-
.beginControlFlow("%M", HEADERS_FUN)
158-
.addStatement(
159-
"append(%T.Authorization, %P)",
160-
HTTP_HEADERS,
161-
CodeBlock.of($$"Bearer ${'$'}{$$TOKEN()}"),
162-
).endControlFlow()
163-
.build()
166+
/**
167+
* Builds the list of auth-related constructor parameter names based on security schemes.
168+
* Returns pairs of (paramName, schemeType) for each scheme.
169+
*/
170+
internal fun buildAuthConstructorParams(securitySchemes: List<SecurityScheme>): List<Pair<String, SecurityScheme>> =
171+
securitySchemes.flatMap { scheme ->
172+
when (scheme) {
173+
is SecurityScheme.Bearer -> {
174+
val isSingleBearer =
175+
securitySchemes.size == 1 && securitySchemes.first() is SecurityScheme.Bearer
176+
177+
val paramName = if (isSingleBearer) TOKEN else "${scheme.name.toCamelCase()}Token"
178+
listOf(paramName to scheme)
179+
}
180+
181+
is SecurityScheme.ApiKey -> {
182+
listOf("${scheme.name.toCamelCase()}Key" to scheme)
183+
}
184+
185+
is SecurityScheme.Basic -> {
186+
listOf(
187+
"${scheme.name.toCamelCase()}Username" to scheme,
188+
"${scheme.name.toCamelCase()}Password" to scheme,
189+
)
190+
}
191+
}
192+
}
193+
194+
private fun buildApplyAuth(securitySchemes: List<SecurityScheme>): FunSpec {
195+
val builder = FunSpec
196+
.builder(APPLY_AUTH)
197+
.addModifiers(KModifier.PROTECTED)
198+
.receiver(HTTP_REQUEST_BUILDER)
199+
200+
if (securitySchemes.isEmpty()) return builder.build()
201+
202+
val headerSchemes = securitySchemes.filter {
203+
it is SecurityScheme.Bearer ||
204+
it is SecurityScheme.Basic ||
205+
(it is SecurityScheme.ApiKey && it.location == ApiKeyLocation.HEADER)
206+
}
207+
val querySchemes = securitySchemes
208+
.filterIsInstance<SecurityScheme.ApiKey>()
209+
.filter { it.location == ApiKeyLocation.QUERY }
210+
211+
if (headerSchemes.isNotEmpty()) {
212+
builder.beginControlFlow("%M", HEADERS_FUN)
213+
for (scheme in headerSchemes) {
214+
when (scheme) {
215+
is SecurityScheme.Bearer -> {
216+
val isSingleBearer =
217+
securitySchemes.size == 1 && securitySchemes.first() is SecurityScheme.Bearer
218+
219+
val paramName = if (isSingleBearer) TOKEN else "${scheme.name.toCamelCase()}Token"
220+
builder.addStatement(
221+
"append(%T.Authorization, %P)",
222+
HTTP_HEADERS,
223+
CodeBlock.of("Bearer \${$paramName()}"),
224+
)
225+
}
226+
227+
is SecurityScheme.Basic -> {
228+
val usernameParam = "${scheme.name.toCamelCase()}Username"
229+
val passwordParam = "${scheme.name.toCamelCase()}Password"
230+
builder.addStatement(
231+
"append(%T.Authorization, %P)",
232+
HTTP_HEADERS,
233+
CodeBlock.of(
234+
"Basic \${%T.getEncoder().encodeToString(\"${'$'}{$usernameParam()}:${'$'}{$passwordParam()}\".toByteArray())}",
235+
BASE64_CLASS,
236+
),
237+
)
238+
}
239+
240+
is SecurityScheme.ApiKey -> {
241+
val paramName = "${scheme.name.toCamelCase()}Key"
242+
builder.addStatement(
243+
"append(%S, $paramName())",
244+
scheme.parameterName,
245+
)
246+
}
247+
}
248+
}
249+
builder.endControlFlow()
250+
}
251+
252+
if (querySchemes.isNotEmpty()) {
253+
builder.beginControlFlow("url")
254+
for (scheme in querySchemes) {
255+
val paramName = "${scheme.name.toCamelCase()}Key"
256+
builder.addStatement(
257+
"parameters.append(%S, $paramName())",
258+
scheme.parameterName,
259+
)
260+
}
261+
builder.endControlFlow()
262+
}
263+
264+
return builder.build()
265+
}
164266

165267
private fun buildSafeCall(): FunSpec = FunSpec
166268
.builder(SAFE_CALL)

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

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import com.avsystem.justworks.core.model.Endpoint
55
import com.avsystem.justworks.core.model.HttpMethod
66
import com.avsystem.justworks.core.model.Parameter
77
import com.avsystem.justworks.core.model.ParameterLocation
8+
import com.avsystem.justworks.core.model.SecurityScheme
89
import com.avsystem.justworks.core.model.TypeRef
910
import com.squareup.kotlinpoet.ClassName
1011
import com.squareup.kotlinpoet.CodeBlock
@@ -34,13 +35,16 @@ private const val API_SUFFIX = "Api"
3435
class ClientGenerator(private val apiPackage: String, private val modelPackage: String) {
3536
fun generate(spec: ApiSpec, hasPolymorphicTypes: Boolean = false): List<FileSpec> {
3637
val grouped = spec.endpoints.groupBy { it.tags.firstOrNull() ?: DEFAULT_TAG }
37-
return grouped.map { (tag, endpoints) -> generateClientFile(tag, endpoints, hasPolymorphicTypes) }
38+
return grouped.map { (tag, endpoints) ->
39+
generateClientFile(tag, endpoints, hasPolymorphicTypes, spec.securitySchemes)
40+
}
3841
}
3942

4043
private fun generateClientFile(
4144
tag: String,
4245
endpoints: List<Endpoint>,
4346
hasPolymorphicTypes: Boolean = false,
47+
securitySchemes: List<SecurityScheme>,
4448
): FileSpec {
4549
val className = ClassName(apiPackage, "${tag.toPascalCase()}$API_SUFFIX")
4650

@@ -52,25 +56,30 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage:
5256
}
5357

5458
val tokenType = LambdaTypeName.get(returnType = STRING)
59+
val authParams = ApiClientBaseGenerator.buildAuthConstructorParams(securitySchemes)
5560

56-
val primaryConstructor = FunSpec
61+
val constructorBuilder = FunSpec
5762
.constructorBuilder()
5863
.addParameter(BASE_URL, STRING)
59-
.addParameter(TOKEN, tokenType)
60-
.build()
64+
65+
val classBuilder = TypeSpec
66+
.classBuilder(className)
67+
.superclass(API_CLIENT_BASE)
68+
.addSuperclassConstructorParameter(BASE_URL)
69+
70+
for ((paramName, _) in authParams) {
71+
constructorBuilder.addParameter(paramName, tokenType)
72+
classBuilder.addSuperclassConstructorParameter(paramName)
73+
}
6174

6275
val httpClientProperty = PropertySpec
6376
.builder(CLIENT, HTTP_CLIENT)
6477
.addModifiers(KModifier.OVERRIDE, KModifier.PROTECTED)
6578
.initializer(clientInitializer)
6679
.build()
6780

68-
val classBuilder = TypeSpec
69-
.classBuilder(className)
70-
.superclass(API_CLIENT_BASE)
71-
.addSuperclassConstructorParameter(BASE_URL)
72-
.addSuperclassConstructorParameter(TOKEN)
73-
.primaryConstructor(primaryConstructor)
81+
classBuilder
82+
.primaryConstructor(constructorBuilder.build())
7483
.addProperty(httpClientProperty)
7584

7685
classBuilder.addFunctions(endpoints.map(::generateEndpointFunction))

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ object CodeGenerator {
1414
spec: ApiSpec,
1515
modelPackage: String,
1616
apiPackage: String,
17-
outputDir: File
17+
outputDir: File,
1818
): Result {
1919
val modelFiles = ModelGenerator(modelPackage).generate(spec)
2020
modelFiles.forEach { it.writeTo(outputDir) }
@@ -27,8 +27,10 @@ object CodeGenerator {
2727
return Result(modelFiles.size, clientFiles.size)
2828
}
2929

30-
fun generateSharedTypes(outputDir: File): Int {
31-
val files = ApiResponseGenerator.generate() + ApiClientBaseGenerator.generate()
30+
fun generateSharedTypes(outputDir: File, specs: List<ApiSpec> = emptyList()): Int {
31+
val securitySchemes = specs.flatMap { it.securitySchemes }
32+
33+
val files = ApiResponseGenerator.generate() + ApiClientBaseGenerator.generate(securitySchemes)
3234
files.forEach { it.writeTo(outputDir) }
3335
return files.size
3436
}

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
@@ -82,6 +82,7 @@ val HTTP_SUCCESS = ClassName("com.avsystem.justworks", "HttpSuccess")
8282
// Kotlin stdlib
8383
// ============================================================================
8484

85+
val BASE64_CLASS = ClassName("java.util", "Base64")
8586
val CLOSEABLE = ClassName("java.io", "Closeable")
8687
val IO_EXCEPTION = ClassName("java.io", "IOException")
8788
val HTTP_REQUEST_TIMEOUT_EXCEPTION = ClassName("io.ktor.client.plugins", "HttpRequestTimeoutException")

core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,29 @@ package com.avsystem.justworks.core.model
77
* code generators. Bridges the raw Swagger Parser OAS model and the generated
88
* Kotlin client/model source files.
99
*/
10+
sealed interface SecurityScheme {
11+
val name: String
12+
13+
data class Bearer(override val name: String) : SecurityScheme
14+
15+
data class ApiKey(
16+
override val name: String,
17+
val parameterName: String,
18+
val location: ApiKeyLocation,
19+
) : SecurityScheme
20+
21+
data class Basic(override val name: String) : SecurityScheme
22+
}
23+
24+
enum class ApiKeyLocation { HEADER, QUERY }
25+
1026
data class ApiSpec(
1127
val title: String,
1228
val version: String,
1329
val endpoints: List<Endpoint>,
1430
val schemas: List<SchemaModel>,
1531
val enums: List<EnumModel>,
32+
val securitySchemes: List<SecurityScheme>,
1633
)
1734

1835
data class Endpoint(

0 commit comments

Comments
 (0)