From 2cd275dc7f54990feecc2d8a9f3e3274aff834da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 25 Mar 2026 11:02:12 +0100 Subject: [PATCH 01/23] 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) --- .../core/gen/ApiClientBaseGenerator.kt | 156 +++++++++++++--- .../justworks/core/gen/ClientGenerator.kt | 29 +-- .../justworks/core/gen/CodeGenerator.kt | 8 +- .../com/avsystem/justworks/core/gen/Names.kt | 1 + .../avsystem/justworks/core/model/ApiSpec.kt | 17 ++ .../justworks/core/parser/SpecParser.kt | 70 ++++++-- .../core/gen/ApiClientBaseGeneratorTest.kt | 167 ++++++++++++++++-- .../justworks/core/gen/ClientGeneratorTest.kt | 111 +++++++++++- .../justworks/core/gen/IntegrationTest.kt | 4 +- .../core/gen/ModelGeneratorPolymorphicTest.kt | 1 + .../justworks/core/gen/ModelGeneratorTest.kt | 2 + .../core/parser/SpecParserSecurityTest.kt | 70 ++++++++ .../test/resources/security-schemes-spec.yaml | 43 +++++ .../gradle/JustworksPluginFunctionalTest.kt | 119 +++++++++++++ .../justworks/gradle/JustworksPlugin.kt | 5 + .../gradle/JustworksSharedTypesTask.kt | 28 ++- 16 files changed, 758 insertions(+), 73 deletions(-) create mode 100644 core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserSecurityTest.kt create mode 100644 core/src/test/resources/security-schemes-spec.yaml diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGenerator.kt index 8c953e7..f3407d6 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGenerator.kt @@ -1,5 +1,7 @@ package com.avsystem.justworks.core.gen +import com.avsystem.justworks.core.model.ApiKeyLocation +import com.avsystem.justworks.core.model.SecurityScheme import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.ContextParameter import com.squareup.kotlinpoet.ExperimentalKotlinPoetApi @@ -30,7 +32,7 @@ object ApiClientBaseGenerator { private const val SUCCESS_BODY = "successBody" private const val SERIALIZERS_MODULE_PARAM = "serializersModule" - fun generate(): FileSpec { + fun generate(securitySchemes: List): FileSpec { val t = TypeVariableName("T").copy(reified = true) return FileSpec @@ -39,7 +41,7 @@ object ApiClientBaseGenerator { .addFunction(buildMapToResult(t)) .addFunction(buildToResult(t)) .addFunction(buildToEmptyResult()) - .addType(buildApiClientBaseClass()) + .addType(buildApiClientBaseClass(securitySchemes)) .build() } @@ -103,26 +105,33 @@ object ApiClientBaseGenerator { .addStatement("return %L { Unit }", MAP_TO_RESULT) .build() - private fun buildApiClientBaseClass(): TypeSpec { + private fun buildApiClientBaseClass(securitySchemes: List): TypeSpec { val tokenType = LambdaTypeName.get(returnType = STRING) + val authParams = buildAuthConstructorParams(securitySchemes) - val constructor = FunSpec + val constructorBuilder = FunSpec .constructorBuilder() .addParameter(BASE_URL, STRING) - .addParameter(TOKEN, tokenType) - .build() + + val propertySpecs = mutableListOf() val baseUrlProp = PropertySpec .builder(BASE_URL, STRING) .initializer(BASE_URL) .addModifiers(KModifier.PROTECTED) .build() + propertySpecs.add(baseUrlProp) - val tokenProp = PropertySpec - .builder(TOKEN, tokenType) - .initializer(TOKEN) - .addModifiers(KModifier.PRIVATE) - .build() + for ((paramName, _) in authParams) { + constructorBuilder.addParameter(paramName, tokenType) + propertySpecs.add( + PropertySpec + .builder(paramName, tokenType) + .initializer(paramName) + .addModifiers(KModifier.PRIVATE) + .build(), + ) + } val clientProp = PropertySpec .builder(CLIENT, HTTP_CLIENT) @@ -135,32 +144,125 @@ object ApiClientBaseGenerator { .addStatement("$CLIENT.close()") .build() - return TypeSpec + val classBuilder = TypeSpec .classBuilder(API_CLIENT_BASE) .addModifiers(KModifier.ABSTRACT) .addSuperinterface(CLOSEABLE) - .primaryConstructor(constructor) - .addProperty(baseUrlProp) - .addProperty(tokenProp) + .primaryConstructor(constructorBuilder.build()) + + for (prop in propertySpecs) { + classBuilder.addProperty(prop) + } + + return classBuilder .addProperty(clientProp) .addFunction(closeFun) - .addFunction(buildApplyAuth()) + .addFunction(buildApplyAuth(securitySchemes)) .addFunction(buildSafeCall()) .addFunction(buildCreateHttpClient()) .build() } - private fun buildApplyAuth(): FunSpec = FunSpec - .builder(APPLY_AUTH) - .addModifiers(KModifier.PROTECTED) - .receiver(HTTP_REQUEST_BUILDER) - .beginControlFlow("%M", HEADERS_FUN) - .addStatement( - "append(%T.Authorization, %P)", - HTTP_HEADERS, - CodeBlock.of($$"Bearer ${'$'}{$$TOKEN()}"), - ).endControlFlow() - .build() + /** + * Builds the list of auth-related constructor parameter names based on security schemes. + * Returns pairs of (paramName, schemeType) for each scheme. + */ + internal fun buildAuthConstructorParams(securitySchemes: List): List> = + securitySchemes.flatMap { scheme -> + when (scheme) { + is SecurityScheme.Bearer -> { + val isSingleBearer = + securitySchemes.size == 1 && securitySchemes.first() is SecurityScheme.Bearer + + val paramName = if (isSingleBearer) TOKEN else "${scheme.name.toCamelCase()}Token" + listOf(paramName to scheme) + } + + is SecurityScheme.ApiKey -> { + listOf("${scheme.name.toCamelCase()}Key" to scheme) + } + + is SecurityScheme.Basic -> { + listOf( + "${scheme.name.toCamelCase()}Username" to scheme, + "${scheme.name.toCamelCase()}Password" to scheme, + ) + } + } + } + + private fun buildApplyAuth(securitySchemes: List): FunSpec { + val builder = FunSpec + .builder(APPLY_AUTH) + .addModifiers(KModifier.PROTECTED) + .receiver(HTTP_REQUEST_BUILDER) + + if (securitySchemes.isEmpty()) return builder.build() + + val headerSchemes = securitySchemes.filter { + it is SecurityScheme.Bearer || + it is SecurityScheme.Basic || + (it is SecurityScheme.ApiKey && it.location == ApiKeyLocation.HEADER) + } + val querySchemes = securitySchemes + .filterIsInstance() + .filter { it.location == ApiKeyLocation.QUERY } + + if (headerSchemes.isNotEmpty()) { + builder.beginControlFlow("%M", HEADERS_FUN) + for (scheme in headerSchemes) { + when (scheme) { + is SecurityScheme.Bearer -> { + val isSingleBearer = + securitySchemes.size == 1 && securitySchemes.first() is SecurityScheme.Bearer + + val paramName = if (isSingleBearer) TOKEN else "${scheme.name.toCamelCase()}Token" + builder.addStatement( + "append(%T.Authorization, %P)", + HTTP_HEADERS, + CodeBlock.of("Bearer \${$paramName()}"), + ) + } + + is SecurityScheme.Basic -> { + val usernameParam = "${scheme.name.toCamelCase()}Username" + val passwordParam = "${scheme.name.toCamelCase()}Password" + builder.addStatement( + "append(%T.Authorization, %P)", + HTTP_HEADERS, + CodeBlock.of( + "Basic \${%T.getEncoder().encodeToString(\"${'$'}{$usernameParam()}:${'$'}{$passwordParam()}\".toByteArray())}", + BASE64_CLASS, + ), + ) + } + + is SecurityScheme.ApiKey -> { + val paramName = "${scheme.name.toCamelCase()}Key" + builder.addStatement( + "append(%S, $paramName())", + scheme.parameterName, + ) + } + } + } + builder.endControlFlow() + } + + if (querySchemes.isNotEmpty()) { + builder.beginControlFlow("url") + for (scheme in querySchemes) { + val paramName = "${scheme.name.toCamelCase()}Key" + builder.addStatement( + "parameters.append(%S, $paramName())", + scheme.parameterName, + ) + } + builder.endControlFlow() + } + + return builder.build() + } private fun buildSafeCall(): FunSpec = FunSpec .builder(SAFE_CALL) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ClientGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ClientGenerator.kt index e182e6f..75463bd 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ClientGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ClientGenerator.kt @@ -5,6 +5,7 @@ import com.avsystem.justworks.core.model.Endpoint import com.avsystem.justworks.core.model.HttpMethod import com.avsystem.justworks.core.model.Parameter import com.avsystem.justworks.core.model.ParameterLocation +import com.avsystem.justworks.core.model.SecurityScheme import com.avsystem.justworks.core.model.TypeRef import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock @@ -34,13 +35,16 @@ private const val API_SUFFIX = "Api" class ClientGenerator(private val apiPackage: String, private val modelPackage: String) { fun generate(spec: ApiSpec, hasPolymorphicTypes: Boolean = false): List { val grouped = spec.endpoints.groupBy { it.tags.firstOrNull() ?: DEFAULT_TAG } - return grouped.map { (tag, endpoints) -> generateClientFile(tag, endpoints, hasPolymorphicTypes) } + return grouped.map { (tag, endpoints) -> + generateClientFile(tag, endpoints, hasPolymorphicTypes, spec.securitySchemes) + } } private fun generateClientFile( tag: String, endpoints: List, hasPolymorphicTypes: Boolean = false, + securitySchemes: List, ): FileSpec { val className = ClassName(apiPackage, "${tag.toPascalCase()}$API_SUFFIX") @@ -52,12 +56,21 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: } val tokenType = LambdaTypeName.get(returnType = STRING) + val authParams = ApiClientBaseGenerator.buildAuthConstructorParams(securitySchemes) - val primaryConstructor = FunSpec + val constructorBuilder = FunSpec .constructorBuilder() .addParameter(BASE_URL, STRING) - .addParameter(TOKEN, tokenType) - .build() + + val classBuilder = TypeSpec + .classBuilder(className) + .superclass(API_CLIENT_BASE) + .addSuperclassConstructorParameter(BASE_URL) + + for ((paramName, _) in authParams) { + constructorBuilder.addParameter(paramName, tokenType) + classBuilder.addSuperclassConstructorParameter(paramName) + } val httpClientProperty = PropertySpec .builder(CLIENT, HTTP_CLIENT) @@ -65,12 +78,8 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: .initializer(clientInitializer) .build() - val classBuilder = TypeSpec - .classBuilder(className) - .superclass(API_CLIENT_BASE) - .addSuperclassConstructorParameter(BASE_URL) - .addSuperclassConstructorParameter(TOKEN) - .primaryConstructor(primaryConstructor) + classBuilder + .primaryConstructor(constructorBuilder.build()) .addProperty(httpClientProperty) classBuilder.addFunctions(endpoints.map(::generateEndpointFunction)) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt index 10c6715..a29dc1e 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt @@ -14,7 +14,7 @@ object CodeGenerator { spec: ApiSpec, modelPackage: String, apiPackage: String, - outputDir: File + outputDir: File, ): Result { val modelFiles = ModelGenerator(modelPackage).generate(spec) modelFiles.forEach { it.writeTo(outputDir) } @@ -27,8 +27,10 @@ object CodeGenerator { return Result(modelFiles.size, clientFiles.size) } - fun generateSharedTypes(outputDir: File): Int { - val files = ApiResponseGenerator.generate() + ApiClientBaseGenerator.generate() + fun generateSharedTypes(outputDir: File, specs: List = emptyList()): Int { + val securitySchemes = specs.flatMap { it.securitySchemes } + + val files = ApiResponseGenerator.generate() + ApiClientBaseGenerator.generate(securitySchemes) files.forEach { it.writeTo(outputDir) } return files.size } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt index e2166db..dd95922 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt @@ -82,6 +82,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") diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt b/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt index fdcc056..f197bf4 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt @@ -7,12 +7,29 @@ package com.avsystem.justworks.core.model * code generators. Bridges the raw Swagger Parser OAS model and the generated * Kotlin client/model source files. */ +sealed interface SecurityScheme { + val name: String + + data class Bearer(override val name: String) : SecurityScheme + + data class ApiKey( + override val name: String, + val parameterName: String, + val location: ApiKeyLocation, + ) : SecurityScheme + + data class Basic(override val name: String) : SecurityScheme +} + +enum class ApiKeyLocation { HEADER, QUERY } + data class ApiSpec( val title: String, val version: String, val endpoints: List, val schemas: List, val enums: List, + val securitySchemes: List, ) data class Endpoint( diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt index 795396b..a613798 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -12,6 +12,7 @@ import arrow.core.raise.iorNel import arrow.core.raise.nullable import com.avsystem.justworks.core.Issue import com.avsystem.justworks.core.Warnings +import com.avsystem.justworks.core.model.ApiKeyLocation import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.Discriminator import com.avsystem.justworks.core.model.Endpoint @@ -25,16 +26,21 @@ import com.avsystem.justworks.core.model.PropertyModel import com.avsystem.justworks.core.model.RequestBody import com.avsystem.justworks.core.model.Response import com.avsystem.justworks.core.model.SchemaModel +import com.avsystem.justworks.core.model.SecurityScheme import com.avsystem.justworks.core.model.TypeRef import com.avsystem.justworks.core.warn import io.swagger.parser.OpenAPIParser import io.swagger.v3.oas.models.OpenAPI import io.swagger.v3.oas.models.PathItem import io.swagger.v3.oas.models.media.Schema +import io.swagger.v3.oas.models.security.SecurityRequirement import io.swagger.v3.parser.core.models.ParseOptions import java.io.File import java.util.IdentityHashMap +import kotlin.apply +import kotlin.collections.map import io.swagger.v3.oas.models.parameters.Parameter as SwaggerParameter +import io.swagger.v3.oas.models.security.SecurityScheme as SwaggerSecurityScheme /** * Result of parsing an OpenAPI specification file. @@ -43,7 +49,7 @@ import io.swagger.v3.oas.models.parameters.Parameter as SwaggerParameter * ```kotlin * when (val result = SpecParser.parse(file)) { * is ParseResult.Success -> result.apiSpec - * is ParseResult.Failure -> handleErrors(result.error) + * is ParseResult.Failure -> handleErrors(result.errors) * } * ``` * @@ -114,6 +120,11 @@ object SpecParser { private fun OpenAPI.toApiSpec(): ApiSpec { val allSchemas = components?.schemas.orEmpty() + val securitySchemes = extractSecuritySchemes( + components?.securitySchemes.orEmpty(), + security.orEmpty(), + ) + val componentSchemaIdentity = ComponentSchemaIdentity(allSchemas.size).apply { allSchemas.forEach { (name, schema) -> this[schema] = name } } @@ -157,10 +168,47 @@ object SpecParser { endpoints = endpoints, schemas = schemaModels + syntheticModels, enums = enumModels, + securitySchemes = securitySchemes, ) } } + @OptIn(ExperimentalRaiseAccumulateApi::class) + context(_: Warnings) + private fun extractSecuritySchemes( + definitions: Map, + requirements: List, + ): List { + val referencedNames = requirements.flatMap { it.keys }.toSet() + return referencedNames.mapNotNull { name -> + definitions[name]?.toSecurityScheme(name) + ?: warn("Security requirement references undefined scheme '$name'") + } + } + + context(_: Warnings) + private fun SwaggerSecurityScheme.toSecurityScheme(name: String): SecurityScheme? = when (type) { + SwaggerSecurityScheme.Type.HTTP -> { + when (scheme?.lowercase()) { + "bearer" -> SecurityScheme.Bearer(name) + "basic" -> SecurityScheme.Basic(name) + else -> warn("Unsupported HTTP auth scheme '$scheme' for '$name'") + } + } + + SwaggerSecurityScheme.Type.APIKEY -> { + when (`in`) { + SwaggerSecurityScheme.In.HEADER -> SecurityScheme.ApiKey(name, this.name, ApiKeyLocation.HEADER) + SwaggerSecurityScheme.In.QUERY -> SecurityScheme.ApiKey(name, this.name, ApiKeyLocation.QUERY) + else -> warn("Unsupported API key location '${`in`}' for '$name'") + } + } + + else -> { + warn("Unsupported security scheme type '$type' for '$name'") + } + } + context(_: ComponentSchemaIdentity, _: ComponentSchemas) private fun extractEndpoints(paths: Map): List = paths .asSequence() @@ -213,7 +261,7 @@ object SpecParser { } }.toList() - context(_: ComponentSchemaIdentity, _: ComponentSchemas) + context (_: ComponentSchemaIdentity, _: ComponentSchemas) private fun SwaggerParameter.toParameter(): Parameter = Parameter( name = name ?: "", location = ParameterLocation.parse(`in`) ?: ParameterLocation.QUERY, @@ -224,7 +272,7 @@ object SpecParser { // --- Schema extraction --- - context(_: Raise, _: ComponentSchemaIdentity, _: ComponentSchemas) + context (_: Raise, _: ComponentSchemaIdentity, _: ComponentSchemas) private fun extractSchemaModel(name: String, schema: Schema<*>): SchemaModel { val allOf = schema.allOf?.mapNotNull { it.resolveName() } @@ -285,7 +333,7 @@ object SpecParser { // --- allOf property merging --- - context(_: ComponentSchemaIdentity, _: ComponentSchemas) + context (_: ComponentSchemaIdentity, _: ComponentSchemas) private fun extractAllOfProperties(parentName: String, schema: Schema<*>): Pair, Set> { val topRequired = schema.required.orEmpty().toSet() val contextCreator: (String) -> String? = { propName -> "$parentName.${propName.toPascalCase()}" } @@ -305,7 +353,7 @@ object SpecParser { return finalProperties to required } - context(_: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) + context (_: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) private fun Schema<*>.resolveSubSchema(): Schema<*> = resolveName()?.let { componentSchemas[it] } ?: this /** @@ -321,7 +369,7 @@ object SpecParser { * * Returns: Pair of (unwrapped oneOf refs, synthetic discriminator) or null if pattern not matched. */ - context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) + context (componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) private fun detectAndUnwrapOneOfWrappers(schema: Schema<*>): Pair, Discriminator>? = nullable { ensure(!schema.oneOf.isNullOrEmpty() && schema.discriminator == null) @@ -354,14 +402,14 @@ object SpecParser { unwrapped.values.toList() to Discriminator(propertyName = "type", mapping = mapping) } - context(_: ComponentSchemaIdentity, _: ComponentSchemas) + context (_: ComponentSchemaIdentity, _: ComponentSchemas) private fun Schema<*>.toTypeRef(contextName: String? = null): TypeRef = contextName?.let { toInlineTypeRef(it) } ?: (resolveName() ?: allOf?.singleOrNull()?.resolveName())?.let(TypeRef::Reference) ?: TypeRef.Unknown.takeIf { (allOf?.size ?: 0) > 1 } ?: resolveByType(contextName) /** Resolves a [TypeRef] based on the schema's structural type/format, ignoring component identity. */ - context(_: ComponentSchemaIdentity, _: ComponentSchemas) + context (_: ComponentSchemaIdentity, _: ComponentSchemas) private fun Schema<*>.resolveByType(contextName: String? = null): TypeRef = when (type) { "string" -> STRING_FORMAT_MAP[format] ?: TypeRef.Primitive(PrimitiveType.STRING) @@ -382,7 +430,7 @@ object SpecParser { else -> TypeRef.Unknown } - context(_: ComponentSchemaIdentity, _: ComponentSchemas) + context (_: ComponentSchemaIdentity, _: ComponentSchemas) private fun Schema<*>.toInlineTypeRef(contextName: String): TypeRef? = takeIf { isInlineObject }?.let { val required = required.orEmpty().toSet() TypeRef.Inline( @@ -392,10 +440,10 @@ object SpecParser { ) } - context(componentSchemaIdentity: ComponentSchemaIdentity) + context (componentSchemaIdentity: ComponentSchemaIdentity) private fun Schema<*>.resolveName(): String? = `$ref`?.removePrefix(SCHEMA_PREFIX) ?: componentSchemaIdentity[this] - context(componentSchemaIdentity: ComponentSchemaIdentity) + context (componentSchemaIdentity: ComponentSchemaIdentity) private val Schema<*>.isInlineObject get(): Boolean = `$ref` == null && this !in componentSchemaIdentity && type == "object" && !properties.isNullOrEmpty() diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt index b2f98a6..d735219 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt @@ -1,5 +1,7 @@ package com.avsystem.justworks.core.gen +import com.avsystem.justworks.core.model.ApiKeyLocation +import com.avsystem.justworks.core.model.SecurityScheme import com.squareup.kotlinpoet.ExperimentalKotlinPoetApi import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.KModifier @@ -12,14 +14,32 @@ import kotlin.test.assertTrue @OptIn(ExperimentalKotlinPoetApi::class) class ApiClientBaseGeneratorTest { - private val file = ApiClientBaseGenerator.generate() + private val file = ApiClientBaseGenerator.generate(emptyList()) private val classSpec: TypeSpec get() = file.members.filterIsInstance().first { it.name == "ApiClientBase" } private fun topLevelFun(name: String): FunSpec = file.members.filterIsInstance().first { it.name == name } - // -- ApiClientBase class -- + private fun classFor(schemes: List): TypeSpec { + val f = ApiClientBaseGenerator.generate(schemes) + return f.members.filterIsInstance().first { it.name == "ApiClientBase" } + } + + private fun applyAuthBody(schemes: List): String { + val cls = classFor(schemes) + return cls.funSpecs + .first { it.name == "applyAuth" } + .body + .toString() + } + + private fun constructorParamNames(schemes: List): List { + val cls = classFor(schemes) + return cls.primaryConstructor!!.parameters.map { it.name } + } + + // -- ApiClientBase class (no-arg backward compat) -- @Test fun `ApiClientBase is abstract`() { @@ -33,13 +53,10 @@ class ApiClientBaseGeneratorTest { } @Test - fun `ApiClientBase has constructor with baseUrl and token provider`() { + fun `ApiClientBase has constructor with only baseUrl when no schemes`() { val constructor = assertNotNull(classSpec.primaryConstructor) val paramNames = constructor.parameters.map { it.name } - assertTrue("baseUrl" in paramNames) - assertTrue("token" in paramNames) - val tokenParam = constructor.parameters.first { it.name == "token" } - assertEquals("() -> kotlin.String", tokenParam.type.toString(), "token should be a () -> String lambda") + assertEquals(listOf("baseUrl"), paramNames) } @Test @@ -58,14 +75,12 @@ class ApiClientBaseGeneratorTest { } @Test - fun `ApiClientBase has applyAuth function`() { + fun `ApiClientBase has empty applyAuth when no schemes`() { val applyAuth = classSpec.funSpecs.first { it.name == "applyAuth" } assertTrue(KModifier.PROTECTED in applyAuth.modifiers) assertNotNull(applyAuth.receiverType, "Expected HttpRequestBuilder receiver") val body = applyAuth.body.toString() - assertTrue(body.contains("Authorization"), "Expected Authorization header") - assertTrue(body.contains("Bearer"), "Expected Bearer prefix") - assertTrue(body.contains("token()"), "Expected token() invocation") + assertTrue(!body.contains("Authorization"), "Expected no Authorization header for empty schemes") } @Test @@ -131,4 +146,134 @@ class ApiClientBaseGeneratorTest { fun `generates single file named ApiClientBase`() { assertEquals("ApiClientBase", file.name) } + + // -- Security scheme: single Bearer (backward compat) -- + + @Test + fun `single Bearer scheme uses token param name for backward compat`() { + val params = constructorParamNames(listOf(SecurityScheme.Bearer("BearerAuth"))) + assertTrue("baseUrl" in params, "Expected baseUrl param") + assertTrue("token" in params, "Expected token param (single-bearer shorthand)") + } + + @Test + fun `single Bearer scheme generates Bearer auth in applyAuth`() { + val body = applyAuthBody(listOf(SecurityScheme.Bearer("BearerAuth"))) + assertTrue(body.contains("Authorization"), "Expected Authorization header") + assertTrue(body.contains("Bearer"), "Expected Bearer prefix") + assertTrue(body.contains("token()"), "Expected token() invocation") + } + + // -- Security scheme: ApiKey in header -- + + @Test + fun `ApiKey HEADER scheme generates constructor param with Key suffix`() { + val params = constructorParamNames( + listOf(SecurityScheme.ApiKey("ApiKeyHeader", "X-API-Key", ApiKeyLocation.HEADER)), + ) + assertTrue("baseUrl" in params, "Expected baseUrl param") + assertTrue("apiKeyHeaderKey" in params, "Expected apiKeyHeaderKey param") + } + + @Test + fun `ApiKey HEADER scheme generates header append in applyAuth`() { + val body = applyAuthBody( + listOf(SecurityScheme.ApiKey("ApiKeyHeader", "X-API-Key", ApiKeyLocation.HEADER)), + ) + assertTrue(body.contains("headers"), "Expected headers block") + assertTrue(body.contains("X-API-Key"), "Expected X-API-Key header name") + assertTrue(body.contains("apiKeyHeaderKey()"), "Expected apiKeyHeaderKey() invocation") + } + + // -- Security scheme: ApiKey in query -- + + @Test + fun `ApiKey QUERY scheme generates constructor param with Key suffix`() { + val params = constructorParamNames( + listOf(SecurityScheme.ApiKey("ApiKeyQuery", "api_key", ApiKeyLocation.QUERY)), + ) + assertTrue("baseUrl" in params, "Expected baseUrl param") + assertTrue("apiKeyQueryKey" in params, "Expected apiKeyQueryKey param") + } + + @Test + fun `ApiKey QUERY scheme generates query parameter in applyAuth`() { + val body = applyAuthBody( + listOf(SecurityScheme.ApiKey("ApiKeyQuery", "api_key", ApiKeyLocation.QUERY)), + ) + assertTrue(body.contains("url"), "Expected url block") + assertTrue(body.contains("parameters.append"), "Expected parameters.append") + assertTrue(body.contains("api_key"), "Expected api_key query param name") + assertTrue(body.contains("apiKeyQueryKey()"), "Expected apiKeyQueryKey() invocation") + } + + // -- Security scheme: HTTP Basic -- + + @Test + fun `Basic scheme generates username and password constructor params`() { + val params = constructorParamNames(listOf(SecurityScheme.Basic("BasicAuth"))) + assertTrue("baseUrl" in params, "Expected baseUrl param") + assertTrue("basicAuthUsername" in params, "Expected basicAuthUsername param") + assertTrue("basicAuthPassword" in params, "Expected basicAuthPassword param") + } + + @Test + fun `Basic scheme generates Base64 Authorization header in applyAuth`() { + val body = applyAuthBody(listOf(SecurityScheme.Basic("BasicAuth"))) + assertTrue(body.contains("Authorization"), "Expected Authorization header") + assertTrue(body.contains("Basic"), "Expected Basic prefix") + assertTrue(body.contains("Base64"), "Expected Base64 encoding") + assertTrue(body.contains("basicAuthUsername()"), "Expected basicAuthUsername() invocation") + assertTrue(body.contains("basicAuthPassword()"), "Expected basicAuthPassword() invocation") + } + + // -- Multiple schemes -- + + @Test + fun `multiple schemes generate all constructor params`() { + val params = constructorParamNames( + listOf( + SecurityScheme.Bearer("BearerAuth"), + SecurityScheme.ApiKey("ApiKeyHeader", "X-API-Key", ApiKeyLocation.HEADER), + SecurityScheme.Basic("BasicAuth"), + ), + ) + assertTrue("baseUrl" in params, "Expected baseUrl param") + assertTrue("bearerAuthToken" in params, "Expected bearerAuthToken param (multi-scheme uses full name)") + assertTrue("apiKeyHeaderKey" in params, "Expected apiKeyHeaderKey param") + assertTrue("basicAuthUsername" in params, "Expected basicAuthUsername param") + assertTrue("basicAuthPassword" in params, "Expected basicAuthPassword param") + } + + @Test + fun `multiple schemes generate all auth types in applyAuth`() { + val body = applyAuthBody( + listOf( + SecurityScheme.Bearer("BearerAuth"), + SecurityScheme.ApiKey("ApiKeyHeader", "X-API-Key", ApiKeyLocation.HEADER), + SecurityScheme.ApiKey("ApiKeyQuery", "api_key", ApiKeyLocation.QUERY), + SecurityScheme.Basic("BasicAuth"), + ), + ) + assertTrue(body.contains("Bearer"), "Expected Bearer in applyAuth") + assertTrue(body.contains("X-API-Key"), "Expected X-API-Key in applyAuth") + assertTrue(body.contains("api_key"), "Expected api_key query param in applyAuth") + assertTrue(body.contains("Basic"), "Expected Basic in applyAuth") + assertTrue(body.contains("Base64"), "Expected Base64 in applyAuth") + } + + // -- Empty schemes (spec with no security) -- + + @Test + fun `empty schemes list generates only baseUrl constructor param`() { + val params = constructorParamNames(emptyList()) + assertEquals(listOf("baseUrl"), params, "Expected only baseUrl param when no security schemes") + } + + @Test + fun `empty schemes list generates empty applyAuth body`() { + val body = applyAuthBody(emptyList()) + assertTrue(!body.contains("headers"), "Expected no headers block for empty schemes") + assertTrue(!body.contains("url"), "Expected no url block for empty schemes") + } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt index 3f60c32..4ef599b 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt @@ -1,5 +1,6 @@ package com.avsystem.justworks.core.gen +import com.avsystem.justworks.core.model.ApiKeyLocation import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.Endpoint import com.avsystem.justworks.core.model.HttpMethod @@ -8,6 +9,7 @@ import com.avsystem.justworks.core.model.ParameterLocation import com.avsystem.justworks.core.model.PrimitiveType import com.avsystem.justworks.core.model.RequestBody import com.avsystem.justworks.core.model.Response +import com.avsystem.justworks.core.model.SecurityScheme import com.avsystem.justworks.core.model.TypeRef import com.squareup.kotlinpoet.ExperimentalKotlinPoetApi import com.squareup.kotlinpoet.KModifier @@ -23,12 +25,13 @@ class ClientGeneratorTest { private val modelPackage = "com.example.model" private val generator = ClientGenerator(apiPackage, modelPackage) - private fun spec(endpoints: List) = ApiSpec( + private fun spec(endpoints: List, securitySchemes: List = emptyList()) = ApiSpec( title = "Test", version = "1.0", endpoints = endpoints, schemas = emptyList(), enums = emptyList(), + securitySchemes = securitySchemes, ) private fun endpoint( @@ -53,8 +56,8 @@ class ClientGeneratorTest { responses = responses, ) - private fun clientClass(endpoints: List): TypeSpec { - val files = generator.generate(spec(endpoints)) + private fun clientClass(endpoints: List, securitySchemes: List = emptyList()): TypeSpec { + val files = generator.generate(spec(endpoints, securitySchemes)) return files .first() .members @@ -294,14 +297,14 @@ class ClientGeneratorTest { assertEquals("kotlin.String", baseUrl.type.toString()) } - // -- AUTH-01: Client constructor has token parameter -- + // -- No security: constructor has only baseUrl -- @Test - fun `client constructor has token provider parameter`() { + fun `no security schemes generates constructor with only baseUrl`() { val cls = clientClass(listOf(endpoint())) val constructor = assertNotNull(cls.primaryConstructor) - val token = constructor.parameters.first { it.name == "token" } - assertEquals("() -> kotlin.String", token.type.toString(), "token should be a () -> String lambda") + val paramNames = constructor.parameters.map { it.name } + assertEquals(listOf("baseUrl"), paramNames) } // -- Pitfall 3: Untagged endpoints go to DefaultClient -- @@ -425,4 +428,98 @@ class ClientGeneratorTest { val body = funSpec.body.toString() assertTrue(body.contains("toEmptyResult"), "Expected toEmptyResult call") } + + // -- SECU: Security-aware constructor generation -- + + @Test + fun `no securitySchemes generates constructor with only baseUrl`() { + val cls = clientClass(listOf(endpoint())) + val constructor = assertNotNull(cls.primaryConstructor) + val paramNames = constructor.parameters.map { it.name } + assertEquals(listOf("baseUrl"), paramNames) + } + + @Test + fun `ApiKey HEADER scheme generates constructor with baseUrl and apiKey param`() { + val cls = clientClass( + listOf(endpoint()), + listOf(SecurityScheme.ApiKey("ApiKeyHeader", "X-API-Key", ApiKeyLocation.HEADER)), + ) + val constructor = assertNotNull(cls.primaryConstructor) + val paramNames = constructor.parameters.map { it.name } + assertTrue("baseUrl" in paramNames, "Expected baseUrl param") + assertTrue("apiKeyHeaderKey" in paramNames, "Expected apiKeyHeaderKey param") + } + + @Test + fun `Basic scheme generates constructor with baseUrl, username, and password`() { + val cls = clientClass( + listOf(endpoint()), + listOf(SecurityScheme.Basic("BasicAuth")), + ) + val constructor = assertNotNull(cls.primaryConstructor) + val paramNames = constructor.parameters.map { it.name } + assertTrue("baseUrl" in paramNames, "Expected baseUrl param") + assertTrue("basicAuthUsername" in paramNames, "Expected basicAuthUsername param") + assertTrue("basicAuthPassword" in paramNames, "Expected basicAuthPassword param") + } + + @Test + fun `multiple schemes generate all constructor params and pass all to super`() { + val cls = clientClass( + listOf(endpoint()), + listOf( + SecurityScheme.Bearer("BearerAuth"), + SecurityScheme.ApiKey("ApiKeyHeader", "X-API-Key", ApiKeyLocation.HEADER), + ), + ) + val constructor = assertNotNull(cls.primaryConstructor) + val paramNames = constructor.parameters.map { it.name } + assertTrue("baseUrl" in paramNames, "Expected baseUrl param") + assertTrue("bearerAuthToken" in paramNames, "Expected bearerAuthToken param") + assertTrue("apiKeyHeaderKey" in paramNames, "Expected apiKeyHeaderKey param") + + // Verify superclass constructor params match + val superParams = cls.superclassConstructorParameters.map { it.toString().trim() } + assertTrue(superParams.contains("baseUrl"), "Expected baseUrl passed to super") + assertTrue(superParams.contains("bearerAuthToken"), "Expected bearerAuthToken passed to super") + assertTrue(superParams.contains("apiKeyHeaderKey"), "Expected apiKeyHeaderKey passed to super") + } + + @Test + fun `explicit empty securitySchemes generates constructor with only baseUrl`() { + // Explicit empty securitySchemes = spec has security: [] (no auth required) + val spec = ApiSpec( + title = "Test", + version = "1.0", + endpoints = listOf(endpoint()), + schemas = emptyList(), + enums = emptyList(), + securitySchemes = emptyList(), + ) + val files = generator.generate(spec) + val cls = files + .first() + .members + .filterIsInstance() + .first() + val constructor = assertNotNull(cls.primaryConstructor) + val paramNames = constructor.parameters.map { it.name } + assertEquals( + listOf("baseUrl"), + paramNames, + "Expected only baseUrl param when security is explicitly empty", + ) + } + + @Test + fun `single Bearer scheme uses token param name as shorthand`() { + val cls = clientClass( + listOf(endpoint()), + listOf(SecurityScheme.Bearer("BearerAuth")), + ) + val constructor = assertNotNull(cls.primaryConstructor) + val paramNames = constructor.parameters.map { it.name } + assertTrue("token" in paramNames, "Expected token param (single-bearer shorthand)") + } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt index cbf97de..4316169 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt @@ -78,7 +78,7 @@ class IntegrationTest { val spec = parseSpec(fixture).apiSpec if (spec.endpoints.isEmpty()) continue - val apiClientBaseFile = ApiClientBaseGenerator.generate() + val apiClientBaseFile = ApiClientBaseGenerator.generate(spec.securitySchemes) assertNotNull(apiClientBaseFile, "$fixture: ApiClientBaseGenerator should produce output") val source = apiClientBaseFile.toString() @@ -111,7 +111,7 @@ class IntegrationTest { ) } - val apiClientBaseFile = ApiClientBaseGenerator.generate() + val apiClientBaseFile = ApiClientBaseGenerator.generate(spec.securitySchemes) assertNotNull(apiClientBaseFile, "$fixture: ApiClientBaseGenerator should produce output") } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorPolymorphicTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorPolymorphicTest.kt index 649d8f5..62768e7 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorPolymorphicTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorPolymorphicTest.kt @@ -24,6 +24,7 @@ class ModelGeneratorPolymorphicTest { endpoints = emptyList(), schemas = schemas, enums = enums, + securitySchemes = emptyList(), ) private fun schema( diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorTest.kt index 9507b32..00fc0ed 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorTest.kt @@ -29,6 +29,7 @@ class ModelGeneratorTest { endpoints = emptyList(), schemas = schemas, enums = enums, + securitySchemes = emptyList(), ) private val petSchema = @@ -1368,6 +1369,7 @@ class ModelGeneratorTest { endpoints = listOf(endpoint), schemas = emptyList(), enums = emptyList(), + securitySchemes = emptyList(), ) val files = generator.generate(apiSpec) val uuidSerializerFile = files.find { it.name == "UuidSerializer" } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserSecurityTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserSecurityTest.kt new file mode 100644 index 0000000..92d1c3a --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserSecurityTest.kt @@ -0,0 +1,70 @@ +package com.avsystem.justworks.core.parser + +import com.avsystem.justworks.core.model.ApiKeyLocation +import com.avsystem.justworks.core.model.ApiSpec +import com.avsystem.justworks.core.model.SecurityScheme +import org.junit.jupiter.api.TestInstance +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class SpecParserSecurityTest : SpecParserTestBase() { + private lateinit var apiSpec: ApiSpec + + @BeforeTest + fun setUp() { + if (!::apiSpec.isInitialized) { + apiSpec = parseSpec(loadResource("security-schemes-spec.yaml")) + } + } + + @Test + fun `parses exactly 4 security schemes from fixture`() { + assertEquals(4, apiSpec.securitySchemes.size) + } + + @Test + fun `parses Bearer security scheme`() { + val bearer = apiSpec.securitySchemes.filterIsInstance() + assertEquals(1, bearer.size) + assertEquals("BearerAuth", bearer.single().name) + } + + @Test + fun `parses ApiKey header security scheme`() { + val apiKeys = apiSpec.securitySchemes.filterIsInstance() + val header = apiKeys.single { it.location == ApiKeyLocation.HEADER } + assertEquals("ApiKeyHeader", header.name) + assertEquals("X-API-Key", header.parameterName) + } + + @Test + fun `parses ApiKey query security scheme`() { + val apiKeys = apiSpec.securitySchemes.filterIsInstance() + val query = apiKeys.single { it.location == ApiKeyLocation.QUERY } + assertEquals("ApiKeyQuery", query.name) + assertEquals("api_key", query.parameterName) + } + + @Test + fun `parses Basic security scheme`() { + val basic = apiSpec.securitySchemes.filterIsInstance() + assertEquals(1, basic.size) + assertEquals("BasicAuth", basic.single().name) + } + + @Test + fun `excludes unreferenced OAuth2 scheme`() { + val names = apiSpec.securitySchemes.map { it.name } + assertTrue("UnusedOAuth" !in names, "UnusedOAuth should not be in parsed schemes") + } + + @Test + fun `spec without security field produces empty securitySchemes`() { + val petstore = parseSpec(loadResource("petstore.yaml")) + assertTrue(petstore.securitySchemes.isEmpty(), "petstore should have no security schemes") + } +} diff --git a/core/src/test/resources/security-schemes-spec.yaml b/core/src/test/resources/security-schemes-spec.yaml new file mode 100644 index 0000000..029ee25 --- /dev/null +++ b/core/src/test/resources/security-schemes-spec.yaml @@ -0,0 +1,43 @@ +openapi: "3.0.3" +info: + title: Security Schemes Test API + version: "1.0.0" + +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + ApiKeyHeader: + type: apiKey + in: header + name: X-API-Key + ApiKeyQuery: + type: apiKey + in: query + name: api_key + BasicAuth: + type: http + scheme: basic + UnusedOAuth: + type: oauth2 + flows: + implicit: + authorizationUrl: https://example.com/oauth/authorize + scopes: + read: Read access + +security: + - BearerAuth: [] + - ApiKeyHeader: [] + - ApiKeyQuery: [] + - BasicAuth: [] + +paths: + /health: + get: + operationId: getHealth + summary: Health check + responses: + "200": + description: OK diff --git a/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt b/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt index c95eeb3..6347cf9 100644 --- a/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt +++ b/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt @@ -495,6 +495,125 @@ class JustworksPluginFunctionalTest { assertTrue(result.output.contains("Invalid spec name 'pet-store'")) } + @Test + fun `spec with security schemes generates ApiClientBase with applyAuth body`() { + writeFile( + "api/secured.yaml", + """ + openapi: '3.0.0' + info: + title: Secured API + version: '1.0' + paths: + /data: + get: + operationId: getData + summary: Get data + tags: + - data + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + value: + type: string + components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + BasicAuth: + type: http + scheme: basic + security: + - ApiKeyAuth: [] + - BasicAuth: [] + """.trimIndent(), + ) + + writeFile( + "build.gradle.kts", + """ + plugins { + kotlin("jvm") version "2.3.0" + kotlin("plugin.serialization") version "2.3.0" + id("com.avsystem.justworks") + } + + repositories { + mavenCentral() + } + + dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.8.1") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1") + implementation("io.ktor:ktor-client-core:3.1.1") + implementation("io.ktor:ktor-client-content-negotiation:3.1.1") + implementation("io.ktor:ktor-serialization-kotlinx-json:3.1.1") + implementation("io.arrow-kt:arrow-core:2.2.1.1") + } + + kotlin { + compilerOptions { + freeCompilerArgs.add("-Xcontext-parameters") + } + } + + justworks { + specs { + register("secured") { + specFile = file("api/secured.yaml") + packageName = "com.example.secured" + } + } + } + """.trimIndent(), + ) + + val result = runner("justworksGenerateSecured").build() + + assertEquals( + TaskOutcome.SUCCESS, + result.task(":justworksGenerateSecured")?.outcome, + ) + + val apiClientBase = projectDir + .resolve("build/generated/justworks/shared/kotlin/com/avsystem/justworks/ApiClientBase.kt") + assertTrue(apiClientBase.exists(), "ApiClientBase.kt should exist") + + val content = apiClientBase.readText() + assertTrue(content.contains("apiKeyAuthKey"), "Should contain apiKeyAuthKey param") + assertTrue(content.contains("basicAuthUsername"), "Should contain basicAuthUsername param") + assertTrue(content.contains("basicAuthPassword"), "Should contain basicAuthPassword param") + assertTrue(content.contains("X-API-Key"), "Should contain X-API-Key header name") + assertTrue(content.contains("applyAuth"), "Should contain applyAuth method") + assertTrue(content.contains("Authorization"), "Should contain Authorization header for Basic auth") + assertFalse( + content.contains("token: () -> String"), + "Should NOT contain backward-compat token param when explicit security schemes present", + ) + } + + @Test + fun `spec without security schemes generates ApiClientBase with no auth params`() { + writeBuildFile() + + runner("justworksGenerateMain").build() + + val apiClientBase = projectDir + .resolve("build/generated/justworks/shared/kotlin/com/avsystem/justworks/ApiClientBase.kt") + assertTrue(apiClientBase.exists(), "ApiClientBase.kt should exist") + + val content = apiClientBase.readText() + assertTrue(!content.contains("token"), "Should NOT contain token param when no security schemes") + assertTrue(!content.contains("Bearer"), "Should NOT contain Bearer when no security schemes") + } + @Test fun `empty specs container logs warning`() { writeFile( diff --git a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksPlugin.kt b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksPlugin.kt index 707dbbd..92df1f9 100644 --- a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksPlugin.kt +++ b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksPlugin.kt @@ -60,6 +60,11 @@ class JustworksPlugin : Plugin { task.description = "Generate Kotlin client from '${spec.name}' OpenAPI spec" } + // Wire spec file into shared types task for security scheme extraction + sharedTypesTask.configure { task -> + task.specFiles.from(spec.specFile) + } + // Wire spec task into aggregate task generateAllTask.configure { it.dependsOn(specTask) } } diff --git a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt index a6b915e..7ce74f2 100644 --- a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt +++ b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt @@ -1,18 +1,33 @@ package com.avsystem.justworks.gradle import com.avsystem.justworks.core.gen.CodeGenerator +import com.avsystem.justworks.core.parser.ParseResult +import com.avsystem.justworks.core.parser.SpecParser import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction /** - * Gradle task that generates shared types (HttpError, Success) once + * Gradle task that generates shared types (HttpError, Success, ApiClientBase) once * to a fixed output directory shared across all spec configurations. + * + * When [specFiles] are configured, the task parses them to extract security schemes + * and passes them to ApiClientBase generation so the generated auth code reflects + * the spec's security configuration. */ @CacheableTask abstract class JustworksSharedTypesTask : DefaultTask() { + /** All configured spec files — used to extract security schemes. */ + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val specFiles: ConfigurableFileCollection + /** Output directory for shared type files. */ @get:OutputDirectory abstract val outputDir: DirectoryProperty @@ -21,8 +36,17 @@ abstract class JustworksSharedTypesTask : DefaultTask() { fun generate() { val outDir = outputDir.get().asFile.recreateDirectory() - val count = CodeGenerator.generateSharedTypes(outDir) + val specs = specFiles.files.sortedBy { it.path }.mapNotNull { file -> + when (val result = SpecParser.parse(file)) { + is ParseResult.Success -> result.apiSpec + is ParseResult.Failure -> { + logger.warn("Failed to parse spec '${file.name}': ${result.error}") + null + } + } + } + val count = CodeGenerator.generateSharedTypes(outDir, specs) logger.lifecycle("Generated $count shared type files") } } From 3a28795d35852d2bdf2404f27541d6b3f63d3f91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 2 Apr 2026 13:47:02 +0200 Subject: [PATCH 02/23] refactor: streamline property handling in `ApiClientBaseGenerator` and replace `warn` with `accumulate` - Replace mutable property list with direct property creation in `classBuilder`. - Remove redundant `warn` function and centralize warning handling using `accumulate`. - Update context receiver parameter names in `ArrowHelpers` for clarity. - Enforce non-null checks and ensure consistent warning accumulation in `SpecParser`. --- .../avsystem/justworks/core/ArrowHelpers.kt | 14 +++++++--- .../com/avsystem/justworks/core/Issue.kt | 6 ----- .../justworks/core/gen/CodeGenerator.kt | 2 +- .../core/gen/shared/ApiClientBaseGenerator.kt | 26 ++++++++----------- .../justworks/core/parser/SpecParser.kt | 18 ++++++++----- 5 files changed, 33 insertions(+), 33 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/ArrowHelpers.kt b/core/src/main/kotlin/com/avsystem/justworks/core/ArrowHelpers.kt index 5777d9f..42dd965 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/ArrowHelpers.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/ArrowHelpers.kt @@ -8,19 +8,25 @@ import kotlin.contracts.InvocationKind.AT_MOST_ONCE import kotlin.contracts.contract @OptIn(ExperimentalContracts::class) -context(warnings: IorRaise>) +context(iorRaise: IorRaise>) inline fun 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>) +context(iorRaise: IorRaise>) inline fun ensureNotNullOrAccumulate(value: B?, error: () -> Error) { contract { callsInPlace(error, AT_MOST_ONCE) } if (value == null) { - warnings.accumulate(nonEmptyListOf(error())) + iorRaise.accumulate(nonEmptyListOf(error())) } } + +context(iorRaise: IorRaise>) +fun accumulate(error: Error): Nothing? { + iorRaise.accumulate(nonEmptyListOf(error)) + return null +} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt b/core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt index 9b2584f..549de2c 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt @@ -16,9 +16,3 @@ object Issue { } typealias Warnings = IorRaise> - -context(warnings: Warnings) -fun warn(message: String): Nothing? { - warnings.accumulate(nonEmptyListOf(Issue.Warning(message))) - return null -} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt index 3f91244..b043a14 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt @@ -36,7 +36,7 @@ object CodeGenerator { return Result(modelFiles.size, clientFiles.size) } - fun generateSharedTypes(outputDir: File, specs: List = emptyList()): Int { + fun generateSharedTypes(outputDir: File, specs: List): Int { val securitySchemes = specs.flatMap { it.securitySchemes } val files = ApiResponseGenerator.generate() + ApiClientBaseGenerator.generate(securitySchemes) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt index dd168c2..c0ed802 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt @@ -44,6 +44,7 @@ import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.PropertySpec import com.squareup.kotlinpoet.STRING import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.TypeSpec.Companion.classBuilder import com.squareup.kotlinpoet.TypeVariableName import com.squareup.kotlinpoet.UNIT @@ -144,18 +145,23 @@ internal object ApiClientBaseGenerator { .constructorBuilder() .addParameter(BASE_URL, STRING) - val propertySpecs = mutableListOf() + val classBuilder = TypeSpec + .classBuilder(API_CLIENT_BASE) + .addModifiers(KModifier.ABSTRACT) + .addSuperinterface(CLOSEABLE) + .primaryConstructor(constructorBuilder.build()) val baseUrlProp = PropertySpec .builder(BASE_URL, STRING) .initializer(BASE_URL) .addModifiers(KModifier.PROTECTED) .build() - propertySpecs.add(baseUrlProp) + + classBuilder.addProperty(baseUrlProp) for ((paramName, _) in authParams) { constructorBuilder.addParameter(paramName, tokenType) - propertySpecs.add( + classBuilder.addProperty( PropertySpec .builder(paramName, tokenType) .initializer(paramName) @@ -175,16 +181,6 @@ internal object ApiClientBaseGenerator { .addStatement("$CLIENT.close()") .build() - val classBuilder = TypeSpec - .classBuilder(API_CLIENT_BASE) - .addModifiers(KModifier.ABSTRACT) - .addSuperinterface(CLOSEABLE) - .primaryConstructor(constructorBuilder.build()) - - for (prop in propertySpecs) { - classBuilder.addProperty(prop) - } - return classBuilder .addProperty(clientProp) .addFunction(closeFun) @@ -251,7 +247,7 @@ internal object ApiClientBaseGenerator { builder.addStatement( "append(%T.Authorization, %P)", HTTP_HEADERS, - CodeBlock.of("Bearer \${$paramName()}"), + CodeBlock.of($$"Bearer ${$$paramName()}"), ) } @@ -262,7 +258,7 @@ internal object ApiClientBaseGenerator { "append(%T.Authorization, %P)", HTTP_HEADERS, CodeBlock.of( - "Basic \${%T.getEncoder().encodeToString(\"${'$'}{$usernameParam()}:${'$'}{$passwordParam()}\".toByteArray())}", + $$"Basic ${%T.getEncoder().encodeToString(\"${$$usernameParam()}:${$$passwordParam()}\".toByteArray())}", BASE64_CLASS, ), ) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt index 345a8ee..38a6bb7 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -5,6 +5,7 @@ import arrow.core.getOrElse import arrow.core.left import arrow.core.raise.ExperimentalRaiseAccumulateApi import arrow.core.raise.Raise +import arrow.core.raise.context.accumulate import arrow.core.raise.context.either import arrow.core.raise.context.ensure import arrow.core.raise.context.ensureNotNull @@ -13,6 +14,8 @@ import arrow.core.raise.nullable import arrow.core.toNonEmptyListOrNull import com.avsystem.justworks.core.Issue import com.avsystem.justworks.core.Warnings +import com.avsystem.justworks.core.accumulate +import com.avsystem.justworks.core.ensureNotNullOrAccumulate import com.avsystem.justworks.core.model.ApiKeyLocation import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.ContentType @@ -31,7 +34,6 @@ import com.avsystem.justworks.core.model.SchemaModel import com.avsystem.justworks.core.model.SecurityScheme import com.avsystem.justworks.core.model.TypeRef import com.avsystem.justworks.core.toEnumOrNull -import com.avsystem.justworks.core.warn import io.swagger.parser.OpenAPIParser import io.swagger.v3.oas.models.OpenAPI import io.swagger.v3.oas.models.PathItem @@ -181,7 +183,6 @@ object SpecParser { } } - @OptIn(ExperimentalRaiseAccumulateApi::class) context(_: Warnings) private fun extractSecuritySchemes( definitions: Map, @@ -189,8 +190,11 @@ object SpecParser { ): List { val referencedNames = requirements.flatMap { it.keys }.toSet() return referencedNames.mapNotNull { name -> - definitions[name]?.toSecurityScheme(name) - ?: warn("Security requirement references undefined scheme '$name'") + definitions[name]?.toSecurityScheme(name).also { + ensureNotNullOrAccumulate(it) { + Issue.Warning("Security requirement references undefined scheme '$name'") + } + } } } @@ -200,7 +204,7 @@ object SpecParser { when (scheme?.lowercase()) { "bearer" -> SecurityScheme.Bearer(name) "basic" -> SecurityScheme.Basic(name) - else -> warn("Unsupported HTTP auth scheme '$scheme' for '$name'") + else -> accumulate(Issue.Warning("Unsupported HTTP auth scheme '$scheme' for '$name'")) } } @@ -208,12 +212,12 @@ object SpecParser { when (`in`) { SwaggerSecurityScheme.In.HEADER -> SecurityScheme.ApiKey(name, this.name, ApiKeyLocation.HEADER) SwaggerSecurityScheme.In.QUERY -> SecurityScheme.ApiKey(name, this.name, ApiKeyLocation.QUERY) - else -> warn("Unsupported API key location '${`in`}' for '$name'") + else -> accumulate(Issue.Warning("Unsupported API key location '${`in`}' for '$name'")) } } else -> { - warn("Unsupported security scheme type '$type' for '$name'") + accumulate(Issue.Warning("Unsupported security scheme type '$type' for '$name'")) } } From 46cd5b81621e9d27ea73470f7633fb60baffe938 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 2 Apr 2026 14:40:29 +0200 Subject: [PATCH 03/23] refactor: simplify constructor migration and update context receiver syntax - Move `primaryConstructor` invocation back to `classBuilder`. - Adjust context receiver parameter syntax for improved consistency and readability. - Ensure `securitySchemes` are deduplicated by name in `CodeGenerator`. --- .../justworks/core/gen/CodeGenerator.kt | 2 +- .../core/gen/shared/ApiClientBaseGenerator.kt | 49 +++++++++---------- .../justworks/core/parser/SpecParser.kt | 20 ++++---- 3 files changed, 33 insertions(+), 38 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt index b043a14..88c7a15 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt @@ -37,7 +37,7 @@ object CodeGenerator { } fun generateSharedTypes(outputDir: File, specs: List): Int { - val securitySchemes = specs.flatMap { it.securitySchemes } + val securitySchemes = specs.flatMap { it.securitySchemes }.distinctBy { it.name } val files = ApiResponseGenerator.generate() + ApiClientBaseGenerator.generate(securitySchemes) files.forEach { it.writeTo(outputDir) } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt index c0ed802..83218f8 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt @@ -44,7 +44,6 @@ import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.PropertySpec import com.squareup.kotlinpoet.STRING import com.squareup.kotlinpoet.TypeSpec -import com.squareup.kotlinpoet.TypeSpec.Companion.classBuilder import com.squareup.kotlinpoet.TypeVariableName import com.squareup.kotlinpoet.UNIT @@ -149,7 +148,6 @@ internal object ApiClientBaseGenerator { .classBuilder(API_CLIENT_BASE) .addModifiers(KModifier.ABSTRACT) .addSuperinterface(CLOSEABLE) - .primaryConstructor(constructorBuilder.build()) val baseUrlProp = PropertySpec .builder(BASE_URL, STRING) @@ -182,6 +180,7 @@ internal object ApiClientBaseGenerator { .build() return classBuilder + .primaryConstructor(constructorBuilder.build()) .addProperty(clientProp) .addFunction(closeFun) .addFunction(buildApplyAuth(securitySchemes)) @@ -194,29 +193,26 @@ internal object ApiClientBaseGenerator { * Builds the list of auth-related constructor parameter names based on security schemes. * Returns pairs of (paramName, schemeType) for each scheme. */ - internal fun buildAuthConstructorParams(securitySchemes: List): List> = - securitySchemes.flatMap { scheme -> - when (scheme) { - is SecurityScheme.Bearer -> { - val isSingleBearer = - securitySchemes.size == 1 && securitySchemes.first() is SecurityScheme.Bearer + internal fun buildAuthConstructorParams(securitySchemes: List): List> { + val isSingleBearer = securitySchemes.size == 1 && securitySchemes.first() is SecurityScheme.Bearer - val paramName = if (isSingleBearer) TOKEN else "${scheme.name.toCamelCase()}Token" - listOf(paramName to scheme) - } + return securitySchemes.flatMap { scheme -> + when (scheme) { + is SecurityScheme.Bearer -> listOf( + (if (isSingleBearer) TOKEN else "${scheme.name.toCamelCase()}Token") to scheme, + ) - is SecurityScheme.ApiKey -> { - listOf("${scheme.name.toCamelCase()}Key" to scheme) - } + is SecurityScheme.ApiKey -> listOf( + "${scheme.name.toCamelCase()}Key" to scheme, + ) - is SecurityScheme.Basic -> { - listOf( - "${scheme.name.toCamelCase()}Username" to scheme, - "${scheme.name.toCamelCase()}Password" to scheme, - ) - } + is SecurityScheme.Basic -> listOf( + "${scheme.name.toCamelCase()}Username" to scheme, + "${scheme.name.toCamelCase()}Password" to scheme, + ) } } + } private fun buildApplyAuth(securitySchemes: List): FunSpec { val builder = FunSpec @@ -226,23 +222,22 @@ internal object ApiClientBaseGenerator { if (securitySchemes.isEmpty()) return builder.build() - val headerSchemes = securitySchemes.filter { - it is SecurityScheme.Bearer || - it is SecurityScheme.Basic || - (it is SecurityScheme.ApiKey && it.location == ApiKeyLocation.HEADER) + val headerSchemes = securitySchemes.filter { scheme -> + scheme is SecurityScheme.Bearer || + scheme is SecurityScheme.Basic || + (scheme is SecurityScheme.ApiKey && scheme.location == ApiKeyLocation.HEADER) } val querySchemes = securitySchemes .filterIsInstance() .filter { it.location == ApiKeyLocation.QUERY } if (headerSchemes.isNotEmpty()) { + val isSingleBearer = securitySchemes.size == 1 && securitySchemes.first() is SecurityScheme.Bearer + builder.beginControlFlow("%M", HEADERS_FUN) for (scheme in headerSchemes) { when (scheme) { is SecurityScheme.Bearer -> { - val isSingleBearer = - securitySchemes.size == 1 && securitySchemes.first() is SecurityScheme.Bearer - val paramName = if (isSingleBearer) TOKEN else "${scheme.name.toCamelCase()}Token" builder.addStatement( "append(%T.Authorization, %P)", diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt index 38a6bb7..efcfc4f 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -281,7 +281,7 @@ object SpecParser { } }.toList() - context (_: ComponentSchemaIdentity, _: ComponentSchemas) + context(_: ComponentSchemaIdentity, _: ComponentSchemas) private fun SwaggerParameter.toParameter(): Parameter = Parameter( name = name ?: "", location = `in`.toEnumOrNull() ?: ParameterLocation.QUERY, @@ -292,7 +292,7 @@ object SpecParser { // --- Schema extraction --- - context (_: Raise, _: ComponentSchemaIdentity, _: ComponentSchemas) + context(_: Raise, _: ComponentSchemaIdentity, _: ComponentSchemas) private fun extractSchemaModel(name: String, schema: Schema<*>): SchemaModel { val allOf = schema.allOf?.mapNotNull { it.resolveName() } @@ -353,7 +353,7 @@ object SpecParser { // --- allOf property merging --- - context (_: ComponentSchemaIdentity, _: ComponentSchemas) + context(_: ComponentSchemaIdentity, _: ComponentSchemas) private fun extractAllOfProperties(parentName: String, schema: Schema<*>): Pair, Set> { val topRequired = schema.required.orEmpty().toSet() val contextCreator: (String) -> String? = { propName -> "$parentName.${propName.toPascalCase()}" } @@ -373,7 +373,7 @@ object SpecParser { return finalProperties to required } - context (_: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) + context(_: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) private fun Schema<*>.resolveSubSchema(): Schema<*> = resolveName()?.let { componentSchemas[it] } ?: this /** @@ -389,7 +389,7 @@ object SpecParser { * * Returns: Pair of (unwrapped oneOf refs, synthetic discriminator) or null if pattern not matched. */ - context (componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) + context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) private fun detectAndUnwrapOneOfWrappers(schema: Schema<*>): Pair, Discriminator>? = nullable { ensure(!schema.oneOf.isNullOrEmpty() && schema.discriminator == null) @@ -422,14 +422,14 @@ object SpecParser { unwrapped.values.toList() to Discriminator(propertyName = "type", mapping = mapping) } - context (_: ComponentSchemaIdentity, _: ComponentSchemas) + context(_: ComponentSchemaIdentity, _: ComponentSchemas) private fun Schema<*>.toTypeRef(contextName: String? = null): TypeRef = contextName?.let { toInlineTypeRef(it) } ?: (resolveName() ?: allOf?.singleOrNull()?.resolveName())?.let(TypeRef::Reference) ?: TypeRef.Unknown.takeIf { (allOf?.size ?: 0) > 1 } ?: resolveByType(contextName) /** Resolves a [TypeRef] based on the schema's structural type/format, ignoring component identity. */ - context (_: ComponentSchemaIdentity, _: ComponentSchemas) + context(_: ComponentSchemaIdentity, _: ComponentSchemas) private fun Schema<*>.resolveByType(contextName: String? = null): TypeRef = when (type) { "string" -> STRING_FORMAT_MAP[format] ?: TypeRef.Primitive(PrimitiveType.STRING) @@ -450,7 +450,7 @@ object SpecParser { else -> TypeRef.Unknown } - context (_: ComponentSchemaIdentity, _: ComponentSchemas) + context(_: ComponentSchemaIdentity, _: ComponentSchemas) private fun Schema<*>.toInlineTypeRef(contextName: String): TypeRef? = takeIf { isInlineObject }?.let { val required = required.orEmpty().toSet() TypeRef.Inline( @@ -460,10 +460,10 @@ object SpecParser { ) } - context (componentSchemaIdentity: ComponentSchemaIdentity) + context(componentSchemaIdentity: ComponentSchemaIdentity) private fun Schema<*>.resolveName(): String? = `$ref`?.removePrefix(SCHEMA_PREFIX) ?: componentSchemaIdentity[this] - context (componentSchemaIdentity: ComponentSchemaIdentity) + context(componentSchemaIdentity: ComponentSchemaIdentity) private val Schema<*>.isInlineObject get(): Boolean = `$ref` == null && this !in componentSchemaIdentity && type == "object" && !properties.isNullOrEmpty() From b496517aace37d811ac3bcf9778b2ed9f7425471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 2 Apr 2026 14:49:19 +0200 Subject: [PATCH 04/23] refactor: remove redundant test case and add validation for conflicting security schemes - Delete unused `no securitySchemes` test in `ClientGeneratorTest`. - Add warning for conflicting security scheme types in `JustworksSharedTypesTask`. --- .../avsystem/justworks/core/parser/SpecParser.kt | 2 -- .../justworks/core/gen/ClientGeneratorTest.kt | 8 -------- .../justworks/gradle/JustworksSharedTypesTask.kt | 16 +++++++++++++++- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt index efcfc4f..3beb543 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -43,8 +43,6 @@ import io.swagger.v3.oas.models.security.SecurityRequirement import io.swagger.v3.parser.core.models.ParseOptions import java.io.File import java.util.IdentityHashMap -import kotlin.apply -import kotlin.collections.map import io.swagger.v3.oas.models.parameters.Parameter as SwaggerParameter import io.swagger.v3.oas.models.security.SecurityScheme as SwaggerSecurityScheme diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt index ba3fde8..5137f19 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt @@ -656,14 +656,6 @@ class ClientGeneratorTest { // -- SECU: Security-aware constructor generation -- - @Test - fun `no securitySchemes generates constructor with only baseUrl`() { - val cls = clientClass(listOf(endpoint())) - val constructor = assertNotNull(cls.primaryConstructor) - val paramNames = constructor.parameters.map { it.name } - assertEquals(listOf("baseUrl"), paramNames) - } - @Test fun `ApiKey HEADER scheme generates constructor with baseUrl and apiKey param`() { val cls = clientClass( diff --git a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt index 7ce74f2..a67442e 100644 --- a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt +++ b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt @@ -38,7 +38,10 @@ abstract class JustworksSharedTypesTask : DefaultTask() { val specs = specFiles.files.sortedBy { it.path }.mapNotNull { file -> when (val result = SpecParser.parse(file)) { - is ParseResult.Success -> result.apiSpec + is ParseResult.Success -> { + result.apiSpec + } + is ParseResult.Failure -> { logger.warn("Failed to parse spec '${file.name}': ${result.error}") null @@ -46,6 +49,17 @@ abstract class JustworksSharedTypesTask : DefaultTask() { } } + specs + .asSequence() + .flatMap { it.securitySchemes } + .groupingBy { it.name } + .eachCount() + .forEach { (name, schemes) -> + if (schemes > 1) { + logger.warn("Security scheme '$name' defined with conflicting types — using first occurrence") + } + } + val count = CodeGenerator.generateSharedTypes(outDir, specs) logger.lifecycle("Generated $count shared type files") } From 927346231b1145214bf17d36599a68fa667da239 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 2 Apr 2026 16:40:07 +0200 Subject: [PATCH 05/23] refactor(core, test): enhance security scheme handling and simplify key utilities --- .../avsystem/justworks/core/ArrowHelpers.kt | 4 +++- .../com/avsystem/justworks/core/Issue.kt | 4 +--- .../core/gen/client/ClientGenerator.kt | 3 +-- .../core/gen/shared/ApiClientBaseGenerator.kt | 24 ++++++++++++------- .../justworks/core/parser/SpecParser.kt | 9 +++---- .../core/parser/SpecParserSecurityTest.kt | 7 +++++- .../test/resources/security-schemes-spec.yaml | 5 ++++ .../gradle/JustworksSharedTypesTask.kt | 12 ++++++---- 8 files changed, 41 insertions(+), 27 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/ArrowHelpers.kt b/core/src/main/kotlin/com/avsystem/justworks/core/ArrowHelpers.kt index 42dd965..0ee39b1 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/ArrowHelpers.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/ArrowHelpers.kt @@ -18,13 +18,15 @@ inline fun ensureOrAccumulate(condition: Boolean, error: () -> Error) { @OptIn(ExperimentalContracts::class) context(iorRaise: IorRaise>) -inline fun ensureNotNullOrAccumulate(value: B?, error: () -> Error) { +inline fun ensureNotNullOrAccumulate(value: B?, error: () -> Error): B? { contract { callsInPlace(error, AT_MOST_ONCE) } if (value == null) { iorRaise.accumulate(nonEmptyListOf(error())) } + return value } +/** Accumulates a single error and returns `null`, for use in `when` branches that must yield a nullable result. */ context(iorRaise: IorRaise>) fun accumulate(error: Error): Nothing? { iorRaise.accumulate(nonEmptyListOf(error)) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt b/core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt index 549de2c..88d63d3 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt @@ -1,12 +1,10 @@ -@file:OptIn(ExperimentalContracts::class, ExperimentalRaiseAccumulateApi::class) +@file:OptIn(ExperimentalRaiseAccumulateApi::class) package com.avsystem.justworks.core import arrow.core.Nel -import arrow.core.nonEmptyListOf import arrow.core.raise.ExperimentalRaiseAccumulateApi import arrow.core.raise.IorRaise -import kotlin.contracts.ExperimentalContracts object Issue { data class Error(val message: String) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt index c6d994e..82a2da8 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt @@ -12,7 +12,6 @@ import com.avsystem.justworks.core.gen.HTTP_SUCCESS import com.avsystem.justworks.core.gen.ModelPackage import com.avsystem.justworks.core.gen.NameRegistry import com.avsystem.justworks.core.gen.RAISE -import com.avsystem.justworks.core.gen.TOKEN import com.avsystem.justworks.core.gen.client.BodyGenerator.buildFunctionBody import com.avsystem.justworks.core.gen.client.ParametersGenerator.buildBodyParams import com.avsystem.justworks.core.gen.client.ParametersGenerator.buildNullableParameter @@ -93,7 +92,7 @@ internal object ClientGenerator { .superclass(API_CLIENT_BASE) .addSuperclassConstructorParameter(BASE_URL) - for ((paramName, _) in authParams) { + for (paramName in authParams) { constructorBuilder.addParameter(paramName, tokenType) classBuilder.addSuperclassConstructorParameter(paramName) } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt index 83218f8..7a01d39 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt @@ -157,7 +157,7 @@ internal object ApiClientBaseGenerator { classBuilder.addProperty(baseUrlProp) - for ((paramName, _) in authParams) { + for (paramName in authParams) { constructorBuilder.addParameter(paramName, tokenType) classBuilder.addProperty( PropertySpec @@ -191,29 +191,35 @@ internal object ApiClientBaseGenerator { /** * Builds the list of auth-related constructor parameter names based on security schemes. - * Returns pairs of (paramName, schemeType) for each scheme. */ - internal fun buildAuthConstructorParams(securitySchemes: List): List> { - val isSingleBearer = securitySchemes.size == 1 && securitySchemes.first() is SecurityScheme.Bearer + internal fun buildAuthConstructorParams(securitySchemes: List): List { + val isSingleBearer = isSingleBearer(securitySchemes) return securitySchemes.flatMap { scheme -> when (scheme) { + is SecurityScheme.Bearer if isSingleBearer -> listOf( + TOKEN, + ) + is SecurityScheme.Bearer -> listOf( - (if (isSingleBearer) TOKEN else "${scheme.name.toCamelCase()}Token") to scheme, + "${scheme.name.toCamelCase()}Token", ) is SecurityScheme.ApiKey -> listOf( - "${scheme.name.toCamelCase()}Key" to scheme, + "${scheme.name.toCamelCase()}Key", ) is SecurityScheme.Basic -> listOf( - "${scheme.name.toCamelCase()}Username" to scheme, - "${scheme.name.toCamelCase()}Password" to scheme, + "${scheme.name.toCamelCase()}Username", + "${scheme.name.toCamelCase()}Password", ) } } } + private fun isSingleBearer(securitySchemes: List): Boolean = + securitySchemes.size == 1 && securitySchemes.first() is SecurityScheme.Bearer + private fun buildApplyAuth(securitySchemes: List): FunSpec { val builder = FunSpec .builder(APPLY_AUTH) @@ -232,7 +238,7 @@ internal object ApiClientBaseGenerator { .filter { it.location == ApiKeyLocation.QUERY } if (headerSchemes.isNotEmpty()) { - val isSingleBearer = securitySchemes.size == 1 && securitySchemes.first() is SecurityScheme.Bearer + val isSingleBearer = isSingleBearer(securitySchemes) builder.beginControlFlow("%M", HEADERS_FUN) for (scheme in headerSchemes) { diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt index 3beb543..d13f167 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -5,7 +5,6 @@ import arrow.core.getOrElse import arrow.core.left import arrow.core.raise.ExperimentalRaiseAccumulateApi import arrow.core.raise.Raise -import arrow.core.raise.context.accumulate import arrow.core.raise.context.either import arrow.core.raise.context.ensure import arrow.core.raise.context.ensureNotNull @@ -188,11 +187,9 @@ object SpecParser { ): List { val referencedNames = requirements.flatMap { it.keys }.toSet() return referencedNames.mapNotNull { name -> - definitions[name]?.toSecurityScheme(name).also { - ensureNotNullOrAccumulate(it) { - Issue.Warning("Security requirement references undefined scheme '$name'") - } - } + ensureNotNullOrAccumulate(definitions[name]) { + Issue.Warning("Security requirement references undefined scheme '$name'") + }?.toSecurityScheme(name) } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserSecurityTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserSecurityTest.kt index 92d1c3a..80a47bb 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserSecurityTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserSecurityTest.kt @@ -7,7 +7,6 @@ import org.junit.jupiter.api.TestInstance import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertIs import kotlin.test.assertTrue @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -62,6 +61,12 @@ class SpecParserSecurityTest : SpecParserTestBase() { assertTrue("UnusedOAuth" !in names, "UnusedOAuth should not be in parsed schemes") } + @Test + fun `excludes unsupported cookie API key scheme`() { + val names = apiSpec.securitySchemes.map { it.name } + assertTrue("ApiKeyCookie" !in names, "ApiKeyCookie should not be in parsed schemes") + } + @Test fun `spec without security field produces empty securitySchemes`() { val petstore = parseSpec(loadResource("petstore.yaml")) diff --git a/core/src/test/resources/security-schemes-spec.yaml b/core/src/test/resources/security-schemes-spec.yaml index 029ee25..6185e45 100644 --- a/core/src/test/resources/security-schemes-spec.yaml +++ b/core/src/test/resources/security-schemes-spec.yaml @@ -19,6 +19,10 @@ components: BasicAuth: type: http scheme: basic + ApiKeyCookie: + type: apiKey + in: cookie + name: session_id UnusedOAuth: type: oauth2 flows: @@ -32,6 +36,7 @@ security: - ApiKeyHeader: [] - ApiKeyQuery: [] - BasicAuth: [] + - ApiKeyCookie: [] paths: /health: diff --git a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt index a67442e..580ce2d 100644 --- a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt +++ b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt @@ -50,13 +50,15 @@ abstract class JustworksSharedTypesTask : DefaultTask() { } specs - .asSequence() .flatMap { it.securitySchemes } - .groupingBy { it.name } - .eachCount() + .groupBy { it.name } .forEach { (name, schemes) -> - if (schemes > 1) { - logger.warn("Security scheme '$name' defined with conflicting types — using first occurrence") + if (schemes.size > 1) { + val types = schemes.map { it::class.simpleName } + logger.warn( + "Security scheme '$name' defined ${schemes.size} times with types $types — " + + "using first occurrence (${types.first()})", + ) } } From 7170e61e42a000693de7a23b1f61c9c184a2231d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 2 Apr 2026 17:10:56 +0200 Subject: [PATCH 06/23] refactor: extract shared parsing logic and add lightweight security scheme extraction Deduplicate SpecParser by extracting loadOpenApi() and parseSpec() helpers. Add parseSecuritySchemes() for lightweight extraction without full schema resolution. Make ParseResult generic to support both ApiSpec and List results. Update JustworksSharedTypesTask to use the lightweight method, avoiding double full-parse of spec files. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../justworks/core/gen/CodeGenerator.kt | 5 +- .../justworks/core/parser/SpecParser.kt | 78 +++++++++++++------ .../justworks/core/gen/CodeGeneratorTest.kt | 2 +- .../justworks/core/gen/IntegrationTest.kt | 12 +-- .../justworks/core/parser/SpecParserTest.kt | 6 +- .../core/parser/SpecParserTestBase.kt | 2 +- .../justworks/gradle/JustworksGenerateTask.kt | 2 +- .../gradle/JustworksSharedTypesTask.kt | 39 +++++----- 8 files changed, 86 insertions(+), 60 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt index 88c7a15..d9fdb59 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt @@ -5,6 +5,7 @@ import com.avsystem.justworks.core.gen.model.ModelGenerator import com.avsystem.justworks.core.gen.shared.ApiClientBaseGenerator import com.avsystem.justworks.core.gen.shared.ApiResponseGenerator import com.avsystem.justworks.core.model.ApiSpec +import com.avsystem.justworks.core.model.SecurityScheme import java.io.File /** @@ -36,9 +37,7 @@ object CodeGenerator { return Result(modelFiles.size, clientFiles.size) } - fun generateSharedTypes(outputDir: File, specs: List): Int { - val securitySchemes = specs.flatMap { it.securitySchemes }.distinctBy { it.name } - + fun generateSharedTypes(outputDir: File, securitySchemes: List): Int { val files = ApiResponseGenerator.generate() + ApiClientBaseGenerator.generate(securitySchemes) files.forEach { it.writeTo(outputDir) } return files.size diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt index d13f167..dfb2e6b 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -10,7 +10,6 @@ import arrow.core.raise.context.ensure import arrow.core.raise.context.ensureNotNull import arrow.core.raise.iorNel import arrow.core.raise.nullable -import arrow.core.toNonEmptyListOrNull import com.avsystem.justworks.core.Issue import com.avsystem.justworks.core.Warnings import com.avsystem.justworks.core.accumulate @@ -32,6 +31,7 @@ import com.avsystem.justworks.core.model.Response import com.avsystem.justworks.core.model.SchemaModel import com.avsystem.justworks.core.model.SecurityScheme import com.avsystem.justworks.core.model.TypeRef +import com.avsystem.justworks.core.parser.SpecParser.parse import com.avsystem.justworks.core.toEnumOrNull import io.swagger.parser.OpenAPIParser import io.swagger.v3.oas.models.OpenAPI @@ -51,8 +51,8 @@ import io.swagger.v3.oas.models.security.SecurityScheme as SwaggerSecurityScheme * Use pattern matching to handle both outcomes: * ```kotlin * when (val result = SpecParser.parse(file)) { - * is ParseResult.Success -> result.apiSpec - * is ParseResult.Failure -> handleErrors(result.errors) + * is ParseResult.Success -> result.value + * is ParseResult.Failure -> handleErrors(result.error) * } * ``` * @@ -60,12 +60,12 @@ import io.swagger.v3.oas.models.security.SecurityScheme as SwaggerSecurityScheme * encountered during parsing or validation. */ -sealed interface ParseResult { +sealed interface ParseResult { val warnings: List - data class Success(val apiSpec: ApiSpec, override val warnings: List) : ParseResult + data class Success(val value: T, override val warnings: List) : ParseResult - data class Failure(val error: Issue.Error, override val warnings: List) : ParseResult + data class Failure(val error: Issue.Error, override val warnings: List) : ParseResult } object SpecParser { @@ -83,43 +83,71 @@ object SpecParser { * @return [ParseResult.Success] with the parsed model and any warnings, or * [ParseResult.Failure] with a non-empty list of error messages */ - @OptIn(ExperimentalRaiseAccumulateApi::class) - fun parse(specFile: File): ParseResult { - val parseOptions = ParseOptions().apply { - isResolve = true - isResolveFully = true - isResolveCombinators = false + fun parse(specFile: File): ParseResult = parseSpec(specFile, resolveFully = true) { openApi -> + SpecValidator.validate(openApi) + openApi.toApiSpec() + } + + /** + * Lightweight extraction of security schemes from an OpenAPI spec file. + * + * Parses only the `components/securitySchemes` and `security` sections, + * skipping the expensive endpoint and schema extraction performed by [parse]. + */ + fun parseSecuritySchemes(specFile: File): ParseResult> = + parseSpec(specFile, resolveFully = false) { openApi -> + extractSecuritySchemes( + openApi.components?.securitySchemes.orEmpty(), + openApi.security.orEmpty(), + ) } + @OptIn(ExperimentalRaiseAccumulateApi::class) + private inline fun parseSpec( + specFile: File, + resolveFully: Boolean, + extract: context(Raise, Warnings) (OpenAPI) -> T, + ): ParseResult { val result = iorNel { either { - val swaggerResult = OpenAPIParser().readLocation(specFile.absolutePath, null, parseOptions) - - swaggerResult - ?.messages - ?.map(Issue::Warning) - ?.toNonEmptyListOrNull() - ?.let(::accumulate) - - val openApi = swaggerResult?.openAPI + val openApi = loadOpenApi(specFile, resolveFully) ensureNotNull(openApi) { Issue.Error("Failed to parse spec: ${specFile.name}") } - SpecValidator.validate(openApi) - openApi.toApiSpec() + extract(openApi) } } val warnings = result.leftOrNull().orEmpty() val either = result.getOrElse { Issue.Error("Failed to parse spec: ${specFile.name}").left() } return either.fold( - ifLeft = { ParseResult.Failure(it, warnings) }, - ifRight = { ParseResult.Success(it, warnings) }, + { ParseResult.Failure(it, warnings) }, + { ParseResult.Success(it, warnings) }, ) } + /** + * Loads and parses an OpenAPI spec file into a Swagger [OpenAPI] model. + * Accumulates parser messages as warnings. + */ + @OptIn(ExperimentalRaiseAccumulateApi::class) + context(_: Warnings) + private fun loadOpenApi(specFile: File, resolveFully: Boolean): OpenAPI? { + val parseOptions = ParseOptions().apply { + isResolve = true + isResolveFully = resolveFully + isResolveCombinators = false + } + + val swaggerResult = OpenAPIParser().readLocation(specFile.absolutePath, null, parseOptions) + + swaggerResult?.messages?.forEach { accumulate(Issue.Warning(it)) } + + return swaggerResult?.openAPI + } + private typealias ComponentSchemaIdentity = IdentityHashMap, String> private typealias ComponentSchemas = MutableMap> diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/CodeGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/CodeGeneratorTest.kt index ab98455..abd834a 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/CodeGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/CodeGeneratorTest.kt @@ -23,7 +23,7 @@ class CodeGeneratorTest { ?: fail("Spec fixture not found: $fixture") val specFile = File(specUrl.toURI()) val spec = when (val result = SpecParser.parse(specFile)) { - is ParseResult.Success -> result.apiSpec + is ParseResult.Success -> result.value is ParseResult.Failure -> fail("Failed to parse $fixture: ${result.error}") } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt index e442575..3ed5683 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt @@ -30,7 +30,7 @@ class IntegrationTest { ) } - private fun parseSpec(resourcePath: String): ParseResult.Success { + private fun parseSpec(resourcePath: String): ParseResult.Success { val specUrl = javaClass.getResource(resourcePath) ?: fail("Spec fixture not found: $resourcePath") val specFile = File(specUrl.toURI()) @@ -54,7 +54,7 @@ class IntegrationTest { @Test fun `real-world specs generate compilable enum code without class body conflicts`() { for (fixture in SPEC_FIXTURES) { - val spec = parseSpec(fixture).apiSpec + val spec = parseSpec(fixture).value if (spec.enums.isEmpty()) continue val files = generateModel(spec) @@ -90,7 +90,7 @@ class IntegrationTest { @Test fun `real-world specs generate ApiClientBase when endpoints exist`() { for (fixture in SPEC_FIXTURES) { - val spec = parseSpec(fixture).apiSpec + val spec = parseSpec(fixture).value if (spec.endpoints.isEmpty()) continue val apiClientBaseFile = ApiClientBaseGenerator.generate(spec.securitySchemes) @@ -111,7 +111,7 @@ class IntegrationTest { @Test fun `real-world specs full pipeline generates client code without exceptions`() { for (fixture in SPEC_FIXTURES) { - val spec = parseSpec(fixture).apiSpec + val spec = parseSpec(fixture).value val (modelFiles, resolvedSpec) = generateModelWithResolvedSpec(spec) assertTrue(modelFiles.isNotEmpty(), "$fixture: ModelGenerator should produce files") @@ -134,7 +134,7 @@ class IntegrationTest { @Test fun `format mappings produce correct types in generated output`() { for (fixture in SPEC_FIXTURES) { - val spec = parseSpec(fixture).apiSpec + val spec = parseSpec(fixture).value val files = generateModel(spec) assertTrue(files.isNotEmpty(), "$fixture: ModelGenerator should produce output files") @@ -164,7 +164,7 @@ class IntegrationTest { @Test fun `all generated files are syntactically valid Kotlin`() { for (fixture in SPEC_FIXTURES) { - val spec = parseSpec(fixture).apiSpec + val spec = parseSpec(fixture).value val files = generateModel(spec) assertTrue(files.isNotEmpty(), "$fixture: ModelGenerator should produce output files") diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt index cb7eaad..b05f97c 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt @@ -226,14 +226,14 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `parse spec with missing info produces warnings`() { val result = SpecParser.parse(loadResource("invalid-spec.yaml")) - assertIs(result) + assertIs>(result) assertTrue(result.warnings.isNotEmpty(), "Spec with missing info should produce warnings") } @Test fun `parse spec with missing info has descriptive warning messages`() { val result = SpecParser.parse(loadResource("invalid-spec.yaml")) - assertIs(result) + assertIs>(result) assertTrue(result.warnings.isNotEmpty(), "Should have warning messages") result.warnings.forEach { warning -> assertTrue(warning.message.length > 5, "Warning message too short to be useful: '$warning'") @@ -245,7 +245,7 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `parse swagger 2 json returns Success`() { val result = SpecParser.parse(loadResource("petstore-v2.json")) - assertIs(result) + assertIs>(result) } @Test diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTestBase.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTestBase.kt index 0ea1c85..9db5186 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTestBase.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTestBase.kt @@ -13,7 +13,7 @@ abstract class SpecParserTestBase { } protected fun parseSpec(file: File): ApiSpec = when (val result = SpecParser.parse(file)) { - is ParseResult.Success -> result.apiSpec + is ParseResult.Success -> result.value is ParseResult.Failure -> fail("Expected success but got error: ${result.error}") } } diff --git a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksGenerateTask.kt b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksGenerateTask.kt index 434684b..c584c58 100644 --- a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksGenerateTask.kt +++ b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksGenerateTask.kt @@ -57,7 +57,7 @@ abstract class JustworksGenerateTask : DefaultTask() { is ParseResult.Success -> { val (modelCount, clientCount) = CodeGenerator.generate( - spec = result.apiSpec, + spec = result.value, modelPackage = modelPackage.get(), apiPackage = apiPackage.get(), outputDir = outDir, diff --git a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt index 580ce2d..f54fe6d 100644 --- a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt +++ b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt @@ -17,9 +17,10 @@ import org.gradle.api.tasks.TaskAction * Gradle task that generates shared types (HttpError, Success, ApiClientBase) once * to a fixed output directory shared across all spec configurations. * - * When [specFiles] are configured, the task parses them to extract security schemes - * and passes them to ApiClientBase generation so the generated auth code reflects - * the spec's security configuration. + * When [specFiles] are configured, the task performs a lightweight parse to extract + * only security schemes (skipping full endpoint/schema extraction) and passes them + * to ApiClientBase generation so the generated auth code reflects the spec's + * security configuration. */ @CacheableTask abstract class JustworksSharedTypesTask : DefaultTask() { @@ -36,33 +37,31 @@ abstract class JustworksSharedTypesTask : DefaultTask() { fun generate() { val outDir = outputDir.get().asFile.recreateDirectory() - val specs = specFiles.files.sortedBy { it.path }.mapNotNull { file -> - when (val result = SpecParser.parse(file)) { + val allSchemes = specFiles.files.sortedBy { it.path }.flatMap { file -> + when (val result = SpecParser.parseSecuritySchemes(file)) { is ParseResult.Success -> { - result.apiSpec + result.warnings.forEach { logger.warn(it.message) } + result.value } is ParseResult.Failure -> { - logger.warn("Failed to parse spec '${file.name}': ${result.error}") - null + logger.warn("Failed to parse security schemes from '${file.name}': ${result.error}") + emptyList() } } } - specs - .flatMap { it.securitySchemes } - .groupBy { it.name } - .forEach { (name, schemes) -> - if (schemes.size > 1) { - val types = schemes.map { it::class.simpleName } - logger.warn( - "Security scheme '$name' defined ${schemes.size} times with types $types — " + - "using first occurrence (${types.first()})", - ) - } + for ((name, schemes) in allSchemes.groupBy { it.name }) { + if (schemes.size > 1) { + val types = schemes.map { it::class.simpleName } + logger.warn( + "Security scheme '$name' defined ${schemes.size} times with types $types — " + + "using first occurrence (${types.first()})", + ) } + } - val count = CodeGenerator.generateSharedTypes(outDir, specs) + val count = CodeGenerator.generateSharedTypes(outDir, allSchemes.distinctBy { it.name }) logger.lifecycle("Generated $count shared type files") } } From d3858098e48515b291c4599053ad5929e48d0c4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 8 Apr 2026 11:53:04 +0200 Subject: [PATCH 07/23] fmt --- .../avsystem/justworks/core/gen/ApiResponseGeneratorTest.kt | 5 ++++- .../com/avsystem/justworks/core/gen/ClientGeneratorTest.kt | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiResponseGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiResponseGeneratorTest.kt index 5731576..651d779 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiResponseGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiResponseGeneratorTest.kt @@ -39,7 +39,10 @@ class ApiResponseGeneratorTest { "Expected superclass constructor parameter for message", ) assertTrue( - typeSpec.superclassConstructorParameters.first().toString().contains("message"), + typeSpec.superclassConstructorParameters + .first() + .toString() + .contains("message"), "Expected message passed to RuntimeException constructor", ) } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt index 233a88c..7196d9b 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt @@ -241,6 +241,7 @@ class ClientGeneratorTest { fun `endpoint functions do not have context parameters`() { val cls = clientClass(endpoint()) val funSpec = cls.funSpecs.first { it.name == "listPets" } + @OptIn(ExperimentalKotlinPoetApi::class) val contextParameters = funSpec.contextParameters assertTrue(contextParameters.isEmpty(), "Expected no context parameters (Arrow removed)") From d19d1ceb8e36a806f75f70f47ca4fa1a30308096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 8 Apr 2026 12:17:05 +0200 Subject: [PATCH 08/23] feat(core): add support for OpenAPI security schemes in API client generation - Generate authentication handling for security schemes (Bearer, Basic, API Key). - Document security scheme support and configuration in README. - Refactor `ApiResponseGenerator` to include security scheme logic. - Update tests to validate security scheme handling. --- README.md | 105 ++++++++++++------ .../core/gen/shared/ApiResponseGenerator.kt | 1 + .../justworks/core/gen/IntegrationTest.kt | 4 +- 3 files changed, 71 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 4dad22e..d4cc7cc 100644 --- a/README.md +++ b/README.md @@ -111,10 +111,32 @@ 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. + +| Scheme type | Location | Generated constructor parameter(s) | +|-------------|----------|----------------------------------------------------------------| +| HTTP Bearer | Header | `token: () -> String` (or `{name}Token` if multiple) | +| HTTP Basic | Header | `{name}Username: () -> String`, `{name}Password: () -> String` | +| API Key | Header | `{name}Key: () -> String` | +| API Key | Query | `{name}Key: () -> String` | + +All auth parameters are `() -> String` lambdas, called on every request. This lets you supply providers that refresh +credentials automatically. + +The generated `ApiClientBase` contains 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 @@ -127,7 +149,7 @@ registered spec). build/generated/justworks/ ├── shared/kotlin/ │ └── com/avsystem/justworks/ -│ ├── ApiClientBase.kt # Abstract base class + helper extensions +│ ├── ApiClientBase.kt # Abstract base class + auth handling + helper extensions │ ├── HttpError.kt # HttpErrorType enum + HttpError data class │ └── HttpSuccess.kt # HttpSuccess data class │ @@ -221,33 +243,28 @@ 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 `Api` (e.g., a `pets` tag produces `PetsApi`). Untagged endpoints go to `DefaultApi`. +**Single Bearer token** (most common case): + ```kotlin val client = PetsApi( baseUrl = "https://api.example.com", @@ -255,8 +272,8 @@ val client = PetsApi( ) ``` -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: +The `token` parameter is a `() -> String` lambda called on every request. This lets you supply a provider that refreshes +automatically: ```kotlin val client = PetsApi( @@ -265,16 +282,36 @@ val client = PetsApi( ) ``` +**Multiple security schemes** -- constructor parameters are derived from the scheme names defined in the spec: + +```kotlin +val client = PetsApi( + baseUrl = "https://api.example.com", + bearerToken = { tokenStore.getAccessToken() }, + internalApiKey = { secrets.getApiKey() }, +) +``` + +**Basic auth**: + +```kotlin +val client = PetsApi( + baseUrl = "https://api.example.com", + basicUsername = { "user" }, + basicPassword = { "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)` and return `HttpSuccess` on success: +Every endpoint becomes a `suspend` function on the client that returns `HttpSuccess` on success and throws +`HttpError` on failure: ```kotlin -// Inside a Raise context (e.g., within either { ... }) val result: HttpSuccess> = client.listPets(limit = 10) println(result.body) // the deserialized response body println(result.code) // the HTTP status code @@ -288,27 +325,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>`: +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> = 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: @@ -329,7 +360,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 diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiResponseGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiResponseGenerator.kt index 202ea8c..02e5543 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiResponseGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiResponseGenerator.kt @@ -57,6 +57,7 @@ internal object ApiResponseGenerator { ).addProperty( PropertySpec .builder(MESSAGE, STRING) + .addModifiers(KModifier.OVERRIDE) .initializer(MESSAGE) .build(), ).addProperty( diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt index 37cf185..b26c666 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt @@ -173,12 +173,12 @@ class IntegrationTest { @Test fun `generated client code does not reference Arrow`() { for (fixture in SPEC_FIXTURES) { - val spec = parseSpec(fixture).apiSpec + val spec = parseSpec(fixture).value if (spec.endpoints.isEmpty()) continue val (_, resolvedSpec) = generateModelWithResolvedSpec(spec) val clientFiles = generateClient(resolvedSpec) - val apiClientBaseFile = ApiClientBaseGenerator.generate() + val apiClientBaseFile = ApiClientBaseGenerator.generate(spec.securitySchemes) val allSources = (clientFiles + apiClientBaseFile).map { it.toString() } for (source in allSources) { From 6aa147f82ceebc9f0cd76d4cf327c14fc14202ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 9 Apr 2026 12:40:32 +0200 Subject: [PATCH 09/23] refactor(core): rename `accumulate` to `accumulateAndReturnNull` in `ArrowHelpers` and update `SpecParser` usage --- .../com/avsystem/justworks/core/ArrowHelpers.kt | 2 +- .../com/avsystem/justworks/core/parser/SpecParser.kt | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/ArrowHelpers.kt b/core/src/main/kotlin/com/avsystem/justworks/core/ArrowHelpers.kt index 0ee39b1..f238bc1 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/ArrowHelpers.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/ArrowHelpers.kt @@ -28,7 +28,7 @@ inline fun ensureNotNullOrAccumulate(value: B?, error: () -> Er /** Accumulates a single error and returns `null`, for use in `when` branches that must yield a nullable result. */ context(iorRaise: IorRaise>) -fun accumulate(error: Error): Nothing? { +fun accumulateAndReturnNull(error: Error): Nothing? { iorRaise.accumulate(nonEmptyListOf(error)) return null } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt index 2726177..04c3737 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -13,7 +13,7 @@ import arrow.core.raise.nullable import com.avsystem.justworks.core.Issue import com.avsystem.justworks.core.SCHEMA_PREFIX import com.avsystem.justworks.core.Warnings -import com.avsystem.justworks.core.accumulate +import com.avsystem.justworks.core.accumulateAndReturnNull import com.avsystem.justworks.core.ensureNotNullOrAccumulate import com.avsystem.justworks.core.model.ApiKeyLocation import com.avsystem.justworks.core.model.ApiSpec @@ -32,7 +32,6 @@ import com.avsystem.justworks.core.model.Response import com.avsystem.justworks.core.model.SchemaModel import com.avsystem.justworks.core.model.SecurityScheme import com.avsystem.justworks.core.model.TypeRef -import com.avsystem.justworks.core.parser.SpecParser.parse import com.avsystem.justworks.core.toEnumOrNull import io.swagger.parser.OpenAPIParser import io.swagger.v3.oas.models.OpenAPI @@ -145,7 +144,7 @@ object SpecParser { val swaggerResult = OpenAPIParser().readLocation(specFile.absolutePath, null, parseOptions) - swaggerResult?.messages?.forEach { accumulate(Issue.Warning(it)) } + swaggerResult?.messages?.forEach { accumulateAndReturnNull(Issue.Warning(it)) } return swaggerResult?.openAPI } @@ -229,7 +228,7 @@ object SpecParser { when (scheme?.lowercase()) { "bearer" -> SecurityScheme.Bearer(name) "basic" -> SecurityScheme.Basic(name) - else -> accumulate(Issue.Warning("Unsupported HTTP auth scheme '$scheme' for '$name'")) + else -> accumulateAndReturnNull(Issue.Warning("Unsupported HTTP auth scheme '$scheme' for '$name'")) } } @@ -237,12 +236,12 @@ object SpecParser { when (`in`) { SwaggerSecurityScheme.In.HEADER -> SecurityScheme.ApiKey(name, this.name, ApiKeyLocation.HEADER) SwaggerSecurityScheme.In.QUERY -> SecurityScheme.ApiKey(name, this.name, ApiKeyLocation.QUERY) - else -> accumulate(Issue.Warning("Unsupported API key location '${`in`}' for '$name'")) + else -> accumulateAndReturnNull(Issue.Warning("Unsupported API key location '${`in`}' for '$name'")) } } else -> { - accumulate(Issue.Warning("Unsupported security scheme type '$type' for '$name'")) + accumulateAndReturnNull(Issue.Warning("Unsupported security scheme type '$type' for '$name'")) } } From 22e42233c2b6d407f2aa724bf746243b5d8e49c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 9 Apr 2026 13:22:09 +0200 Subject: [PATCH 10/23] refactor(core): extract `AuthParam` sealed interface and refactor auth parameter generation - Move `AuthParam` logic to a dedicated file. - Simplify `buildAuthConstructorParams` and authentication handling in `ApiClientBaseGenerator`. --- .../core/gen/shared/ApiClientBaseGenerator.kt | 54 +++++------------- .../justworks/core/gen/shared/AuthParam.kt | 55 +++++++++++++++++++ 2 files changed, 68 insertions(+), 41 deletions(-) create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/AuthParam.kt diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt index 256a348..c01cf97 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt @@ -26,7 +26,6 @@ import com.avsystem.justworks.core.gen.JSON_CLASS import com.avsystem.justworks.core.gen.JSON_FUN import com.avsystem.justworks.core.gen.SAFE_CALL import com.avsystem.justworks.core.gen.SERIALIZERS_MODULE -import com.avsystem.justworks.core.gen.TOKEN import com.avsystem.justworks.core.gen.toCamelCase import com.avsystem.justworks.core.model.ApiKeyLocation import com.avsystem.justworks.core.model.SecurityScheme @@ -178,37 +177,6 @@ internal object ApiClientBaseGenerator { .build() } - /** - * Builds the list of auth-related constructor parameter names based on security schemes. - */ - internal fun buildAuthConstructorParams(securitySchemes: List): List { - val isSingleBearer = isSingleBearer(securitySchemes) - - return securitySchemes.flatMap { scheme -> - when (scheme) { - is SecurityScheme.Bearer if isSingleBearer -> listOf( - TOKEN, - ) - - is SecurityScheme.Bearer -> listOf( - "${scheme.name.toCamelCase()}Token", - ) - - is SecurityScheme.ApiKey -> listOf( - "${scheme.name.toCamelCase()}Key", - ) - - is SecurityScheme.Basic -> listOf( - "${scheme.name.toCamelCase()}Username", - "${scheme.name.toCamelCase()}Password", - ) - } - } - } - - private fun isSingleBearer(securitySchemes: List): Boolean = - securitySchemes.size == 1 && securitySchemes.first() is SecurityScheme.Bearer - private fun buildApplyAuth(securitySchemes: List): FunSpec { val builder = FunSpec .builder(APPLY_AUTH) @@ -232,32 +200,37 @@ internal object ApiClientBaseGenerator { builder.beginControlFlow("%M", HEADERS_FUN) for (scheme in headerSchemes) { when (scheme) { + is SecurityScheme.Bearer if isSingleBearer -> { + builder.addStatement( + "append(%T.Authorization, %P)", + HTTP_HEADERS, + CodeBlock.of($$"Bearer ${$${scheme.toAuthParam().name}()}"), + ) + } + is SecurityScheme.Bearer -> { - val paramName = if (isSingleBearer) TOKEN else "${scheme.name.toCamelCase()}Token" builder.addStatement( "append(%T.Authorization, %P)", HTTP_HEADERS, - CodeBlock.of($$"Bearer ${$$paramName()}"), + CodeBlock.of($$"Bearer ${$${scheme.toAuthParam().name}()}"), ) } is SecurityScheme.Basic -> { - val usernameParam = "${scheme.name.toCamelCase()}Username" - val passwordParam = "${scheme.name.toCamelCase()}Password" + val authParam = scheme.toAuthParam() builder.addStatement( "append(%T.Authorization, %P)", HTTP_HEADERS, CodeBlock.of( - $$"Basic ${%T.getEncoder().encodeToString(\"${$$usernameParam()}:${$$passwordParam()}\".toByteArray())}", + $$"Basic ${%T.getEncoder().encodeToString(\"${$${authParam.username}()}:${$${authParam.password}()}\".toByteArray())}", BASE64_CLASS, ), ) } is SecurityScheme.ApiKey -> { - val paramName = "${scheme.name.toCamelCase()}Key" builder.addStatement( - "append(%S, $paramName())", + "append(%S, ${scheme.toAuthParam().name}())", scheme.parameterName, ) } @@ -269,9 +242,8 @@ internal object ApiClientBaseGenerator { if (querySchemes.isNotEmpty()) { builder.beginControlFlow("url") for (scheme in querySchemes) { - val paramName = "${scheme.name.toCamelCase()}Key" builder.addStatement( - "parameters.append(%S, $paramName())", + "parameters.append(%S, ${scheme.name.toCamelCase()}Key())", scheme.parameterName, ) } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/AuthParam.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/AuthParam.kt new file mode 100644 index 0000000..4fc8d35 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/AuthParam.kt @@ -0,0 +1,55 @@ +package com.avsystem.justworks.core.gen.shared + +import com.avsystem.justworks.core.gen.TOKEN +import com.avsystem.justworks.core.gen.toCamelCase +import com.avsystem.justworks.core.model.SecurityScheme + +/** + * Builds the list of auth-related constructor parameter names based on security schemes. + */ +internal fun buildAuthConstructorParams(securitySchemes: List): List = + if (isSingleBearer(securitySchemes)) { + listOf(TOKEN) + } else { + securitySchemes.flatMap { + when (it) { + is SecurityScheme.Bearer -> { + listOf(it.toAuthParam().name) + } + + is SecurityScheme.ApiKey -> { + listOf(it.toAuthParam().name) + } + + is SecurityScheme.Basic -> { + val authParam = it.toAuthParam() + listOf(authParam.username, it.toAuthParam().password) + } + } + } + } + +sealed interface AuthParam { + data class Bearer(private val _name: String) : AuthParam { + val name = "${_name.toCamelCase()}Token" + } + + data class Basic(private val _name: String) : AuthParam { + val name = _name.toCamelCase() + val username = "${name}Username" + val password = "${name}Password" + } + + data class ApiKey(private val _name: String) : AuthParam { + val name = _name.toCamelCase() + } +} + +internal fun SecurityScheme.Basic.toAuthParam(): AuthParam.Basic = AuthParam.Basic(name) + +internal fun SecurityScheme.ApiKey.toAuthParam(): AuthParam.ApiKey = AuthParam.ApiKey(name) + +internal fun SecurityScheme.Bearer.toAuthParam(): AuthParam.Bearer = AuthParam.Bearer(name) + +internal fun isSingleBearer(securitySchemes: List): Boolean = + securitySchemes.size == 1 && securitySchemes.first() is SecurityScheme.Bearer From 4b9c7ab034135122e4991557b35d5237cecb93d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 9 Apr 2026 15:23:13 +0200 Subject: [PATCH 11/23] refactor(core): remove security scheme handling in shared types generation and client base generator - Update `ApiClientBaseGenerator` to remove explicit dependencies on `SecurityScheme`. - Simplify `JustworksSharedTypesTask` by removing security schemes extraction. - Streamline `CodeGenerator` and `ClientGenerator` to simplify API client construction and shared type generation. - Update tests to match new design. --- .../justworks/core/gen/CodeGenerator.kt | 9 +- .../avsystem/justworks/core/gen/Hierarchy.kt | 25 ++- .../avsystem/justworks/core/gen/NameUtils.kt | 2 +- .../com/avsystem/justworks/core/gen/Names.kt | 1 - .../core/gen/client/ClientGenerator.kt | 88 +++++++- .../core/gen/shared/ApiClientBaseGenerator.kt | 114 +---------- .../justworks/core/gen/shared/AuthParam.kt | 59 ++---- .../avsystem/justworks/core/model/ApiSpec.kt | 12 +- .../justworks/core/parser/SpecParser.kt | 30 ++- .../core/gen/ApiClientBaseGeneratorTest.kt | 160 +-------------- .../justworks/core/gen/ClientGeneratorTest.kt | 24 +-- .../core/gen/InlineTypeResolverTest.kt | 3 + .../justworks/core/gen/IntegrationTest.kt | 6 +- .../core/gen/ModelGeneratorRegressionTest.kt | 1 + .../gradle/JustworksPluginFunctionalTest.kt | 190 ++++++++++++++++-- .../justworks/gradle/JustworksPlugin.kt | 5 - .../gradle/JustworksSharedTypesTask.kt | 43 +--- 17 files changed, 356 insertions(+), 416 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt index e636bcd..55f2ecd 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt @@ -5,7 +5,6 @@ import com.avsystem.justworks.core.gen.model.ModelGenerator import com.avsystem.justworks.core.gen.shared.ApiClientBaseGenerator import com.avsystem.justworks.core.gen.shared.ApiResponseGenerator import com.avsystem.justworks.core.model.ApiSpec -import com.avsystem.justworks.core.model.SecurityScheme import java.io.File /** @@ -21,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) @@ -40,8 +41,8 @@ object CodeGenerator { return Result(modelFiles.size, clientFiles.size) } - fun generateSharedTypes(outputDir: File, securitySchemes: List): Int { - val files = ApiResponseGenerator.generate() + ApiClientBaseGenerator.generate(securitySchemes) + fun generateSharedTypes(outputDir: File): Int { + val files = ApiResponseGenerator.generate() + ApiClientBaseGenerator.generate() files.forEach { it.writeTo(outputDir) } return files.size } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt index e573a7f..19b2e22 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt @@ -7,31 +7,30 @@ import com.avsystem.justworks.core.model.TypeRef import com.squareup.kotlinpoet.ClassName internal class Hierarchy(val modelPackage: ModelPackage) { - private val schemas = mutableSetOf() - - private val memoScope = MemoScope() + private val schemaModels = mutableSetOf() + private val schemaModelsScope = 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) { - schemas += newSchemas - memoScope.reset() + schemaModels += newSchemas + schemaModelsScope.reset() } /** All schemas indexed by name for quick lookup. */ - val schemasById: Map by memoized(memoScope) { - schemas.associateBy { it.name } + val schemasById: Map by memoized(schemaModelsScope) { + schemaModels.associateBy { it.name } } /** Schemas that define polymorphic variants via oneOf or anyOf. */ - private val polymorphicSchemas: List by memoized(memoScope) { - schemas.filterNot { it.variants().isNullOrEmpty() } + private val polymorphicSchemas: List by memoized(schemaModelsScope) { + schemaModels.filterNot { it.variants().isNullOrEmpty() } } /** Maps parent schema name to its variant schema names (for both oneOf and anyOf). */ - val sealedHierarchies: Map> by memoized(memoScope) { + val sealedHierarchies: Map> by memoized(schemaModelsScope) { polymorphicSchemas .associate { schema -> schema.name to schema @@ -43,7 +42,7 @@ internal class Hierarchy(val modelPackage: ModelPackage) { } /** Parent schema names that use anyOf without a discriminator (JsonContentPolymorphicSerializer pattern). */ - val anyOfWithoutDiscriminator: Set by memoized(memoScope) { + val anyOfWithoutDiscriminator: Set by memoized(schemaModelsScope) { polymorphicSchemas .asSequence() .filter { !it.anyOf.isNullOrEmpty() && it.discriminator == null } @@ -52,7 +51,7 @@ internal class Hierarchy(val modelPackage: ModelPackage) { } /** Inverse of [sealedHierarchies] for anyOf-without-discriminator: variant name to its parent names. */ - val anyOfParents: Map> by memoized(memoScope) { + val anyOfParents: Map> by memoized(schemaModelsScope) { sealedHierarchies .asSequence() .filter { (parent, _) -> parent in anyOfWithoutDiscriminator } @@ -62,7 +61,7 @@ internal class Hierarchy(val modelPackage: ModelPackage) { } /** Maps schema name to its [ClassName], using nested class for discriminated hierarchy variants. */ - private val lookup: Map by memoized(memoScope) { + private val lookup: Map by memoized(schemaModelsScope) { sealedHierarchies .asSequence() .filterNot { (parent, _) -> parent in anyOfWithoutDiscriminator } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameUtils.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameUtils.kt index af4105b..4ed5160 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameUtils.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameUtils.kt @@ -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])") /** diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt index 7ab14cd..199a23f 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt @@ -117,7 +117,6 @@ val UUID_SERIALIZER = ClassName("com.avsystem.justworks", "UuidSerializer") // ============================================================================ const val BASE_URL = "baseUrl" -const val TOKEN = "token" const val CLIENT = "client" const val BODY = "body" const val APPLY_AUTH = "applyAuth" diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt index 9e70367..2361371 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt @@ -1,12 +1,17 @@ package com.avsystem.justworks.core.gen.client import com.avsystem.justworks.core.gen.API_CLIENT_BASE +import com.avsystem.justworks.core.gen.APPLY_AUTH import com.avsystem.justworks.core.gen.ApiPackage +import com.avsystem.justworks.core.gen.BASE64_CLASS import com.avsystem.justworks.core.gen.BASE_URL import com.avsystem.justworks.core.gen.CLIENT import com.avsystem.justworks.core.gen.CREATE_HTTP_CLIENT import com.avsystem.justworks.core.gen.GENERATED_SERIALIZERS_MODULE +import com.avsystem.justworks.core.gen.HEADERS_FUN import com.avsystem.justworks.core.gen.HTTP_CLIENT +import com.avsystem.justworks.core.gen.HTTP_HEADERS +import com.avsystem.justworks.core.gen.HTTP_REQUEST_BUILDER import com.avsystem.justworks.core.gen.HTTP_SUCCESS import com.avsystem.justworks.core.gen.Hierarchy import com.avsystem.justworks.core.gen.NameRegistry @@ -15,10 +20,11 @@ import com.avsystem.justworks.core.gen.client.ParametersGenerator.buildBodyParam import com.avsystem.justworks.core.gen.client.ParametersGenerator.buildNullableParameter import com.avsystem.justworks.core.gen.invoke import com.avsystem.justworks.core.gen.sanitizeKdoc -import com.avsystem.justworks.core.gen.shared.ApiClientBaseGenerator +import com.avsystem.justworks.core.gen.shared.paramNames import com.avsystem.justworks.core.gen.toCamelCase import com.avsystem.justworks.core.gen.toPascalCase import com.avsystem.justworks.core.gen.toTypeName +import com.avsystem.justworks.core.model.ApiKeyLocation import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.Endpoint import com.avsystem.justworks.core.model.ParameterLocation @@ -72,7 +78,7 @@ internal object ClientGenerator { } val tokenType = LambdaTypeName.get(returnType = STRING) - val authParams = ApiClientBaseGenerator.buildAuthConstructorParams(securitySchemes) + val authParamNames = securitySchemes.flatMap { it.paramNames } val constructorBuilder = FunSpec .constructorBuilder() @@ -83,9 +89,15 @@ internal object ClientGenerator { .superclass(API_CLIENT_BASE) .addSuperclassConstructorParameter(BASE_URL) - for (paramName in authParams) { + for (paramName in authParamNames) { constructorBuilder.addParameter(paramName, tokenType) - classBuilder.addSuperclassConstructorParameter(paramName) + classBuilder.addProperty( + PropertySpec + .builder(paramName, tokenType) + .initializer(paramName) + .addModifiers(KModifier.PRIVATE) + .build(), + ) } val httpClientProperty = PropertySpec @@ -98,6 +110,10 @@ internal object ClientGenerator { .primaryConstructor(constructorBuilder.build()) .addProperty(httpClientProperty) + if (securitySchemes.isNotEmpty()) { + classBuilder.addFunction(buildApplyAuth(securitySchemes)) + } + context(NameRegistry()) { classBuilder.addFunctions(endpoints.map { generateEndpointFunction(it) }) } @@ -108,6 +124,70 @@ internal object ClientGenerator { .build() } + private fun buildApplyAuth(securitySchemes: List): FunSpec { + val builder = FunSpec + .builder(APPLY_AUTH) + .addModifiers(KModifier.OVERRIDE, KModifier.PROTECTED) + .receiver(HTTP_REQUEST_BUILDER) + + val headerSchemes = securitySchemes.filter { scheme -> + scheme is SecurityScheme.Bearer || + scheme is SecurityScheme.Basic || + (scheme is SecurityScheme.ApiKey && scheme.location == ApiKeyLocation.HEADER) + } + val querySchemes = securitySchemes + .filterIsInstance() + .filter { it.location == ApiKeyLocation.QUERY } + + if (headerSchemes.isNotEmpty()) { + builder.beginControlFlow("%M", HEADERS_FUN) + for (scheme in headerSchemes) { + val names = scheme.paramNames + when (scheme) { + is SecurityScheme.Bearer -> { + builder.addStatement( + "append(%T.Authorization, %P)", + HTTP_HEADERS, + CodeBlock.of($$"Bearer ${$${names.first()}()}"), + ) + } + + is SecurityScheme.Basic -> { + builder.addStatement( + "append(%T.Authorization, %P)", + HTTP_HEADERS, + CodeBlock.of( + $$"Basic ${%T.getEncoder().encodeToString(\"${$${names[0]}()}:${$${names[1]}()}\".toByteArray())}", + BASE64_CLASS, + ), + ) + } + + is SecurityScheme.ApiKey -> { + builder.addStatement( + "append(%S, ${names.first()}())", + scheme.parameterName, + ) + } + } + } + builder.endControlFlow() + } + + if (querySchemes.isNotEmpty()) { + builder.beginControlFlow("url") + for (scheme in querySchemes) { + builder.addStatement( + "parameters.append(%S, ${scheme.paramNames.first()}())", + scheme.parameterName, + ) + } + builder.endControlFlow() + } + + return builder.build() + } + context(_: Hierarchy, methodRegistry: NameRegistry) private fun generateEndpointFunction(endpoint: Endpoint): FunSpec { val functionName = methodRegistry.register(endpoint.operationId.toCamelCase()) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt index c01cf97..337af32 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt @@ -2,7 +2,6 @@ package com.avsystem.justworks.core.gen.shared import com.avsystem.justworks.core.gen.API_CLIENT_BASE import com.avsystem.justworks.core.gen.APPLY_AUTH -import com.avsystem.justworks.core.gen.BASE64_CLASS import com.avsystem.justworks.core.gen.BASE_URL import com.avsystem.justworks.core.gen.BODY_AS_TEXT_FUN import com.avsystem.justworks.core.gen.BODY_FUN @@ -12,11 +11,9 @@ import com.avsystem.justworks.core.gen.CONTENT_NEGOTIATION import com.avsystem.justworks.core.gen.CREATE_HTTP_CLIENT import com.avsystem.justworks.core.gen.ENCODE_PARAM_FUN import com.avsystem.justworks.core.gen.ENCODE_TO_STRING_FUN -import com.avsystem.justworks.core.gen.HEADERS_FUN import com.avsystem.justworks.core.gen.HTTP_CLIENT import com.avsystem.justworks.core.gen.HTTP_ERROR import com.avsystem.justworks.core.gen.HTTP_ERROR_TYPE -import com.avsystem.justworks.core.gen.HTTP_HEADERS import com.avsystem.justworks.core.gen.HTTP_REQUEST_BUILDER import com.avsystem.justworks.core.gen.HTTP_REQUEST_TIMEOUT_EXCEPTION import com.avsystem.justworks.core.gen.HTTP_RESPONSE @@ -26,10 +23,6 @@ import com.avsystem.justworks.core.gen.JSON_CLASS import com.avsystem.justworks.core.gen.JSON_FUN import com.avsystem.justworks.core.gen.SAFE_CALL import com.avsystem.justworks.core.gen.SERIALIZERS_MODULE -import com.avsystem.justworks.core.gen.toCamelCase -import com.avsystem.justworks.core.model.ApiKeyLocation -import com.avsystem.justworks.core.model.SecurityScheme -import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.KModifier @@ -57,7 +50,7 @@ internal object ApiClientBaseGenerator { private const val BLOCK = "block" private const val NETWORK_ERROR = "Network error" - fun generate(securitySchemes: List): FileSpec { + fun generate(): FileSpec { val t = TypeVariableName("T").copy(reified = true) return FileSpec @@ -66,7 +59,7 @@ internal object ApiClientBaseGenerator { .addFunction(buildMapToResult(t)) .addFunction(buildToResult(t)) .addFunction(buildToEmptyResult()) - .addType(buildApiClientBaseClass(securitySchemes)) + .addType(buildApiClientBaseClass()) .build() } @@ -124,10 +117,7 @@ internal object ApiClientBaseGenerator { .addStatement("return %L { Unit }", MAP_TO_RESULT) .build() - private fun buildApiClientBaseClass(securitySchemes: List): TypeSpec { - val tokenType = LambdaTypeName.get(returnType = STRING) - val authParams = buildAuthConstructorParams(securitySchemes) - + private fun buildApiClientBaseClass(): TypeSpec { val constructorBuilder = FunSpec .constructorBuilder() .addParameter(BASE_URL, STRING) @@ -143,19 +133,6 @@ internal object ApiClientBaseGenerator { .addModifiers(KModifier.PROTECTED) .build() - classBuilder.addProperty(baseUrlProp) - - for (paramName in authParams) { - constructorBuilder.addParameter(paramName, tokenType) - classBuilder.addProperty( - PropertySpec - .builder(paramName, tokenType) - .initializer(paramName) - .addModifiers(KModifier.PRIVATE) - .build(), - ) - } - val clientProp = PropertySpec .builder(CLIENT, HTTP_CLIENT) .addModifiers(KModifier.PROTECTED, KModifier.ABSTRACT) @@ -167,92 +144,23 @@ internal object ApiClientBaseGenerator { .addStatement("$CLIENT.close()") .build() + val applyAuthFun = FunSpec + .builder(APPLY_AUTH) + .addModifiers(KModifier.PROTECTED, KModifier.OPEN) + .receiver(HTTP_REQUEST_BUILDER) + .build() + return classBuilder .primaryConstructor(constructorBuilder.build()) + .addProperty(baseUrlProp) .addProperty(clientProp) .addFunction(closeFun) - .addFunction(buildApplyAuth(securitySchemes)) + .addFunction(applyAuthFun) .addFunction(buildSafeCall()) .addFunction(buildCreateHttpClient()) .build() } - private fun buildApplyAuth(securitySchemes: List): FunSpec { - val builder = FunSpec - .builder(APPLY_AUTH) - .addModifiers(KModifier.PROTECTED) - .receiver(HTTP_REQUEST_BUILDER) - - if (securitySchemes.isEmpty()) return builder.build() - - val headerSchemes = securitySchemes.filter { scheme -> - scheme is SecurityScheme.Bearer || - scheme is SecurityScheme.Basic || - (scheme is SecurityScheme.ApiKey && scheme.location == ApiKeyLocation.HEADER) - } - val querySchemes = securitySchemes - .filterIsInstance() - .filter { it.location == ApiKeyLocation.QUERY } - - if (headerSchemes.isNotEmpty()) { - val isSingleBearer = isSingleBearer(securitySchemes) - - builder.beginControlFlow("%M", HEADERS_FUN) - for (scheme in headerSchemes) { - when (scheme) { - is SecurityScheme.Bearer if isSingleBearer -> { - builder.addStatement( - "append(%T.Authorization, %P)", - HTTP_HEADERS, - CodeBlock.of($$"Bearer ${$${scheme.toAuthParam().name}()}"), - ) - } - - is SecurityScheme.Bearer -> { - builder.addStatement( - "append(%T.Authorization, %P)", - HTTP_HEADERS, - CodeBlock.of($$"Bearer ${$${scheme.toAuthParam().name}()}"), - ) - } - - is SecurityScheme.Basic -> { - val authParam = scheme.toAuthParam() - builder.addStatement( - "append(%T.Authorization, %P)", - HTTP_HEADERS, - CodeBlock.of( - $$"Basic ${%T.getEncoder().encodeToString(\"${$${authParam.username}()}:${$${authParam.password}()}\".toByteArray())}", - BASE64_CLASS, - ), - ) - } - - is SecurityScheme.ApiKey -> { - builder.addStatement( - "append(%S, ${scheme.toAuthParam().name}())", - scheme.parameterName, - ) - } - } - } - builder.endControlFlow() - } - - if (querySchemes.isNotEmpty()) { - builder.beginControlFlow("url") - for (scheme in querySchemes) { - builder.addStatement( - "parameters.append(%S, ${scheme.name.toCamelCase()}Key())", - scheme.parameterName, - ) - } - builder.endControlFlow() - } - - return builder.build() - } - private fun buildSafeCall(): FunSpec = FunSpec .builder(SAFE_CALL) .addModifiers(KModifier.PROTECTED, KModifier.SUSPEND) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/AuthParam.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/AuthParam.kt index 4fc8d35..9de9f56 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/AuthParam.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/AuthParam.kt @@ -1,55 +1,22 @@ package com.avsystem.justworks.core.gen.shared -import com.avsystem.justworks.core.gen.TOKEN import com.avsystem.justworks.core.gen.toCamelCase +import com.avsystem.justworks.core.gen.toPascalCase import com.avsystem.justworks.core.model.SecurityScheme /** - * Builds the list of auth-related constructor parameter names based on security schemes. + * Derives constructor parameter names for a security scheme. + * + * Bearer and ApiKey produce a single parameter name; Basic produces two + * (username + password). The names are scoped by both [SecurityScheme.name] + * and [SecurityScheme.specTitle] to avoid collisions across specs. */ -internal fun buildAuthConstructorParams(securitySchemes: List): List = - if (isSingleBearer(securitySchemes)) { - listOf(TOKEN) - } else { - securitySchemes.flatMap { - when (it) { - is SecurityScheme.Bearer -> { - listOf(it.toAuthParam().name) - } - - is SecurityScheme.ApiKey -> { - listOf(it.toAuthParam().name) - } - - is SecurityScheme.Basic -> { - val authParam = it.toAuthParam() - listOf(authParam.username, it.toAuthParam().password) - } - } +internal val SecurityScheme.paramNames: List + get() { + val base = "${name.toCamelCase()}${specTitle.toPascalCase()}" + return when (this) { + is SecurityScheme.Bearer -> listOf("${base}Token") + is SecurityScheme.ApiKey -> listOf(base) + is SecurityScheme.Basic -> listOf("${base}Username", "${base}Password") } } - -sealed interface AuthParam { - data class Bearer(private val _name: String) : AuthParam { - val name = "${_name.toCamelCase()}Token" - } - - data class Basic(private val _name: String) : AuthParam { - val name = _name.toCamelCase() - val username = "${name}Username" - val password = "${name}Password" - } - - data class ApiKey(private val _name: String) : AuthParam { - val name = _name.toCamelCase() - } -} - -internal fun SecurityScheme.Basic.toAuthParam(): AuthParam.Basic = AuthParam.Basic(name) - -internal fun SecurityScheme.ApiKey.toAuthParam(): AuthParam.ApiKey = AuthParam.ApiKey(name) - -internal fun SecurityScheme.Bearer.toAuthParam(): AuthParam.Bearer = AuthParam.Bearer(name) - -internal fun isSingleBearer(securitySchemes: List): Boolean = - securitySchemes.size == 1 && securitySchemes.first() is SecurityScheme.Bearer diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt b/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt index af4a13d..7a2cbc8 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt @@ -9,16 +9,18 @@ package com.avsystem.justworks.core.model */ sealed interface SecurityScheme { val name: String + val specTitle: String - data class Bearer(override val name: String) : SecurityScheme + data class Bearer(override val name: String, override val specTitle: String) : SecurityScheme data class ApiKey( override val name: String, + override val specTitle: String, val parameterName: String, val location: ApiKeyLocation, ) : SecurityScheme - data class Basic(override val name: String) : SecurityScheme + data class Basic(override val name: String, override val specTitle: String) : SecurityScheme } enum class ApiKeyLocation { HEADER, QUERY } @@ -29,7 +31,7 @@ data class ApiSpec( val endpoints: List, val schemas: List, val enums: List, - val securitySchemes: List = emptyList(), + val securitySchemes: List, ) data class Endpoint( @@ -96,9 +98,7 @@ data class SchemaModel( val anyOf: List?, val discriminator: Discriminator?, val underlyingType: TypeRef? = null, -) { - val isNested get() = name.contains(".") -} +) data class PropertyModel( val name: String, diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt index 04c3737..9b89964 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -98,6 +98,7 @@ object SpecParser { fun parseSecuritySchemes(specFile: File): ParseResult> = parseSpec(specFile, resolveFully = false) { openApi -> extractSecuritySchemes( + openApi.info?.title ?: "Untitled", openApi.components?.securitySchemes.orEmpty(), openApi.security.orEmpty(), ) @@ -155,8 +156,10 @@ object SpecParser { context(_: Raise, _: Warnings) private fun OpenAPI.toApiSpec(): ApiSpec { val allSchemas = components?.schemas.orEmpty() + val title = info?.title ?: "Untitled" val securitySchemes = extractSecuritySchemes( + title, components?.securitySchemes.orEmpty(), security.orEmpty(), ) @@ -199,7 +202,7 @@ object SpecParser { val syntheticModels = collectModels(emptySet(), emptyList()) return ApiSpec( - title = info?.title ?: "Untitled", + title = title, version = info?.version ?: "0.0.0", endpoints = endpoints, schemas = schemaModels + syntheticModels, @@ -211,6 +214,7 @@ object SpecParser { context(_: Warnings) private fun extractSecuritySchemes( + specTitle: String, definitions: Map, requirements: List, ): List { @@ -218,24 +222,36 @@ object SpecParser { return referencedNames.mapNotNull { name -> ensureNotNullOrAccumulate(definitions[name]) { Issue.Warning("Security requirement references undefined scheme '$name'") - }?.toSecurityScheme(name) + }?.toSecurityScheme(name, specTitle) } } context(_: Warnings) - private fun SwaggerSecurityScheme.toSecurityScheme(name: String): SecurityScheme? = when (type) { + private fun SwaggerSecurityScheme.toSecurityScheme(name: String, specTitle: String): SecurityScheme? = when (type) { SwaggerSecurityScheme.Type.HTTP -> { when (scheme?.lowercase()) { - "bearer" -> SecurityScheme.Bearer(name) - "basic" -> SecurityScheme.Basic(name) + "bearer" -> SecurityScheme.Bearer(name, specTitle) + "basic" -> SecurityScheme.Basic(name, specTitle) else -> accumulateAndReturnNull(Issue.Warning("Unsupported HTTP auth scheme '$scheme' for '$name'")) } } SwaggerSecurityScheme.Type.APIKEY -> { when (`in`) { - SwaggerSecurityScheme.In.HEADER -> SecurityScheme.ApiKey(name, this.name, ApiKeyLocation.HEADER) - SwaggerSecurityScheme.In.QUERY -> SecurityScheme.ApiKey(name, this.name, ApiKeyLocation.QUERY) + SwaggerSecurityScheme.In.HEADER -> SecurityScheme.ApiKey( + name, + specTitle, + this.name, + ApiKeyLocation.HEADER, + ) + + SwaggerSecurityScheme.In.QUERY -> SecurityScheme.ApiKey( + name, + specTitle, + this.name, + ApiKeyLocation.QUERY, + ) + else -> accumulateAndReturnNull(Issue.Warning("Unsupported API key location '${`in`}' for '$name'")) } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt index fe0ef92..9d747c2 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt @@ -1,8 +1,6 @@ package com.avsystem.justworks.core.gen import com.avsystem.justworks.core.gen.shared.ApiClientBaseGenerator -import com.avsystem.justworks.core.model.ApiKeyLocation -import com.avsystem.justworks.core.model.SecurityScheme import com.squareup.kotlinpoet.ExperimentalKotlinPoetApi import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.KModifier @@ -14,32 +12,14 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue class ApiClientBaseGeneratorTest { - private val file = ApiClientBaseGenerator.generate(emptyList()) + private val file = ApiClientBaseGenerator.generate() private val classSpec: TypeSpec get() = file.members.filterIsInstance().first { it.name == "ApiClientBase" } private fun topLevelFun(name: String): FunSpec = file.members.filterIsInstance().first { it.name == name } - private fun classFor(schemes: List): TypeSpec { - val f = ApiClientBaseGenerator.generate(schemes) - return f.members.filterIsInstance().first { it.name == "ApiClientBase" } - } - - private fun applyAuthBody(schemes: List): String { - val cls = classFor(schemes) - return cls.funSpecs - .first { it.name == "applyAuth" } - .body - .toString() - } - - private fun constructorParamNames(schemes: List): List { - val cls = classFor(schemes) - return cls.primaryConstructor!!.parameters.map { it.name } - } - - // -- ApiClientBase class (no-arg backward compat) -- + // -- ApiClientBase class -- @Test fun `ApiClientBase is abstract`() { @@ -53,7 +33,7 @@ class ApiClientBaseGeneratorTest { } @Test - fun `ApiClientBase has constructor with only baseUrl when no schemes`() { + fun `ApiClientBase has constructor with only baseUrl`() { val constructor = assertNotNull(classSpec.primaryConstructor) val paramNames = constructor.parameters.map { it.name } assertEquals(listOf("baseUrl"), paramNames) @@ -75,12 +55,10 @@ class ApiClientBaseGeneratorTest { } @Test - fun `ApiClientBase has empty applyAuth when no schemes`() { + fun `ApiClientBase has applyAuth function`() { val applyAuth = classSpec.funSpecs.first { it.name == "applyAuth" } assertTrue(KModifier.PROTECTED in applyAuth.modifiers) assertNotNull(applyAuth.receiverType, "Expected HttpRequestBuilder receiver") - val body = applyAuth.body.toString() - assertTrue(!body.contains("Authorization"), "Expected no Authorization header for empty schemes") } @OptIn(ExperimentalKotlinPoetApi::class) @@ -156,134 +134,4 @@ class ApiClientBaseGeneratorTest { fun `generates single file named ApiClientBase`() { assertEquals("ApiClientBase", file.name) } - - // -- Security scheme: single Bearer (backward compat) -- - - @Test - fun `single Bearer scheme uses token param name for backward compat`() { - val params = constructorParamNames(listOf(SecurityScheme.Bearer("BearerAuth"))) - assertTrue("baseUrl" in params, "Expected baseUrl param") - assertTrue("token" in params, "Expected token param (single-bearer shorthand)") - } - - @Test - fun `single Bearer scheme generates Bearer auth in applyAuth`() { - val body = applyAuthBody(listOf(SecurityScheme.Bearer("BearerAuth"))) - assertTrue(body.contains("Authorization"), "Expected Authorization header") - assertTrue(body.contains("Bearer"), "Expected Bearer prefix") - assertTrue(body.contains("token()"), "Expected token() invocation") - } - - // -- Security scheme: ApiKey in header -- - - @Test - fun `ApiKey HEADER scheme generates constructor param with Key suffix`() { - val params = constructorParamNames( - listOf(SecurityScheme.ApiKey("ApiKeyHeader", "X-API-Key", ApiKeyLocation.HEADER)), - ) - assertTrue("baseUrl" in params, "Expected baseUrl param") - assertTrue("apiKeyHeaderKey" in params, "Expected apiKeyHeaderKey param") - } - - @Test - fun `ApiKey HEADER scheme generates header append in applyAuth`() { - val body = applyAuthBody( - listOf(SecurityScheme.ApiKey("ApiKeyHeader", "X-API-Key", ApiKeyLocation.HEADER)), - ) - assertTrue(body.contains("headers"), "Expected headers block") - assertTrue(body.contains("X-API-Key"), "Expected X-API-Key header name") - assertTrue(body.contains("apiKeyHeaderKey()"), "Expected apiKeyHeaderKey() invocation") - } - - // -- Security scheme: ApiKey in query -- - - @Test - fun `ApiKey QUERY scheme generates constructor param with Key suffix`() { - val params = constructorParamNames( - listOf(SecurityScheme.ApiKey("ApiKeyQuery", "api_key", ApiKeyLocation.QUERY)), - ) - assertTrue("baseUrl" in params, "Expected baseUrl param") - assertTrue("apiKeyQueryKey" in params, "Expected apiKeyQueryKey param") - } - - @Test - fun `ApiKey QUERY scheme generates query parameter in applyAuth`() { - val body = applyAuthBody( - listOf(SecurityScheme.ApiKey("ApiKeyQuery", "api_key", ApiKeyLocation.QUERY)), - ) - assertTrue(body.contains("url"), "Expected url block") - assertTrue(body.contains("parameters.append"), "Expected parameters.append") - assertTrue(body.contains("api_key"), "Expected api_key query param name") - assertTrue(body.contains("apiKeyQueryKey()"), "Expected apiKeyQueryKey() invocation") - } - - // -- Security scheme: HTTP Basic -- - - @Test - fun `Basic scheme generates username and password constructor params`() { - val params = constructorParamNames(listOf(SecurityScheme.Basic("BasicAuth"))) - assertTrue("baseUrl" in params, "Expected baseUrl param") - assertTrue("basicAuthUsername" in params, "Expected basicAuthUsername param") - assertTrue("basicAuthPassword" in params, "Expected basicAuthPassword param") - } - - @Test - fun `Basic scheme generates Base64 Authorization header in applyAuth`() { - val body = applyAuthBody(listOf(SecurityScheme.Basic("BasicAuth"))) - assertTrue(body.contains("Authorization"), "Expected Authorization header") - assertTrue(body.contains("Basic"), "Expected Basic prefix") - assertTrue(body.contains("Base64"), "Expected Base64 encoding") - assertTrue(body.contains("basicAuthUsername()"), "Expected basicAuthUsername() invocation") - assertTrue(body.contains("basicAuthPassword()"), "Expected basicAuthPassword() invocation") - } - - // -- Multiple schemes -- - - @Test - fun `multiple schemes generate all constructor params`() { - val params = constructorParamNames( - listOf( - SecurityScheme.Bearer("BearerAuth"), - SecurityScheme.ApiKey("ApiKeyHeader", "X-API-Key", ApiKeyLocation.HEADER), - SecurityScheme.Basic("BasicAuth"), - ), - ) - assertTrue("baseUrl" in params, "Expected baseUrl param") - assertTrue("bearerAuthToken" in params, "Expected bearerAuthToken param (multi-scheme uses full name)") - assertTrue("apiKeyHeaderKey" in params, "Expected apiKeyHeaderKey param") - assertTrue("basicAuthUsername" in params, "Expected basicAuthUsername param") - assertTrue("basicAuthPassword" in params, "Expected basicAuthPassword param") - } - - @Test - fun `multiple schemes generate all auth types in applyAuth`() { - val body = applyAuthBody( - listOf( - SecurityScheme.Bearer("BearerAuth"), - SecurityScheme.ApiKey("ApiKeyHeader", "X-API-Key", ApiKeyLocation.HEADER), - SecurityScheme.ApiKey("ApiKeyQuery", "api_key", ApiKeyLocation.QUERY), - SecurityScheme.Basic("BasicAuth"), - ), - ) - assertTrue(body.contains("Bearer"), "Expected Bearer in applyAuth") - assertTrue(body.contains("X-API-Key"), "Expected X-API-Key in applyAuth") - assertTrue(body.contains("api_key"), "Expected api_key query param in applyAuth") - assertTrue(body.contains("Basic"), "Expected Basic in applyAuth") - assertTrue(body.contains("Base64"), "Expected Base64 in applyAuth") - } - - // -- Empty schemes (spec with no security) -- - - @Test - fun `empty schemes list generates only baseUrl constructor param`() { - val params = constructorParamNames(emptyList()) - assertEquals(listOf("baseUrl"), params, "Expected only baseUrl param when no security schemes") - } - - @Test - fun `empty schemes list generates empty applyAuth body`() { - val body = applyAuthBody(emptyList()) - assertTrue(!body.contains("headers"), "Expected no headers block for empty schemes") - assertTrue(!body.contains("url"), "Expected no url block for empty schemes") - } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt index 7196d9b..2702e40 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt @@ -665,19 +665,19 @@ class ClientGeneratorTest { fun `ApiKey HEADER scheme generates constructor with baseUrl and apiKey param`() { val cls = clientClass( listOf(endpoint()), - listOf(SecurityScheme.ApiKey("ApiKeyHeader", "X-API-Key", ApiKeyLocation.HEADER)), + listOf(SecurityScheme.ApiKey("ApiKeyHeader", "", "X-API-Key", ApiKeyLocation.HEADER)), ) val constructor = assertNotNull(cls.primaryConstructor) val paramNames = constructor.parameters.map { it.name } assertTrue("baseUrl" in paramNames, "Expected baseUrl param") - assertTrue("apiKeyHeaderKey" in paramNames, "Expected apiKeyHeaderKey param") + assertTrue("apiKeyHeader" in paramNames, "Expected apiKeyHeader param") } @Test fun `Basic scheme generates constructor with baseUrl, username, and password`() { val cls = clientClass( listOf(endpoint()), - listOf(SecurityScheme.Basic("BasicAuth")), + listOf(SecurityScheme.Basic("BasicAuth", "")), ) val constructor = assertNotNull(cls.primaryConstructor) val paramNames = constructor.parameters.map { it.name } @@ -691,21 +691,19 @@ class ClientGeneratorTest { val cls = clientClass( listOf(endpoint()), listOf( - SecurityScheme.Bearer("BearerAuth"), - SecurityScheme.ApiKey("ApiKeyHeader", "X-API-Key", ApiKeyLocation.HEADER), + SecurityScheme.Bearer("BearerAuth", ""), + SecurityScheme.ApiKey("ApiKeyHeader", "", "X-API-Key", ApiKeyLocation.HEADER), ), ) val constructor = assertNotNull(cls.primaryConstructor) val paramNames = constructor.parameters.map { it.name } assertTrue("baseUrl" in paramNames, "Expected baseUrl param") assertTrue("bearerAuthToken" in paramNames, "Expected bearerAuthToken param") - assertTrue("apiKeyHeaderKey" in paramNames, "Expected apiKeyHeaderKey param") + assertTrue("apiKeyHeader" in paramNames, "Expected apiKeyHeader param") - // Verify superclass constructor params match + // Verify only baseUrl is passed to super (auth is handled per-client, not in ApiClientBase) val superParams = cls.superclassConstructorParameters.map { it.toString().trim() } - assertTrue(superParams.contains("baseUrl"), "Expected baseUrl passed to super") - assertTrue(superParams.contains("bearerAuthToken"), "Expected bearerAuthToken passed to super") - assertTrue(superParams.contains("apiKeyHeaderKey"), "Expected apiKeyHeaderKey passed to super") + assertEquals(listOf("baseUrl"), superParams, "Expected only baseUrl passed to super") } @Test @@ -735,14 +733,14 @@ class ClientGeneratorTest { } @Test - fun `single Bearer scheme uses token param name as shorthand`() { + fun `single Bearer scheme generates named token param`() { val cls = clientClass( listOf(endpoint()), - listOf(SecurityScheme.Bearer("BearerAuth")), + listOf(SecurityScheme.Bearer("BearerAuth", "")), ) val constructor = assertNotNull(cls.primaryConstructor) val paramNames = constructor.parameters.map { it.name } - assertTrue("token" in paramNames, "Expected token param (single-bearer shorthand)") + assertTrue("bearerAuthToken" in paramNames, "Expected bearerAuthToken param") } // -- DOCS-03: Endpoint KDoc generation -- diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineTypeResolverTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineTypeResolverTest.kt index 7c72fd1..1a55755 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineTypeResolverTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineTypeResolverTest.kt @@ -20,6 +20,7 @@ class InlineTypeResolverTest { schemas = emptyList(), enums = emptyList(), endpoints = emptyList(), + securitySchemes = emptyList(), ) private fun inlineType(vararg propNames: String, contextHint: String = "Test") = TypeRef.Inline( @@ -114,6 +115,7 @@ class InlineTypeResolverTest { version = "1.0", schemas = emptyList(), enums = emptyList(), + securitySchemes = emptyList(), endpoints = listOf( Endpoint( path = "/test", @@ -148,6 +150,7 @@ class InlineTypeResolverTest { version = "1.0", schemas = emptyList(), enums = emptyList(), + securitySchemes = emptyList(), endpoints = listOf( Endpoint( path = "/test", diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt index b26c666..9cb17fc 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt @@ -102,7 +102,7 @@ class IntegrationTest { val spec = parseSpec(fixture).value if (spec.endpoints.isEmpty()) continue - val apiClientBaseFile = ApiClientBaseGenerator.generate(spec.securitySchemes) + val apiClientBaseFile = ApiClientBaseGenerator.generate() assertNotNull(apiClientBaseFile, "$fixture: ApiClientBaseGenerator should produce output") val source = apiClientBaseFile.toString() @@ -133,7 +133,7 @@ class IntegrationTest { ) } - val apiClientBaseFile = ApiClientBaseGenerator.generate(spec.securitySchemes) + val apiClientBaseFile = ApiClientBaseGenerator.generate() assertNotNull(apiClientBaseFile, "$fixture: ApiClientBaseGenerator should produce output") } } @@ -178,7 +178,7 @@ class IntegrationTest { val (_, resolvedSpec) = generateModelWithResolvedSpec(spec) val clientFiles = generateClient(resolvedSpec) - val apiClientBaseFile = ApiClientBaseGenerator.generate(spec.securitySchemes) + val apiClientBaseFile = ApiClientBaseGenerator.generate() val allSources = (clientFiles + apiClientBaseFile).map { it.toString() } for (source in allSources) { diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorRegressionTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorRegressionTest.kt index de95681..09eaf6d 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorRegressionTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorRegressionTest.kt @@ -26,6 +26,7 @@ class ModelGeneratorRegressionTest { endpoints = emptyList(), schemas = schemas, enums = emptyList(), + securitySchemes = emptyList(), ) private fun schema( diff --git a/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt b/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt index 9c63ba3..103658e 100644 --- a/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt +++ b/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt @@ -582,21 +582,24 @@ class JustworksPluginFunctionalTest { result.task(":justworksGenerateSecured")?.outcome, ) - val apiClientBase = projectDir - .resolve("build/generated/justworks/shared/kotlin/com/avsystem/justworks/ApiClientBase.kt") - assertTrue(apiClientBase.exists(), "ApiClientBase.kt should exist") + // Auth params and applyAuth are generated in the per-spec client, not in ApiClientBase + val clientFile = projectDir + .resolve("build/generated/justworks/secured/com/example/secured/api/DataApi.kt") + assertTrue(clientFile.exists(), "DataApi.kt should exist") - val content = apiClientBase.readText() - assertTrue(content.contains("apiKeyAuthKey"), "Should contain apiKeyAuthKey param") - assertTrue(content.contains("basicAuthUsername"), "Should contain basicAuthUsername param") - assertTrue(content.contains("basicAuthPassword"), "Should contain basicAuthPassword param") + val content = clientFile.readText() + assertTrue(content.contains("apiKeyAuthSecuredApi"), "Should contain apiKeyAuthSecuredApi param") + assertTrue(content.contains("basicAuthSecuredApiUsername"), "Should contain basicAuthSecuredApiUsername param") + assertTrue(content.contains("basicAuthSecuredApiPassword"), "Should contain basicAuthSecuredApiPassword param") assertTrue(content.contains("X-API-Key"), "Should contain X-API-Key header name") - assertTrue(content.contains("applyAuth"), "Should contain applyAuth method") + assertTrue(content.contains("applyAuth"), "Should contain applyAuth override") assertTrue(content.contains("Authorization"), "Should contain Authorization header for Basic auth") - assertFalse( - content.contains("token: () -> String"), - "Should NOT contain backward-compat token param when explicit security schemes present", - ) + + // ApiClientBase should be auth-agnostic + val apiClientBase = projectDir + .resolve("build/generated/justworks/shared/kotlin/com/avsystem/justworks/ApiClientBase.kt") + val baseContent = apiClientBase.readText() + assertFalse(baseContent.contains("token"), "ApiClientBase should NOT contain auth params") } @Test @@ -636,4 +639,167 @@ class JustworksPluginFunctionalTest { assertEquals(TaskOutcome.UP_TO_DATE, result.task(":justworksGenerateAll")?.outcome) assertTrue(result.output.contains("justworks: no specs configured")) } + + @Test + fun `multiple specs with conflicting security schemes generate unique params in ApiClientBase`() { + writeFile( + "api/spec1.yaml", + """ + openapi: '3.0.0' + info: + title: API 1 + version: '1.0' + paths: + /health: + get: + operationId: checkHealth + responses: + '200': + description: OK + components: + securitySchemes: + CommonAuth: + type: apiKey + in: header + name: X-API-Key-1 + security: + - CommonAuth: [] + """.trimIndent(), + ) + + writeFile( + "api/spec2.yaml", + """ + openapi: '3.0.0' + info: + title: API 2 + version: '1.0' + paths: + /health: + get: + operationId: checkHealth + responses: + '200': + description: OK + components: + securitySchemes: + CommonAuth: + type: apiKey + in: header + name: X-API-Key-2 + security: + - CommonAuth: [] + """.trimIndent(), + ) + + writeFile( + "build.gradle.kts", + """ + plugins { + id("com.avsystem.justworks") + } + + justworks { + specs { + register("spec1") { + specFile = file("api/spec1.yaml") + packageName = "com.example.spec1" + } + register("spec2") { + specFile = file("api/spec2.yaml") + packageName = "com.example.spec2" + } + } + } + """.trimIndent(), + ) + + runner("justworksGenerateAll").build() + + // Each client has its own auth param scoped by spec title — no cross-spec forwarding + val api1Client = projectDir + .resolve("build/generated/justworks/spec1/com/example/spec1/api/DefaultApi.kt") + assertTrue(api1Client.exists(), "DefaultApi for spec1 should exist") + val api1Content = api1Client.readText() + assertTrue(api1Content.contains("commonAuthApi1"), "Spec1 client should take commonAuthApi1") + assertTrue(api1Content.contains("X-API-Key-1"), "Spec1 client should reference X-API-Key-1") + assertTrue( + api1Content.contains("ApiClientBase(baseUrl)"), + "Spec1 client should pass only baseUrl to super", + ) + + val api2Client = projectDir + .resolve("build/generated/justworks/spec2/com/example/spec2/api/DefaultApi.kt") + assertTrue(api2Client.exists(), "DefaultApi for spec2 should exist") + val api2Content = api2Client.readText() + assertTrue(api2Content.contains("commonAuthApi2"), "Spec2 client should take commonAuthApi2") + assertTrue(api2Content.contains("X-API-Key-2"), "Spec2 client should reference X-API-Key-2") + assertTrue( + api2Content.contains("ApiClientBase(baseUrl)"), + "Spec2 client should pass only baseUrl to super", + ) + } + + @Test + fun `multiple specs with identical security schemes pass the build`() { + writeFile( + "api/spec1.yaml", + """ + openapi: '3.0.0' + info: + title: API 1 + version: '1.0' + components: + securitySchemes: + CommonAuth: + type: apiKey + in: header + name: X-API-Key + security: + - CommonAuth: [] + """.trimIndent(), + ) + + writeFile( + "api/spec2.yaml", + """ + openapi: '3.0.0' + info: + title: API 2 + version: '1.0' + components: + securitySchemes: + CommonAuth: + type: apiKey + in: header + name: X-API-Key + security: + - CommonAuth: [] + """.trimIndent(), + ) + + writeFile( + "build.gradle.kts", + """ + plugins { + id("com.avsystem.justworks") + } + + justworks { + specs { + register("spec1") { + specFile = file("api/spec1.yaml") + packageName = "com.example" + } + register("spec2") { + specFile = file("api/spec2.yaml") + packageName = "com.example" + } + } + } + """.trimIndent(), + ) + + runner("justworksSharedTypes").build() + } } diff --git a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksPlugin.kt b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksPlugin.kt index 92df1f9..707dbbd 100644 --- a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksPlugin.kt +++ b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksPlugin.kt @@ -60,11 +60,6 @@ class JustworksPlugin : Plugin { task.description = "Generate Kotlin client from '${spec.name}' OpenAPI spec" } - // Wire spec file into shared types task for security scheme extraction - sharedTypesTask.configure { task -> - task.specFiles.from(spec.specFile) - } - // Wire spec task into aggregate task generateAllTask.configure { it.dependsOn(specTask) } } diff --git a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt index f54fe6d..52f3203 100644 --- a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt +++ b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt @@ -1,34 +1,18 @@ package com.avsystem.justworks.gradle import com.avsystem.justworks.core.gen.CodeGenerator -import com.avsystem.justworks.core.parser.ParseResult -import com.avsystem.justworks.core.parser.SpecParser import org.gradle.api.DefaultTask -import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty import org.gradle.api.tasks.CacheableTask -import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.OutputDirectory -import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction /** * Gradle task that generates shared types (HttpError, Success, ApiClientBase) once * to a fixed output directory shared across all spec configurations. - * - * When [specFiles] are configured, the task performs a lightweight parse to extract - * only security schemes (skipping full endpoint/schema extraction) and passes them - * to ApiClientBase generation so the generated auth code reflects the spec's - * security configuration. */ @CacheableTask abstract class JustworksSharedTypesTask : DefaultTask() { - /** All configured spec files — used to extract security schemes. */ - @get:InputFiles - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val specFiles: ConfigurableFileCollection - /** Output directory for shared type files. */ @get:OutputDirectory abstract val outputDir: DirectoryProperty @@ -36,32 +20,7 @@ abstract class JustworksSharedTypesTask : DefaultTask() { @TaskAction fun generate() { val outDir = outputDir.get().asFile.recreateDirectory() - - val allSchemes = specFiles.files.sortedBy { it.path }.flatMap { file -> - when (val result = SpecParser.parseSecuritySchemes(file)) { - is ParseResult.Success -> { - result.warnings.forEach { logger.warn(it.message) } - result.value - } - - is ParseResult.Failure -> { - logger.warn("Failed to parse security schemes from '${file.name}': ${result.error}") - emptyList() - } - } - } - - for ((name, schemes) in allSchemes.groupBy { it.name }) { - if (schemes.size > 1) { - val types = schemes.map { it::class.simpleName } - logger.warn( - "Security scheme '$name' defined ${schemes.size} times with types $types — " + - "using first occurrence (${types.first()})", - ) - } - } - - val count = CodeGenerator.generateSharedTypes(outDir, allSchemes.distinctBy { it.name }) + val count = CodeGenerator.generateSharedTypes(outDir) logger.lifecycle("Generated $count shared type files") } } From 64e8a3f73b3bc309e9c59a300ee6f124ed7323f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 9 Apr 2026 15:41:50 +0200 Subject: [PATCH 12/23] refactor(core): move specTitle out of SecurityScheme model and sanitize identifier generation specTitle is a generation concern, not a property of the scheme itself. Move it to ClientGenerator where the spec context is available, keeping SecurityScheme a pure domain model. Also strip non-alphanumeric chars in toPascalCase so free-text titles like "Payments API (v2)" produce valid Kotlin identifiers. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../avsystem/justworks/core/gen/NameUtils.kt | 4 ++- .../core/gen/client/ClientGenerator.kt | 13 ++++----- .../justworks/core/gen/shared/AuthParam.kt | 19 +++++++------ .../avsystem/justworks/core/model/ApiSpec.kt | 6 ++--- .../justworks/core/parser/SpecParser.kt | 27 +++++-------------- .../justworks/core/gen/ClientGeneratorTest.kt | 22 +++++++-------- 6 files changed, 38 insertions(+), 53 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameUtils.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameUtils.kt index 4ed5160..99b03db 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameUtils.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameUtils.kt @@ -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. diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt index 2361371..9355c82 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt @@ -57,7 +57,7 @@ internal object ClientGenerator { fun generate(spec: ApiSpec, hasPolymorphicTypes: Boolean): List { val grouped = spec.endpoints.groupBy { it.tags.firstOrNull() ?: DEFAULT_TAG } return grouped.map { (tag, endpoints) -> - generateClientFile(tag, endpoints, hasPolymorphicTypes, spec.securitySchemes) + generateClientFile(tag, endpoints, hasPolymorphicTypes, spec.securitySchemes, spec.title) } } @@ -67,6 +67,7 @@ internal object ClientGenerator { endpoints: List, hasPolymorphicTypes: Boolean, securitySchemes: List, + specTitle: String, ): FileSpec { val className = ClassName(apiPackage, nameRegistry.register("${tag.toPascalCase()}$API_SUFFIX")) @@ -78,7 +79,7 @@ internal object ClientGenerator { } val tokenType = LambdaTypeName.get(returnType = STRING) - val authParamNames = securitySchemes.flatMap { it.paramNames } + val authParamNames = securitySchemes.flatMap { it.paramNames(specTitle) } val constructorBuilder = FunSpec .constructorBuilder() @@ -111,7 +112,7 @@ internal object ClientGenerator { .addProperty(httpClientProperty) if (securitySchemes.isNotEmpty()) { - classBuilder.addFunction(buildApplyAuth(securitySchemes)) + classBuilder.addFunction(buildApplyAuth(securitySchemes, specTitle)) } context(NameRegistry()) { @@ -124,7 +125,7 @@ internal object ClientGenerator { .build() } - private fun buildApplyAuth(securitySchemes: List): FunSpec { + private fun buildApplyAuth(securitySchemes: List, specTitle: String): FunSpec { val builder = FunSpec .builder(APPLY_AUTH) .addModifiers(KModifier.OVERRIDE, KModifier.PROTECTED) @@ -142,7 +143,7 @@ internal object ClientGenerator { if (headerSchemes.isNotEmpty()) { builder.beginControlFlow("%M", HEADERS_FUN) for (scheme in headerSchemes) { - val names = scheme.paramNames + val names = scheme.paramNames(specTitle) when (scheme) { is SecurityScheme.Bearer -> { builder.addStatement( @@ -178,7 +179,7 @@ internal object ClientGenerator { builder.beginControlFlow("url") for (scheme in querySchemes) { builder.addStatement( - "parameters.append(%S, ${scheme.paramNames.first()}())", + "parameters.append(%S, ${scheme.paramNames(specTitle).first()}())", scheme.parameterName, ) } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/AuthParam.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/AuthParam.kt index 9de9f56..5359511 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/AuthParam.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/AuthParam.kt @@ -8,15 +8,14 @@ import com.avsystem.justworks.core.model.SecurityScheme * Derives constructor parameter names for a security scheme. * * Bearer and ApiKey produce a single parameter name; Basic produces two - * (username + password). The names are scoped by both [SecurityScheme.name] - * and [SecurityScheme.specTitle] to avoid collisions across specs. + * (username + password). The names are scoped by [specTitle] to avoid + * collisions when multiple specs define schemes with the same name. */ -internal val SecurityScheme.paramNames: List - get() { - val base = "${name.toCamelCase()}${specTitle.toPascalCase()}" - return when (this) { - is SecurityScheme.Bearer -> listOf("${base}Token") - is SecurityScheme.ApiKey -> listOf(base) - is SecurityScheme.Basic -> listOf("${base}Username", "${base}Password") - } +internal fun SecurityScheme.paramNames(specTitle: String): List { + val base = "${name.toCamelCase()}${specTitle.toPascalCase()}" + return when (this) { + is SecurityScheme.Bearer -> listOf("${base}Token") + is SecurityScheme.ApiKey -> listOf(base) + is SecurityScheme.Basic -> listOf("${base}Username", "${base}Password") } +} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt b/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt index 7a2cbc8..dc5d0ef 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt @@ -9,18 +9,16 @@ package com.avsystem.justworks.core.model */ sealed interface SecurityScheme { val name: String - val specTitle: String - data class Bearer(override val name: String, override val specTitle: String) : SecurityScheme + data class Bearer(override val name: String) : SecurityScheme data class ApiKey( override val name: String, - override val specTitle: String, val parameterName: String, val location: ApiKeyLocation, ) : SecurityScheme - data class Basic(override val name: String, override val specTitle: String) : SecurityScheme + data class Basic(override val name: String) : SecurityScheme } enum class ApiKeyLocation { HEADER, QUERY } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt index 9b89964..ff5866e 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -98,7 +98,6 @@ object SpecParser { fun parseSecuritySchemes(specFile: File): ParseResult> = parseSpec(specFile, resolveFully = false) { openApi -> extractSecuritySchemes( - openApi.info?.title ?: "Untitled", openApi.components?.securitySchemes.orEmpty(), openApi.security.orEmpty(), ) @@ -159,7 +158,6 @@ object SpecParser { val title = info?.title ?: "Untitled" val securitySchemes = extractSecuritySchemes( - title, components?.securitySchemes.orEmpty(), security.orEmpty(), ) @@ -214,7 +212,6 @@ object SpecParser { context(_: Warnings) private fun extractSecuritySchemes( - specTitle: String, definitions: Map, requirements: List, ): List { @@ -222,36 +219,24 @@ object SpecParser { return referencedNames.mapNotNull { name -> ensureNotNullOrAccumulate(definitions[name]) { Issue.Warning("Security requirement references undefined scheme '$name'") - }?.toSecurityScheme(name, specTitle) + }?.toSecurityScheme(name) } } context(_: Warnings) - private fun SwaggerSecurityScheme.toSecurityScheme(name: String, specTitle: String): SecurityScheme? = when (type) { + private fun SwaggerSecurityScheme.toSecurityScheme(name: String): SecurityScheme? = when (type) { SwaggerSecurityScheme.Type.HTTP -> { when (scheme?.lowercase()) { - "bearer" -> SecurityScheme.Bearer(name, specTitle) - "basic" -> SecurityScheme.Basic(name, specTitle) + "bearer" -> SecurityScheme.Bearer(name) + "basic" -> SecurityScheme.Basic(name) else -> accumulateAndReturnNull(Issue.Warning("Unsupported HTTP auth scheme '$scheme' for '$name'")) } } SwaggerSecurityScheme.Type.APIKEY -> { when (`in`) { - SwaggerSecurityScheme.In.HEADER -> SecurityScheme.ApiKey( - name, - specTitle, - this.name, - ApiKeyLocation.HEADER, - ) - - SwaggerSecurityScheme.In.QUERY -> SecurityScheme.ApiKey( - name, - specTitle, - this.name, - ApiKeyLocation.QUERY, - ) - + SwaggerSecurityScheme.In.HEADER -> SecurityScheme.ApiKey(name, this.name, ApiKeyLocation.HEADER) + SwaggerSecurityScheme.In.QUERY -> SecurityScheme.ApiKey(name, this.name, ApiKeyLocation.QUERY) else -> accumulateAndReturnNull(Issue.Warning("Unsupported API key location '${`in`}' for '$name'")) } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt index 2702e40..8ed0179 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt @@ -665,25 +665,25 @@ class ClientGeneratorTest { fun `ApiKey HEADER scheme generates constructor with baseUrl and apiKey param`() { val cls = clientClass( listOf(endpoint()), - listOf(SecurityScheme.ApiKey("ApiKeyHeader", "", "X-API-Key", ApiKeyLocation.HEADER)), + listOf(SecurityScheme.ApiKey("ApiKeyHeader", "X-API-Key", ApiKeyLocation.HEADER)), ) val constructor = assertNotNull(cls.primaryConstructor) val paramNames = constructor.parameters.map { it.name } assertTrue("baseUrl" in paramNames, "Expected baseUrl param") - assertTrue("apiKeyHeader" in paramNames, "Expected apiKeyHeader param") + assertTrue("apiKeyHeaderTest" in paramNames, "Expected apiKeyHeaderTest param") } @Test fun `Basic scheme generates constructor with baseUrl, username, and password`() { val cls = clientClass( listOf(endpoint()), - listOf(SecurityScheme.Basic("BasicAuth", "")), + listOf(SecurityScheme.Basic("BasicAuth")), ) val constructor = assertNotNull(cls.primaryConstructor) val paramNames = constructor.parameters.map { it.name } assertTrue("baseUrl" in paramNames, "Expected baseUrl param") - assertTrue("basicAuthUsername" in paramNames, "Expected basicAuthUsername param") - assertTrue("basicAuthPassword" in paramNames, "Expected basicAuthPassword param") + assertTrue("basicAuthTestUsername" in paramNames, "Expected basicAuthTestUsername param") + assertTrue("basicAuthTestPassword" in paramNames, "Expected basicAuthTestPassword param") } @Test @@ -691,15 +691,15 @@ class ClientGeneratorTest { val cls = clientClass( listOf(endpoint()), listOf( - SecurityScheme.Bearer("BearerAuth", ""), - SecurityScheme.ApiKey("ApiKeyHeader", "", "X-API-Key", ApiKeyLocation.HEADER), + SecurityScheme.Bearer("BearerAuth"), + SecurityScheme.ApiKey("ApiKeyHeader", "X-API-Key", ApiKeyLocation.HEADER), ), ) val constructor = assertNotNull(cls.primaryConstructor) val paramNames = constructor.parameters.map { it.name } assertTrue("baseUrl" in paramNames, "Expected baseUrl param") - assertTrue("bearerAuthToken" in paramNames, "Expected bearerAuthToken param") - assertTrue("apiKeyHeader" in paramNames, "Expected apiKeyHeader param") + assertTrue("bearerAuthTestToken" in paramNames, "Expected bearerAuthTestToken param") + assertTrue("apiKeyHeaderTest" in paramNames, "Expected apiKeyHeaderTest param") // Verify only baseUrl is passed to super (auth is handled per-client, not in ApiClientBase) val superParams = cls.superclassConstructorParameters.map { it.toString().trim() } @@ -736,11 +736,11 @@ class ClientGeneratorTest { fun `single Bearer scheme generates named token param`() { val cls = clientClass( listOf(endpoint()), - listOf(SecurityScheme.Bearer("BearerAuth", "")), + listOf(SecurityScheme.Bearer("BearerAuth")), ) val constructor = assertNotNull(cls.primaryConstructor) val paramNames = constructor.parameters.map { it.name } - assertTrue("bearerAuthToken" in paramNames, "Expected bearerAuthToken param") + assertTrue("bearerAuthTestToken" in paramNames, "Expected bearerAuthTestToken param") } // -- DOCS-03: Endpoint KDoc generation -- From f1e76687c5e786f223e7d31cf74b7e0f4ffc03f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 9 Apr 2026 15:48:51 +0200 Subject: [PATCH 13/23] fix: update README for per-client auth, remove dead code, add sanitization tests - Update security schemes docs to reflect specTitle scoping and per-client applyAuth() override (not in ApiClientBase) - Remove unused SpecParser.parseSecuritySchemes() method - Add toPascalCase tests for special character stripping - Cache paramNames() call in query scheme loop Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 40 ++++++++++--------- .../core/gen/client/ClientGenerator.kt | 3 +- .../justworks/core/parser/SpecParser.kt | 14 ------- .../justworks/core/gen/NameUtilsTest.kt | 10 +++++ 4 files changed, 34 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index d4cc7cc..2d78f60 100644 --- a/README.md +++ b/README.md @@ -116,17 +116,21 @@ A `SerializersModule` is auto-generated when discriminated polymorphic types are 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. -| Scheme type | Location | Generated constructor parameter(s) | -|-------------|----------|----------------------------------------------------------------| -| HTTP Bearer | Header | `token: () -> String` (or `{name}Token` if multiple) | -| HTTP Basic | Header | `{name}Username: () -> String`, `{name}Password: () -> String` | -| API Key | Header | `{name}Key: () -> String` | -| API Key | Query | `{name}Key: () -> String` | +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. -The generated `ApiClientBase` contains an `applyAuth()` method that applies all credentials to each request: +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 @@ -149,7 +153,7 @@ registered spec). build/generated/justworks/ ├── shared/kotlin/ │ └── com/avsystem/justworks/ -│ ├── ApiClientBase.kt # Abstract base class + auth handling + helper extensions +│ ├── ApiClientBase.kt # Abstract base class + helper extensions │ ├── HttpError.kt # HttpErrorType enum + HttpError data class │ └── HttpSuccess.kt # HttpSuccess data class │ @@ -263,42 +267,42 @@ You only need to provide the base URL and authentication credentials (if the spe Class names are derived from OpenAPI tags as `Api` (e.g., a `pets` tag produces `PetsApi`). Untagged endpoints go to `DefaultApi`. -**Single Bearer token** (most common case): +**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. This lets you supply a provider that refreshes +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** -- constructor parameters are derived from the scheme names defined in the spec: +**Multiple security schemes** -- parameters are scoped by scheme name and spec title: ```kotlin val client = PetsApi( baseUrl = "https://api.example.com", - bearerToken = { tokenStore.getAccessToken() }, - internalApiKey = { secrets.getApiKey() }, + bearerAuthPetstoreToken = { tokenStore.getAccessToken() }, + internalApiKeyPetstore = { secrets.getApiKey() }, ) ``` -**Basic auth**: +**Basic auth** (scheme name "BasicAuth"): ```kotlin val client = PetsApi( baseUrl = "https://api.example.com", - basicUsername = { "user" }, - basicPassword = { "pass" }, + basicAuthPetstoreUsername = { "user" }, + basicAuthPetstorePassword = { "pass" }, ) ``` diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt index 9355c82..b685527 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt @@ -178,8 +178,9 @@ internal object ClientGenerator { if (querySchemes.isNotEmpty()) { builder.beginControlFlow("url") for (scheme in querySchemes) { + val paramName = scheme.paramNames(specTitle).first() builder.addStatement( - "parameters.append(%S, ${scheme.paramNames(specTitle).first()}())", + "parameters.append(%S, $paramName())", scheme.parameterName, ) } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt index ff5866e..fa46a59 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -89,20 +89,6 @@ object SpecParser { openApi.toApiSpec() } - /** - * Lightweight extraction of security schemes from an OpenAPI spec file. - * - * Parses only the `components/securitySchemes` and `security` sections, - * skipping the expensive endpoint and schema extraction performed by [parse]. - */ - fun parseSecuritySchemes(specFile: File): ParseResult> = - parseSpec(specFile, resolveFully = false) { openApi -> - extractSecuritySchemes( - openApi.components?.securitySchemes.orEmpty(), - openApi.security.orEmpty(), - ) - } - @OptIn(ExperimentalRaiseAccumulateApi::class) private inline fun parseSpec( specFile: File, diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/NameUtilsTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/NameUtilsTest.kt index 5b1cb1a..0b49050 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/NameUtilsTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/NameUtilsTest.kt @@ -91,6 +91,16 @@ class NameUtilsTest { assertEquals("GetUrlMapping", "getURLMapping".toPascalCase()) } + @Test + fun `toPascalCase strips non-alphanumeric characters`() { + assertEquals("PaymentsApiV2", "Payments API (v2)".toPascalCase()) + } + + @Test + fun `toPascalCase handles brackets and special chars`() { + assertEquals("MyApi", "My @API!".toPascalCase()) + } + // -- toEnumConstantName -- @Test From 4c9e6216f1d136e3271c71ff351c0958587a6941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 9 Apr 2026 16:08:01 +0200 Subject: [PATCH 14/23] =?UTF-8?q?fix:=20review=20fixes=20=E2=80=94=20UTF-8?= =?UTF-8?q?=20Basic=20auth=20encoding,=20applyAuth=20tests,=20accumulate?= =?UTF-8?q?=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Explicit Charsets.UTF_8 in generated Basic auth toByteArray() per RFC 7617 - Add applyAuth body assertions for all 4 scheme types in ClientGeneratorTest - Add accumulate() Unit helper for side-effect-only warning accumulation - Pre-compute paramNames() once in buildApplyAuth instead of per-loop Co-Authored-By: Claude Opus 4.6 (1M context) --- .../avsystem/justworks/core/ArrowHelpers.kt | 8 ++- .../core/gen/client/ClientGenerator.kt | 21 +++---- .../justworks/core/parser/SpecParser.kt | 3 +- .../justworks/core/gen/ClientGeneratorTest.kt | 55 +++++++++++++++++++ 4 files changed, 75 insertions(+), 12 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/ArrowHelpers.kt b/core/src/main/kotlin/com/avsystem/justworks/core/ArrowHelpers.kt index f238bc1..5c65781 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/ArrowHelpers.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/ArrowHelpers.kt @@ -26,9 +26,15 @@ inline fun ensureNotNullOrAccumulate(value: B?, error: () -> Er return value } +/** Accumulates a single error as a side effect, for use outside of expression context. */ +context(iorRaise: IorRaise>) +fun 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>) fun accumulateAndReturnNull(error: Error): Nothing? { - iorRaise.accumulate(nonEmptyListOf(error)) + accumulate(error) return null } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt index b685527..6bf65ef 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt @@ -131,19 +131,20 @@ internal object ClientGenerator { .addModifiers(KModifier.OVERRIDE, KModifier.PROTECTED) .receiver(HTTP_REQUEST_BUILDER) - val headerSchemes = securitySchemes.filter { scheme -> + val schemesWithNames = securitySchemes.map { it to it.paramNames(specTitle) } + + val headerSchemes = schemesWithNames.filter { (scheme, _) -> scheme is SecurityScheme.Bearer || scheme is SecurityScheme.Basic || (scheme is SecurityScheme.ApiKey && scheme.location == ApiKeyLocation.HEADER) } - val querySchemes = securitySchemes - .filterIsInstance() - .filter { it.location == ApiKeyLocation.QUERY } + val querySchemes = schemesWithNames.filter { (scheme, _) -> + scheme is SecurityScheme.ApiKey && scheme.location == ApiKeyLocation.QUERY + } if (headerSchemes.isNotEmpty()) { builder.beginControlFlow("%M", HEADERS_FUN) - for (scheme in headerSchemes) { - val names = scheme.paramNames(specTitle) + for ((scheme, names) in headerSchemes) { when (scheme) { is SecurityScheme.Bearer -> { builder.addStatement( @@ -158,7 +159,7 @@ internal object ClientGenerator { "append(%T.Authorization, %P)", HTTP_HEADERS, CodeBlock.of( - $$"Basic ${%T.getEncoder().encodeToString(\"${$${names[0]}()}:${$${names[1]}()}\".toByteArray())}", + $$"Basic ${%T.getEncoder().encodeToString(\"${$${names[0]}()}:${$${names[1]}()}\".toByteArray(Charsets.UTF_8))}", BASE64_CLASS, ), ) @@ -177,10 +178,10 @@ internal object ClientGenerator { if (querySchemes.isNotEmpty()) { builder.beginControlFlow("url") - for (scheme in querySchemes) { - val paramName = scheme.paramNames(specTitle).first() + for ((scheme, names) in querySchemes) { + scheme as SecurityScheme.ApiKey builder.addStatement( - "parameters.append(%S, $paramName())", + "parameters.append(%S, ${names.first()}())", scheme.parameterName, ) } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt index fa46a59..c45053f 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -13,6 +13,7 @@ import arrow.core.raise.nullable import com.avsystem.justworks.core.Issue import com.avsystem.justworks.core.SCHEMA_PREFIX import com.avsystem.justworks.core.Warnings +import com.avsystem.justworks.core.accumulate import com.avsystem.justworks.core.accumulateAndReturnNull import com.avsystem.justworks.core.ensureNotNullOrAccumulate import com.avsystem.justworks.core.model.ApiKeyLocation @@ -130,7 +131,7 @@ object SpecParser { val swaggerResult = OpenAPIParser().readLocation(specFile.absolutePath, null, parseOptions) - swaggerResult?.messages?.forEach { accumulateAndReturnNull(Issue.Warning(it)) } + swaggerResult?.messages?.forEach { accumulate(Issue.Warning(it)) } return swaggerResult?.openAPI } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt index 8ed0179..e844bd4 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt @@ -743,6 +743,61 @@ class ClientGeneratorTest { assertTrue("bearerAuthTestToken" in paramNames, "Expected bearerAuthTestToken param") } + // -- SECU: applyAuth body assertions -- + + @Test + fun `Bearer scheme applyAuth contains Authorization header with Bearer prefix`() { + val cls = clientClass( + listOf(endpoint()), + listOf(SecurityScheme.Bearer("BearerAuth")), + ) + val applyAuth = cls.funSpecs.first { it.name == "applyAuth" } + val body = applyAuth.body.toString() + assertTrue(body.contains("Authorization"), "Expected Authorization header") + assertTrue(body.contains("Bearer"), "Expected Bearer prefix") + assertTrue(body.contains("bearerAuthTestToken()"), "Expected bearerAuthTestToken() invocation") + } + + @Test + fun `Basic scheme applyAuth contains Authorization header with Base64 encoding`() { + val cls = clientClass( + listOf(endpoint()), + listOf(SecurityScheme.Basic("BasicAuth")), + ) + val applyAuth = cls.funSpecs.first { it.name == "applyAuth" } + val body = applyAuth.body.toString() + assertTrue(body.contains("Authorization"), "Expected Authorization header") + assertTrue(body.contains("Basic"), "Expected Basic prefix") + assertTrue(body.contains("Base64"), "Expected Base64 encoding") + assertTrue(body.contains("basicAuthTestUsername()"), "Expected username invocation") + assertTrue(body.contains("basicAuthTestPassword()"), "Expected password invocation") + } + + @Test + fun `ApiKey HEADER scheme applyAuth appends header with spec parameter name`() { + val cls = clientClass( + listOf(endpoint()), + listOf(SecurityScheme.ApiKey("ApiKeyHeader", "X-API-Key", ApiKeyLocation.HEADER)), + ) + val applyAuth = cls.funSpecs.first { it.name == "applyAuth" } + val body = applyAuth.body.toString() + assertTrue(body.contains("X-API-Key"), "Expected X-API-Key header name") + assertTrue(body.contains("apiKeyHeaderTest()"), "Expected apiKeyHeaderTest() invocation") + } + + @Test + fun `ApiKey QUERY scheme applyAuth appends query parameter`() { + val cls = clientClass( + listOf(endpoint()), + listOf(SecurityScheme.ApiKey("ApiKeyQuery", "api_key", ApiKeyLocation.QUERY)), + ) + val applyAuth = cls.funSpecs.first { it.name == "applyAuth" } + val body = applyAuth.body.toString() + assertTrue(body.contains("parameters.append"), "Expected query parameters.append call") + assertTrue(body.contains("api_key"), "Expected api_key parameter name") + assertTrue(body.contains("apiKeyQueryTest()"), "Expected apiKeyQueryTest() invocation") + } + // -- DOCS-03: Endpoint KDoc generation -- @Test From 13900da7761ae229fe76c599e5fa9e9db525f4af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 9 Apr 2026 16:22:55 +0200 Subject: [PATCH 15/23] refactor(core): replace `paramNames` with `toAuthParam` for security scheme handling in `ClientGenerator` - Introduce `AuthParam` sealed interface to represent auth parameters. - Update `ClientGenerator` to use `toAuthParam`, simplifying authentication handling. - Refactor header and query parameter generation to leverage `AuthParam` types instead of raw strings. --- .../core/gen/client/ClientGenerator.kt | 29 +++++++++++++------ .../justworks/core/gen/shared/AuthParam.kt | 24 ++++++++++++--- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt index 6bf65ef..6586dfd 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt @@ -20,7 +20,8 @@ import com.avsystem.justworks.core.gen.client.ParametersGenerator.buildBodyParam import com.avsystem.justworks.core.gen.client.ParametersGenerator.buildNullableParameter import com.avsystem.justworks.core.gen.invoke import com.avsystem.justworks.core.gen.sanitizeKdoc -import com.avsystem.justworks.core.gen.shared.paramNames +import com.avsystem.justworks.core.gen.shared.AuthParam +import com.avsystem.justworks.core.gen.shared.toAuthParam import com.avsystem.justworks.core.gen.toCamelCase import com.avsystem.justworks.core.gen.toPascalCase import com.avsystem.justworks.core.gen.toTypeName @@ -79,7 +80,13 @@ internal object ClientGenerator { } val tokenType = LambdaTypeName.get(returnType = STRING) - val authParamNames = securitySchemes.flatMap { it.paramNames(specTitle) } + val authParamNames = securitySchemes.flatMap { + when (val toAuthParam = it.toAuthParam(specTitle)) { + is AuthParam.Bearer -> listOf(toAuthParam.name) + is AuthParam.ApiKey -> listOf(toAuthParam.name) + is AuthParam.Basic -> listOf(toAuthParam.username, toAuthParam.password) + } + } val constructorBuilder = FunSpec .constructorBuilder() @@ -131,7 +138,7 @@ internal object ClientGenerator { .addModifiers(KModifier.OVERRIDE, KModifier.PROTECTED) .receiver(HTTP_REQUEST_BUILDER) - val schemesWithNames = securitySchemes.map { it to it.paramNames(specTitle) } + val schemesWithNames = securitySchemes.map { it to it.toAuthParam(specTitle) } val headerSchemes = schemesWithNames.filter { (scheme, _) -> scheme is SecurityScheme.Bearer || @@ -144,30 +151,33 @@ internal object ClientGenerator { if (headerSchemes.isNotEmpty()) { builder.beginControlFlow("%M", HEADERS_FUN) - for ((scheme, names) in headerSchemes) { + for ((scheme, authParam) in headerSchemes) { when (scheme) { is SecurityScheme.Bearer -> { + authParam as AuthParam.Bearer builder.addStatement( "append(%T.Authorization, %P)", HTTP_HEADERS, - CodeBlock.of($$"Bearer ${$${names.first()}()}"), + CodeBlock.of($$"Bearer ${$${authParam.name}()}"), ) } is SecurityScheme.Basic -> { + authParam as AuthParam.Basic builder.addStatement( "append(%T.Authorization, %P)", HTTP_HEADERS, CodeBlock.of( - $$"Basic ${%T.getEncoder().encodeToString(\"${$${names[0]}()}:${$${names[1]}()}\".toByteArray(Charsets.UTF_8))}", + $$"Basic ${%T.getEncoder().encodeToString(\"${$${authParam.username}()}:${$${authParam.password}()}\".toByteArray(Charsets.UTF_8))}", BASE64_CLASS, ), ) } is SecurityScheme.ApiKey -> { + authParam as AuthParam.ApiKey builder.addStatement( - "append(%S, ${names.first()}())", + "append(%S, ${authParam.name}())", scheme.parameterName, ) } @@ -178,10 +188,11 @@ internal object ClientGenerator { if (querySchemes.isNotEmpty()) { builder.beginControlFlow("url") - for ((scheme, names) in querySchemes) { + for ((scheme, authParam) in querySchemes) { scheme as SecurityScheme.ApiKey + authParam as AuthParam.ApiKey builder.addStatement( - "parameters.append(%S, ${names.first()}())", + "parameters.append(%S, ${authParam.name}())", scheme.parameterName, ) } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/AuthParam.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/AuthParam.kt index 5359511..52604cd 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/AuthParam.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/AuthParam.kt @@ -11,11 +11,27 @@ import com.avsystem.justworks.core.model.SecurityScheme * (username + password). The names are scoped by [specTitle] to avoid * collisions when multiple specs define schemes with the same name. */ -internal fun SecurityScheme.paramNames(specTitle: String): List { +internal fun SecurityScheme.toAuthParam(specTitle: String): AuthParam { val base = "${name.toCamelCase()}${specTitle.toPascalCase()}" return when (this) { - is SecurityScheme.Bearer -> listOf("${base}Token") - is SecurityScheme.ApiKey -> listOf(base) - is SecurityScheme.Basic -> listOf("${base}Username", "${base}Password") + is SecurityScheme.Bearer -> AuthParam.Bearer(base) + is SecurityScheme.ApiKey -> AuthParam.ApiKey(base) + is SecurityScheme.Basic -> AuthParam.Basic(base) + } +} + +sealed interface AuthParam { + data class Basic(private val base: String) : AuthParam { + private val formattedBase = base.toCamelCase() + val username: String = formattedBase + "Username" + val password: String = base.toPascalCase() + } + + data class Bearer(private val base: String) : AuthParam { + val name = base.toCamelCase() + "Bearer" + } + + data class ApiKey(private val base: String) : AuthParam { + val name = base.toCamelCase() + "ApiKey" } } From f454fa04c541c64d3fec481844664790705952f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 9 Apr 2026 16:50:50 +0200 Subject: [PATCH 16/23] refactor(core): streamline `toAuthParam` usage and refactor `ClientGenerator` security scheme handling - Separate `toAuthParam` methods for each security scheme type, improving readability. - Refactor header and query param logic to directly use security schemes without intermediate mappings. - Rename `schemaModelsScope` to `memoScope` for clarity in `Hierarchy` logic. --- .../avsystem/justworks/core/gen/Hierarchy.kt | 16 +++---- .../core/gen/client/ClientGenerator.kt | 33 +++++++------- .../justworks/core/gen/shared/AuthParam.kt | 44 +++++++++---------- 3 files changed, 44 insertions(+), 49 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt index 19b2e22..b03f363 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt @@ -8,7 +8,7 @@ import com.squareup.kotlinpoet.ClassName internal class Hierarchy(val modelPackage: ModelPackage) { private val schemaModels = mutableSetOf() - private val schemaModelsScope = MemoScope() + private val memoScope = MemoScope() /** * Updates the underlying schemas and invalidates all cached derived views. @@ -16,21 +16,21 @@ internal class Hierarchy(val modelPackage: ModelPackage) { */ fun addSchemas(newSchemas: List) { schemaModels += newSchemas - schemaModelsScope.reset() + memoScope.reset() } /** All schemas indexed by name for quick lookup. */ - val schemasById: Map by memoized(schemaModelsScope) { + val schemasById: Map by memoized(memoScope) { schemaModels.associateBy { it.name } } /** Schemas that define polymorphic variants via oneOf or anyOf. */ - private val polymorphicSchemas: List by memoized(schemaModelsScope) { + private val polymorphicSchemas: List by memoized(memoScope) { schemaModels.filterNot { it.variants().isNullOrEmpty() } } /** Maps parent schema name to its variant schema names (for both oneOf and anyOf). */ - val sealedHierarchies: Map> by memoized(schemaModelsScope) { + val sealedHierarchies: Map> by memoized(memoScope) { polymorphicSchemas .associate { schema -> schema.name to schema @@ -42,7 +42,7 @@ internal class Hierarchy(val modelPackage: ModelPackage) { } /** Parent schema names that use anyOf without a discriminator (JsonContentPolymorphicSerializer pattern). */ - val anyOfWithoutDiscriminator: Set by memoized(schemaModelsScope) { + val anyOfWithoutDiscriminator: Set by memoized(memoScope) { polymorphicSchemas .asSequence() .filter { !it.anyOf.isNullOrEmpty() && it.discriminator == null } @@ -51,7 +51,7 @@ internal class Hierarchy(val modelPackage: ModelPackage) { } /** Inverse of [sealedHierarchies] for anyOf-without-discriminator: variant name to its parent names. */ - val anyOfParents: Map> by memoized(schemaModelsScope) { + val anyOfParents: Map> by memoized(memoScope) { sealedHierarchies .asSequence() .filter { (parent, _) -> parent in anyOfWithoutDiscriminator } @@ -61,7 +61,7 @@ internal class Hierarchy(val modelPackage: ModelPackage) { } /** Maps schema name to its [ClassName], using nested class for discriminated hierarchy variants. */ - private val lookup: Map by memoized(schemaModelsScope) { + private val lookup: Map by memoized(memoScope) { sealedHierarchies .asSequence() .filterNot { (parent, _) -> parent in anyOfWithoutDiscriminator } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt index 6586dfd..5193f6b 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt @@ -80,11 +80,11 @@ internal object ClientGenerator { } val tokenType = LambdaTypeName.get(returnType = STRING) - val authParamNames = securitySchemes.flatMap { - when (val toAuthParam = it.toAuthParam(specTitle)) { - is AuthParam.Bearer -> listOf(toAuthParam.name) - is AuthParam.ApiKey -> listOf(toAuthParam.name) - is AuthParam.Basic -> listOf(toAuthParam.username, toAuthParam.password) + val authParamNames = securitySchemes.flatMap { scheme -> + when (scheme) { + is SecurityScheme.Bearer -> listOf(scheme.toAuthParam(specTitle).name) + is SecurityScheme.ApiKey -> listOf(scheme.toAuthParam(specTitle).name) + is SecurityScheme.Basic -> scheme.toAuthParam(specTitle).let { listOf(it.username, it.password) } } } @@ -138,23 +138,21 @@ internal object ClientGenerator { .addModifiers(KModifier.OVERRIDE, KModifier.PROTECTED) .receiver(HTTP_REQUEST_BUILDER) - val schemesWithNames = securitySchemes.map { it to it.toAuthParam(specTitle) } - - val headerSchemes = schemesWithNames.filter { (scheme, _) -> + val headerSchemes = securitySchemes.filter { scheme -> scheme is SecurityScheme.Bearer || scheme is SecurityScheme.Basic || (scheme is SecurityScheme.ApiKey && scheme.location == ApiKeyLocation.HEADER) } - val querySchemes = schemesWithNames.filter { (scheme, _) -> - scheme is SecurityScheme.ApiKey && scheme.location == ApiKeyLocation.QUERY - } + val querySchemes = securitySchemes + .filterIsInstance() + .filter { scheme -> scheme.location == ApiKeyLocation.QUERY } if (headerSchemes.isNotEmpty()) { builder.beginControlFlow("%M", HEADERS_FUN) - for ((scheme, authParam) in headerSchemes) { + for (scheme in headerSchemes) { when (scheme) { is SecurityScheme.Bearer -> { - authParam as AuthParam.Bearer + val authParam = scheme.toAuthParam(specTitle) builder.addStatement( "append(%T.Authorization, %P)", HTTP_HEADERS, @@ -163,7 +161,7 @@ internal object ClientGenerator { } is SecurityScheme.Basic -> { - authParam as AuthParam.Basic + val authParam = scheme.toAuthParam(specTitle) builder.addStatement( "append(%T.Authorization, %P)", HTTP_HEADERS, @@ -175,7 +173,7 @@ internal object ClientGenerator { } is SecurityScheme.ApiKey -> { - authParam as AuthParam.ApiKey + val authParam = scheme.toAuthParam(specTitle) builder.addStatement( "append(%S, ${authParam.name}())", scheme.parameterName, @@ -188,9 +186,8 @@ internal object ClientGenerator { if (querySchemes.isNotEmpty()) { builder.beginControlFlow("url") - for ((scheme, authParam) in querySchemes) { - scheme as SecurityScheme.ApiKey - authParam as AuthParam.ApiKey + for (scheme in querySchemes) { + val authParam = scheme.toAuthParam(specTitle) builder.addStatement( "parameters.append(%S, ${authParam.name}())", scheme.parameterName, diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/AuthParam.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/AuthParam.kt index 52604cd..e8a9bdd 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/AuthParam.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/AuthParam.kt @@ -4,34 +4,32 @@ import com.avsystem.justworks.core.gen.toCamelCase import com.avsystem.justworks.core.gen.toPascalCase import com.avsystem.justworks.core.model.SecurityScheme -/** - * Derives constructor parameter names for a security scheme. - * - * Bearer and ApiKey produce a single parameter name; Basic produces two - * (username + password). The names are scoped by [specTitle] to avoid - * collisions when multiple specs define schemes with the same name. - */ -internal fun SecurityScheme.toAuthParam(specTitle: String): AuthParam { - val base = "${name.toCamelCase()}${specTitle.toPascalCase()}" - return when (this) { - is SecurityScheme.Bearer -> AuthParam.Bearer(base) - is SecurityScheme.ApiKey -> AuthParam.ApiKey(base) - is SecurityScheme.Basic -> AuthParam.Basic(base) - } -} +internal fun SecurityScheme.Bearer.toAuthParam(specTitle: String) = AuthParam.Bearer(name, specTitle) + +internal fun SecurityScheme.ApiKey.toAuthParam(specTitle: String) = AuthParam.ApiKey(name, specTitle) + +internal fun SecurityScheme.Basic.toAuthParam(specTitle: String) = AuthParam.Basic(name, specTitle) sealed interface AuthParam { - data class Basic(private val base: String) : AuthParam { - private val formattedBase = base.toCamelCase() - val username: String = formattedBase + "Username" - val password: String = base.toPascalCase() + @ConsistentCopyVisibility + data class Basic private constructor(val username: String, val password: String) : AuthParam { + companion object { + operator fun invoke(base: String, specTitle: String): Basic { + val formattedBase = formatBase(base, specTitle) + return Basic(formattedBase + "Username", formattedBase + "Password") + } + } } - data class Bearer(private val base: String) : AuthParam { - val name = base.toCamelCase() + "Bearer" + @ConsistentCopyVisibility + data class Bearer private constructor(val name: String) : AuthParam { + constructor(base: String, specTitle: String) : this("${formatBase(base, specTitle)}Bearer") } - data class ApiKey(private val base: String) : AuthParam { - val name = base.toCamelCase() + "ApiKey" + @ConsistentCopyVisibility + data class ApiKey private constructor(val name: String) : AuthParam { + constructor(base: String, specTitle: String) : this("${formatBase(base, specTitle)}ApiKey") } } + +private fun formatBase(base: String, specTitle: String) = base.toCamelCase() + specTitle.toPascalCase() From 55ddfb079ae96a9c6c3231dbb4527e80af7eaeed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 9 Apr 2026 16:52:22 +0200 Subject: [PATCH 17/23] fmt --- .../com/avsystem/justworks/core/gen/client/ClientGenerator.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt index 5193f6b..c00c8cc 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt @@ -20,7 +20,6 @@ import com.avsystem.justworks.core.gen.client.ParametersGenerator.buildBodyParam import com.avsystem.justworks.core.gen.client.ParametersGenerator.buildNullableParameter import com.avsystem.justworks.core.gen.invoke import com.avsystem.justworks.core.gen.sanitizeKdoc -import com.avsystem.justworks.core.gen.shared.AuthParam import com.avsystem.justworks.core.gen.shared.toAuthParam import com.avsystem.justworks.core.gen.toCamelCase import com.avsystem.justworks.core.gen.toPascalCase From dea811b0b04c440ef06876b82882492cfa04d3ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 9 Apr 2026 16:56:49 +0200 Subject: [PATCH 18/23] refactor(core): update `AuthParam` constructors for consistent token naming and formatting - Rename `Bearer` suffix to `Token` for improved clarity. - Simplify `ApiKey` constructor by removing redundant suffix. --- .../com/avsystem/justworks/core/gen/shared/AuthParam.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/AuthParam.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/AuthParam.kt index e8a9bdd..4d4f63b 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/AuthParam.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/AuthParam.kt @@ -23,12 +23,12 @@ sealed interface AuthParam { @ConsistentCopyVisibility data class Bearer private constructor(val name: String) : AuthParam { - constructor(base: String, specTitle: String) : this("${formatBase(base, specTitle)}Bearer") + constructor(base: String, specTitle: String) : this("${formatBase(base, specTitle)}Token") } @ConsistentCopyVisibility data class ApiKey private constructor(val name: String) : AuthParam { - constructor(base: String, specTitle: String) : this("${formatBase(base, specTitle)}ApiKey") + constructor(base: String, specTitle: String) : this(formatBase(base, specTitle)) } } From 51ce217595fff763f457107c5365bb5734c5829c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 9 Apr 2026 17:53:05 +0200 Subject: [PATCH 19/23] refactor(core): update `ApiClientBase` and `ClientGenerator` to support global token param and Bearer auth inheritance - Add `token` lambda as a constructor parameter in `ApiClientBase`. - Update `applyAuth` to include Bearer token logic in `ApiClientBase`. - Simplify client-specific `applyAuth` for single Bearer scheme to rely on base class. - Refactor `ClientGenerator` to handle token inheritance for shared authentication logic. --- .../com/avsystem/justworks/core/gen/Names.kt | 1 + .../core/gen/client/ClientGenerator.kt | 50 ++++++++++++------- .../core/gen/shared/ApiClientBaseGenerator.kt | 47 +++++++++++------ .../core/gen/ApiClientBaseGeneratorTest.kt | 13 +++-- .../justworks/core/gen/ClientGeneratorTest.kt | 22 ++++---- .../gradle/JustworksPluginFunctionalTest.kt | 18 +++---- 6 files changed, 95 insertions(+), 56 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt index 199a23f..7ab14cd 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt @@ -117,6 +117,7 @@ val UUID_SERIALIZER = ClassName("com.avsystem.justworks", "UuidSerializer") // ============================================================================ const val BASE_URL = "baseUrl" +const val TOKEN = "token" const val CLIENT = "client" const val BODY = "body" const val APPLY_AUTH = "applyAuth" diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt index c00c8cc..c89b023 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt @@ -6,6 +6,7 @@ import com.avsystem.justworks.core.gen.ApiPackage import com.avsystem.justworks.core.gen.BASE64_CLASS import com.avsystem.justworks.core.gen.BASE_URL import com.avsystem.justworks.core.gen.CLIENT +import com.avsystem.justworks.core.gen.TOKEN import com.avsystem.justworks.core.gen.CREATE_HTTP_CLIENT import com.avsystem.justworks.core.gen.GENERATED_SERIALIZERS_MODULE import com.avsystem.justworks.core.gen.HEADERS_FUN @@ -79,13 +80,8 @@ internal object ClientGenerator { } val tokenType = LambdaTypeName.get(returnType = STRING) - val authParamNames = securitySchemes.flatMap { scheme -> - when (scheme) { - is SecurityScheme.Bearer -> listOf(scheme.toAuthParam(specTitle).name) - is SecurityScheme.ApiKey -> listOf(scheme.toAuthParam(specTitle).name) - is SecurityScheme.Basic -> scheme.toAuthParam(specTitle).let { listOf(it.username, it.password) } - } - } + val isSingleBearer = securitySchemes.singleOrNull() is SecurityScheme.Bearer + val needsAuthOverride = securitySchemes.isNotEmpty() && !isSingleBearer val constructorBuilder = FunSpec .constructorBuilder() @@ -96,15 +92,35 @@ internal object ClientGenerator { .superclass(API_CLIENT_BASE) .addSuperclassConstructorParameter(BASE_URL) - for (paramName in authParamNames) { - constructorBuilder.addParameter(paramName, tokenType) - classBuilder.addProperty( - PropertySpec - .builder(paramName, tokenType) - .initializer(paramName) - .addModifiers(KModifier.PRIVATE) - .build(), - ) + if (isSingleBearer) { + // Single Bearer: use plain token param, pass to super (base class handles Bearer) + constructorBuilder.addParameter(TOKEN, tokenType) + classBuilder.addSuperclassConstructorParameter(TOKEN) + } else if (securitySchemes.isNotEmpty()) { + // Multiple or non-Bearer schemes: generate named auth params, override applyAuth + val authParamNames = securitySchemes.flatMap { scheme -> + when (scheme) { + is SecurityScheme.Bearer -> listOf(scheme.toAuthParam(specTitle).name) + is SecurityScheme.ApiKey -> listOf(scheme.toAuthParam(specTitle).name) + is SecurityScheme.Basic -> scheme.toAuthParam(specTitle).let { listOf(it.username, it.password) } + } + } + + for (paramName in authParamNames) { + constructorBuilder.addParameter(paramName, tokenType) + classBuilder.addProperty( + PropertySpec + .builder(paramName, tokenType) + .initializer(paramName) + .addModifiers(KModifier.PRIVATE) + .build(), + ) + } + + classBuilder.addSuperclassConstructorParameter("{ %S }", "") + } else { + // No security schemes: no auth params, pass no-op token to super + classBuilder.addSuperclassConstructorParameter("{ %S }", "") } val httpClientProperty = PropertySpec @@ -117,7 +133,7 @@ internal object ClientGenerator { .primaryConstructor(constructorBuilder.build()) .addProperty(httpClientProperty) - if (securitySchemes.isNotEmpty()) { + if (needsAuthOverride) { classBuilder.addFunction(buildApplyAuth(securitySchemes, specTitle)) } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt index 337af32..7cb6b7d 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt @@ -11,9 +11,11 @@ import com.avsystem.justworks.core.gen.CONTENT_NEGOTIATION import com.avsystem.justworks.core.gen.CREATE_HTTP_CLIENT import com.avsystem.justworks.core.gen.ENCODE_PARAM_FUN import com.avsystem.justworks.core.gen.ENCODE_TO_STRING_FUN +import com.avsystem.justworks.core.gen.HEADERS_FUN import com.avsystem.justworks.core.gen.HTTP_CLIENT import com.avsystem.justworks.core.gen.HTTP_ERROR import com.avsystem.justworks.core.gen.HTTP_ERROR_TYPE +import com.avsystem.justworks.core.gen.HTTP_HEADERS import com.avsystem.justworks.core.gen.HTTP_REQUEST_BUILDER import com.avsystem.justworks.core.gen.HTTP_REQUEST_TIMEOUT_EXCEPTION import com.avsystem.justworks.core.gen.HTTP_RESPONSE @@ -23,6 +25,8 @@ import com.avsystem.justworks.core.gen.JSON_CLASS import com.avsystem.justworks.core.gen.JSON_FUN import com.avsystem.justworks.core.gen.SAFE_CALL import com.avsystem.justworks.core.gen.SERIALIZERS_MODULE +import com.avsystem.justworks.core.gen.TOKEN +import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.KModifier @@ -118,14 +122,11 @@ internal object ApiClientBaseGenerator { .build() private fun buildApiClientBaseClass(): TypeSpec { - val constructorBuilder = FunSpec + val constructor = FunSpec .constructorBuilder() .addParameter(BASE_URL, STRING) - - val classBuilder = TypeSpec - .classBuilder(API_CLIENT_BASE) - .addModifiers(KModifier.ABSTRACT) - .addSuperinterface(CLOSEABLE) + .addParameter(TOKEN, LambdaTypeName.get(returnType = STRING)) + .build() val baseUrlProp = PropertySpec .builder(BASE_URL, STRING) @@ -133,6 +134,12 @@ internal object ApiClientBaseGenerator { .addModifiers(KModifier.PROTECTED) .build() + val tokenProp = PropertySpec + .builder(TOKEN, LambdaTypeName.get(returnType = STRING)) + .initializer(TOKEN) + .addModifiers(KModifier.PRIVATE) + .build() + val clientProp = PropertySpec .builder(CLIENT, HTTP_CLIENT) .addModifiers(KModifier.PROTECTED, KModifier.ABSTRACT) @@ -144,23 +151,33 @@ internal object ApiClientBaseGenerator { .addStatement("$CLIENT.close()") .build() - val applyAuthFun = FunSpec - .builder(APPLY_AUTH) - .addModifiers(KModifier.PROTECTED, KModifier.OPEN) - .receiver(HTTP_REQUEST_BUILDER) - .build() - - return classBuilder - .primaryConstructor(constructorBuilder.build()) + return TypeSpec + .classBuilder(API_CLIENT_BASE) + .addModifiers(KModifier.ABSTRACT) + .addSuperinterface(CLOSEABLE) + .primaryConstructor(constructor) .addProperty(baseUrlProp) + .addProperty(tokenProp) .addProperty(clientProp) .addFunction(closeFun) - .addFunction(applyAuthFun) + .addFunction(buildApplyAuth()) .addFunction(buildSafeCall()) .addFunction(buildCreateHttpClient()) .build() } + private fun buildApplyAuth(): FunSpec = FunSpec + .builder(APPLY_AUTH) + .addModifiers(KModifier.PROTECTED, KModifier.OPEN) + .receiver(HTTP_REQUEST_BUILDER) + .beginControlFlow("%M", HEADERS_FUN) + .addStatement( + "append(%T.Authorization, %P)", + HTTP_HEADERS, + CodeBlock.of($$"Bearer ${'$'}{$$TOKEN()}"), + ).endControlFlow() + .build() + private fun buildSafeCall(): FunSpec = FunSpec .builder(SAFE_CALL) .addModifiers(KModifier.PROTECTED, KModifier.SUSPEND) diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt index 9d747c2..72b943f 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt @@ -33,10 +33,13 @@ class ApiClientBaseGeneratorTest { } @Test - fun `ApiClientBase has constructor with only baseUrl`() { + fun `ApiClientBase has constructor with baseUrl and token provider`() { val constructor = assertNotNull(classSpec.primaryConstructor) val paramNames = constructor.parameters.map { it.name } - assertEquals(listOf("baseUrl"), paramNames) + assertTrue("baseUrl" in paramNames) + assertTrue("token" in paramNames) + val tokenParam = constructor.parameters.first { it.name == "token" } + assertEquals("() -> kotlin.String", tokenParam.type.toString(), "token should be a () -> String lambda") } @Test @@ -55,10 +58,14 @@ class ApiClientBaseGeneratorTest { } @Test - fun `ApiClientBase has applyAuth function`() { + fun `ApiClientBase has applyAuth function with Bearer token`() { val applyAuth = classSpec.funSpecs.first { it.name == "applyAuth" } assertTrue(KModifier.PROTECTED in applyAuth.modifiers) assertNotNull(applyAuth.receiverType, "Expected HttpRequestBuilder receiver") + val body = applyAuth.body.toString() + assertTrue(body.contains("Authorization"), "Expected Authorization header") + assertTrue(body.contains("Bearer"), "Expected Bearer prefix") + assertTrue(body.contains("token()"), "Expected token() invocation") } @OptIn(ExperimentalKotlinPoetApi::class) diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt index e844bd4..f996131 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt @@ -701,9 +701,10 @@ class ClientGeneratorTest { assertTrue("bearerAuthTestToken" in paramNames, "Expected bearerAuthTestToken param") assertTrue("apiKeyHeaderTest" in paramNames, "Expected apiKeyHeaderTest param") - // Verify only baseUrl is passed to super (auth is handled per-client, not in ApiClientBase) + // Verify baseUrl and no-op token are passed to super val superParams = cls.superclassConstructorParameters.map { it.toString().trim() } - assertEquals(listOf("baseUrl"), superParams, "Expected only baseUrl passed to super") + assertEquals(2, superParams.size, "Expected baseUrl and token passed to super") + assertEquals("baseUrl", superParams[0]) } @Test @@ -733,31 +734,28 @@ class ClientGeneratorTest { } @Test - fun `single Bearer scheme generates named token param`() { + fun `single Bearer scheme uses plain token param (no prefix)`() { val cls = clientClass( listOf(endpoint()), listOf(SecurityScheme.Bearer("BearerAuth")), ) val constructor = assertNotNull(cls.primaryConstructor) val paramNames = constructor.parameters.map { it.name } - assertTrue("bearerAuthTestToken" in paramNames, "Expected bearerAuthTestToken param") + assertEquals(listOf("baseUrl", "token"), paramNames, "Single Bearer should use plain token param") } - // -- SECU: applyAuth body assertions -- - @Test - fun `Bearer scheme applyAuth contains Authorization header with Bearer prefix`() { + fun `single Bearer scheme does not override applyAuth (inherits from base)`() { val cls = clientClass( listOf(endpoint()), listOf(SecurityScheme.Bearer("BearerAuth")), ) - val applyAuth = cls.funSpecs.first { it.name == "applyAuth" } - val body = applyAuth.body.toString() - assertTrue(body.contains("Authorization"), "Expected Authorization header") - assertTrue(body.contains("Bearer"), "Expected Bearer prefix") - assertTrue(body.contains("bearerAuthTestToken()"), "Expected bearerAuthTestToken() invocation") + val applyAuthFuns = cls.funSpecs.filter { it.name == "applyAuth" } + assertTrue(applyAuthFuns.isEmpty(), "Single Bearer should not override applyAuth") } + // -- SECU: applyAuth body assertions -- + @Test fun `Basic scheme applyAuth contains Authorization header with Base64 encoding`() { val cls = clientClass( diff --git a/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt b/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt index 103658e..386301b 100644 --- a/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt +++ b/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt @@ -595,15 +595,15 @@ class JustworksPluginFunctionalTest { assertTrue(content.contains("applyAuth"), "Should contain applyAuth override") assertTrue(content.contains("Authorization"), "Should contain Authorization header for Basic auth") - // ApiClientBase should be auth-agnostic + // ApiClientBase should still have the global token param (default Bearer) val apiClientBase = projectDir .resolve("build/generated/justworks/shared/kotlin/com/avsystem/justworks/ApiClientBase.kt") val baseContent = apiClientBase.readText() - assertFalse(baseContent.contains("token"), "ApiClientBase should NOT contain auth params") + assertTrue(baseContent.contains("token"), "ApiClientBase should contain token param") } @Test - fun `spec without security schemes generates ApiClientBase with no auth params`() { + fun `spec without security schemes generates ApiClientBase with token and Bearer`() { writeBuildFile() runner("justworksGenerateMain").build() @@ -613,8 +613,8 @@ class JustworksPluginFunctionalTest { assertTrue(apiClientBase.exists(), "ApiClientBase.kt should exist") val content = apiClientBase.readText() - assertTrue(!content.contains("token"), "Should NOT contain token param when no security schemes") - assertTrue(!content.contains("Bearer"), "Should NOT contain Bearer when no security schemes") + assertTrue(content.contains("token"), "Should contain token param") + assertTrue(content.contains("Bearer"), "Should contain default Bearer auth") } @Test @@ -724,8 +724,8 @@ class JustworksPluginFunctionalTest { assertTrue(api1Content.contains("commonAuthApi1"), "Spec1 client should take commonAuthApi1") assertTrue(api1Content.contains("X-API-Key-1"), "Spec1 client should reference X-API-Key-1") assertTrue( - api1Content.contains("ApiClientBase(baseUrl)"), - "Spec1 client should pass only baseUrl to super", + api1Content.contains("ApiClientBase(baseUrl,"), + "Spec1 client should pass baseUrl and token to super", ) val api2Client = projectDir @@ -735,8 +735,8 @@ class JustworksPluginFunctionalTest { assertTrue(api2Content.contains("commonAuthApi2"), "Spec2 client should take commonAuthApi2") assertTrue(api2Content.contains("X-API-Key-2"), "Spec2 client should reference X-API-Key-2") assertTrue( - api2Content.contains("ApiClientBase(baseUrl)"), - "Spec2 client should pass only baseUrl to super", + api2Content.contains("ApiClientBase(baseUrl,"), + "Spec2 client should pass baseUrl and token to super", ) } From 7b1db8392ecf94c571d0a02600d919831bbb9a81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 9 Apr 2026 18:08:01 +0200 Subject: [PATCH 20/23] refactor(core): remove global token inheritance and update `applyAuth` for per-client auth logic - Remove `token` lambda from `ApiClientBase` constructor and its associated Bearer auth handling. - Refactor `applyAuth` to be a no-op in `ApiClientBase`, leaving auth implementation to per-client overrides. - Update `ClientGenerator` to support per-client authentication setup. - Modify tests to align with the new per-client authentication model. --- .../core/gen/client/ClientGenerator.kt | 34 +++++++++++-------- .../core/gen/shared/ApiClientBaseGenerator.kt | 18 ---------- .../core/gen/ApiClientBaseGeneratorTest.kt | 15 +++----- .../justworks/core/gen/ClientGeneratorTest.kt | 13 ++++--- .../gradle/JustworksPluginFunctionalTest.kt | 19 ++++++----- 5 files changed, 42 insertions(+), 57 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt index c89b023..c9bc6bd 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt @@ -6,7 +6,6 @@ import com.avsystem.justworks.core.gen.ApiPackage import com.avsystem.justworks.core.gen.BASE64_CLASS import com.avsystem.justworks.core.gen.BASE_URL import com.avsystem.justworks.core.gen.CLIENT -import com.avsystem.justworks.core.gen.TOKEN import com.avsystem.justworks.core.gen.CREATE_HTTP_CLIENT import com.avsystem.justworks.core.gen.GENERATED_SERIALIZERS_MODULE import com.avsystem.justworks.core.gen.HEADERS_FUN @@ -16,6 +15,7 @@ import com.avsystem.justworks.core.gen.HTTP_REQUEST_BUILDER import com.avsystem.justworks.core.gen.HTTP_SUCCESS import com.avsystem.justworks.core.gen.Hierarchy import com.avsystem.justworks.core.gen.NameRegistry +import com.avsystem.justworks.core.gen.TOKEN import com.avsystem.justworks.core.gen.client.BodyGenerator.buildFunctionBody import com.avsystem.justworks.core.gen.client.ParametersGenerator.buildBodyParams import com.avsystem.justworks.core.gen.client.ParametersGenerator.buildNullableParameter @@ -81,7 +81,6 @@ internal object ClientGenerator { val tokenType = LambdaTypeName.get(returnType = STRING) val isSingleBearer = securitySchemes.singleOrNull() is SecurityScheme.Bearer - val needsAuthOverride = securitySchemes.isNotEmpty() && !isSingleBearer val constructorBuilder = FunSpec .constructorBuilder() @@ -93,11 +92,17 @@ internal object ClientGenerator { .addSuperclassConstructorParameter(BASE_URL) if (isSingleBearer) { - // Single Bearer: use plain token param, pass to super (base class handles Bearer) + // Single Bearer: use plain "token" param name for ergonomics constructorBuilder.addParameter(TOKEN, tokenType) - classBuilder.addSuperclassConstructorParameter(TOKEN) + classBuilder.addProperty( + PropertySpec + .builder(TOKEN, tokenType) + .initializer(TOKEN) + .addModifiers(KModifier.PRIVATE) + .build(), + ) } else if (securitySchemes.isNotEmpty()) { - // Multiple or non-Bearer schemes: generate named auth params, override applyAuth + // Multiple or non-Bearer schemes: generate named auth params val authParamNames = securitySchemes.flatMap { scheme -> when (scheme) { is SecurityScheme.Bearer -> listOf(scheme.toAuthParam(specTitle).name) @@ -116,11 +121,6 @@ internal object ClientGenerator { .build(), ) } - - classBuilder.addSuperclassConstructorParameter("{ %S }", "") - } else { - // No security schemes: no auth params, pass no-op token to super - classBuilder.addSuperclassConstructorParameter("{ %S }", "") } val httpClientProperty = PropertySpec @@ -133,8 +133,8 @@ internal object ClientGenerator { .primaryConstructor(constructorBuilder.build()) .addProperty(httpClientProperty) - if (needsAuthOverride) { - classBuilder.addFunction(buildApplyAuth(securitySchemes, specTitle)) + if (securitySchemes.isNotEmpty()) { + classBuilder.addFunction(buildApplyAuth(securitySchemes, isSingleBearer, specTitle)) } context(NameRegistry()) { @@ -147,7 +147,11 @@ internal object ClientGenerator { .build() } - private fun buildApplyAuth(securitySchemes: List, specTitle: String): FunSpec { + private fun buildApplyAuth( + securitySchemes: List, + isSingleBearer: Boolean, + specTitle: String, + ): FunSpec { val builder = FunSpec .builder(APPLY_AUTH) .addModifiers(KModifier.OVERRIDE, KModifier.PROTECTED) @@ -167,11 +171,11 @@ internal object ClientGenerator { for (scheme in headerSchemes) { when (scheme) { is SecurityScheme.Bearer -> { - val authParam = scheme.toAuthParam(specTitle) + val tokenRef = if (isSingleBearer) TOKEN else scheme.toAuthParam(specTitle).name builder.addStatement( "append(%T.Authorization, %P)", HTTP_HEADERS, - CodeBlock.of($$"Bearer ${$${authParam.name}()}"), + CodeBlock.of($$"Bearer ${$$tokenRef()}"), ) } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt index 7cb6b7d..78eb3db 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt @@ -11,11 +11,9 @@ import com.avsystem.justworks.core.gen.CONTENT_NEGOTIATION import com.avsystem.justworks.core.gen.CREATE_HTTP_CLIENT import com.avsystem.justworks.core.gen.ENCODE_PARAM_FUN import com.avsystem.justworks.core.gen.ENCODE_TO_STRING_FUN -import com.avsystem.justworks.core.gen.HEADERS_FUN import com.avsystem.justworks.core.gen.HTTP_CLIENT import com.avsystem.justworks.core.gen.HTTP_ERROR import com.avsystem.justworks.core.gen.HTTP_ERROR_TYPE -import com.avsystem.justworks.core.gen.HTTP_HEADERS import com.avsystem.justworks.core.gen.HTTP_REQUEST_BUILDER import com.avsystem.justworks.core.gen.HTTP_REQUEST_TIMEOUT_EXCEPTION import com.avsystem.justworks.core.gen.HTTP_RESPONSE @@ -25,8 +23,6 @@ import com.avsystem.justworks.core.gen.JSON_CLASS import com.avsystem.justworks.core.gen.JSON_FUN import com.avsystem.justworks.core.gen.SAFE_CALL import com.avsystem.justworks.core.gen.SERIALIZERS_MODULE -import com.avsystem.justworks.core.gen.TOKEN -import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.KModifier @@ -125,7 +121,6 @@ internal object ApiClientBaseGenerator { val constructor = FunSpec .constructorBuilder() .addParameter(BASE_URL, STRING) - .addParameter(TOKEN, LambdaTypeName.get(returnType = STRING)) .build() val baseUrlProp = PropertySpec @@ -134,12 +129,6 @@ internal object ApiClientBaseGenerator { .addModifiers(KModifier.PROTECTED) .build() - val tokenProp = PropertySpec - .builder(TOKEN, LambdaTypeName.get(returnType = STRING)) - .initializer(TOKEN) - .addModifiers(KModifier.PRIVATE) - .build() - val clientProp = PropertySpec .builder(CLIENT, HTTP_CLIENT) .addModifiers(KModifier.PROTECTED, KModifier.ABSTRACT) @@ -157,7 +146,6 @@ internal object ApiClientBaseGenerator { .addSuperinterface(CLOSEABLE) .primaryConstructor(constructor) .addProperty(baseUrlProp) - .addProperty(tokenProp) .addProperty(clientProp) .addFunction(closeFun) .addFunction(buildApplyAuth()) @@ -170,12 +158,6 @@ internal object ApiClientBaseGenerator { .builder(APPLY_AUTH) .addModifiers(KModifier.PROTECTED, KModifier.OPEN) .receiver(HTTP_REQUEST_BUILDER) - .beginControlFlow("%M", HEADERS_FUN) - .addStatement( - "append(%T.Authorization, %P)", - HTTP_HEADERS, - CodeBlock.of($$"Bearer ${'$'}{$$TOKEN()}"), - ).endControlFlow() .build() private fun buildSafeCall(): FunSpec = FunSpec diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt index 72b943f..3f74b97 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt @@ -33,13 +33,10 @@ class ApiClientBaseGeneratorTest { } @Test - fun `ApiClientBase has constructor with baseUrl and token provider`() { + fun `ApiClientBase has constructor with only baseUrl`() { val constructor = assertNotNull(classSpec.primaryConstructor) val paramNames = constructor.parameters.map { it.name } - assertTrue("baseUrl" in paramNames) - assertTrue("token" in paramNames) - val tokenParam = constructor.parameters.first { it.name == "token" } - assertEquals("() -> kotlin.String", tokenParam.type.toString(), "token should be a () -> String lambda") + assertEquals(listOf("baseUrl"), paramNames) } @Test @@ -58,14 +55,12 @@ class ApiClientBaseGeneratorTest { } @Test - fun `ApiClientBase has applyAuth function with Bearer token`() { + fun `ApiClientBase has empty applyAuth function`() { val applyAuth = classSpec.funSpecs.first { it.name == "applyAuth" } assertTrue(KModifier.PROTECTED in applyAuth.modifiers) + assertTrue(KModifier.OPEN in applyAuth.modifiers) assertNotNull(applyAuth.receiverType, "Expected HttpRequestBuilder receiver") - val body = applyAuth.body.toString() - assertTrue(body.contains("Authorization"), "Expected Authorization header") - assertTrue(body.contains("Bearer"), "Expected Bearer prefix") - assertTrue(body.contains("token()"), "Expected token() invocation") + assertTrue(applyAuth.body.toString().isBlank(), "Base applyAuth should be a no-op") } @OptIn(ExperimentalKotlinPoetApi::class) diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt index f996131..01debe9 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt @@ -701,9 +701,9 @@ class ClientGeneratorTest { assertTrue("bearerAuthTestToken" in paramNames, "Expected bearerAuthTestToken param") assertTrue("apiKeyHeaderTest" in paramNames, "Expected apiKeyHeaderTest param") - // Verify baseUrl and no-op token are passed to super + // Verify only baseUrl is passed to super val superParams = cls.superclassConstructorParameters.map { it.toString().trim() } - assertEquals(2, superParams.size, "Expected baseUrl and token passed to super") + assertEquals(1, superParams.size, "Expected only baseUrl passed to super") assertEquals("baseUrl", superParams[0]) } @@ -745,13 +745,16 @@ class ClientGeneratorTest { } @Test - fun `single Bearer scheme does not override applyAuth (inherits from base)`() { + fun `single Bearer scheme overrides applyAuth with Bearer token`() { val cls = clientClass( listOf(endpoint()), listOf(SecurityScheme.Bearer("BearerAuth")), ) - val applyAuthFuns = cls.funSpecs.filter { it.name == "applyAuth" } - assertTrue(applyAuthFuns.isEmpty(), "Single Bearer should not override applyAuth") + val applyAuth = cls.funSpecs.first { it.name == "applyAuth" } + val body = applyAuth.body.toString() + assertTrue(body.contains("Authorization"), "Expected Authorization header") + assertTrue(body.contains("Bearer"), "Expected Bearer prefix") + assertTrue(body.contains("token()"), "Expected token() invocation") } // -- SECU: applyAuth body assertions -- diff --git a/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt b/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt index 386301b..566691f 100644 --- a/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt +++ b/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt @@ -595,15 +595,16 @@ class JustworksPluginFunctionalTest { assertTrue(content.contains("applyAuth"), "Should contain applyAuth override") assertTrue(content.contains("Authorization"), "Should contain Authorization header for Basic auth") - // ApiClientBase should still have the global token param (default Bearer) + // ApiClientBase should NOT contain token — auth is per-client now val apiClientBase = projectDir .resolve("build/generated/justworks/shared/kotlin/com/avsystem/justworks/ApiClientBase.kt") val baseContent = apiClientBase.readText() - assertTrue(baseContent.contains("token"), "ApiClientBase should contain token param") + assertFalse(baseContent.contains("token"), "ApiClientBase should not contain token param") + assertFalse(baseContent.contains("Bearer"), "ApiClientBase should not contain Bearer auth") } @Test - fun `spec without security schemes generates ApiClientBase with token and Bearer`() { + fun `spec without security schemes generates ApiClientBase without auth`() { writeBuildFile() runner("justworksGenerateMain").build() @@ -613,8 +614,8 @@ class JustworksPluginFunctionalTest { assertTrue(apiClientBase.exists(), "ApiClientBase.kt should exist") val content = apiClientBase.readText() - assertTrue(content.contains("token"), "Should contain token param") - assertTrue(content.contains("Bearer"), "Should contain default Bearer auth") + assertFalse(content.contains("token"), "Should not contain token param") + assertFalse(content.contains("Bearer"), "Should not contain Bearer auth") } @Test @@ -724,8 +725,8 @@ class JustworksPluginFunctionalTest { assertTrue(api1Content.contains("commonAuthApi1"), "Spec1 client should take commonAuthApi1") assertTrue(api1Content.contains("X-API-Key-1"), "Spec1 client should reference X-API-Key-1") assertTrue( - api1Content.contains("ApiClientBase(baseUrl,"), - "Spec1 client should pass baseUrl and token to super", + api1Content.contains("ApiClientBase(baseUrl)"), + "Spec1 client should pass only baseUrl to super", ) val api2Client = projectDir @@ -735,8 +736,8 @@ class JustworksPluginFunctionalTest { assertTrue(api2Content.contains("commonAuthApi2"), "Spec2 client should take commonAuthApi2") assertTrue(api2Content.contains("X-API-Key-2"), "Spec2 client should reference X-API-Key-2") assertTrue( - api2Content.contains("ApiClientBase(baseUrl,"), - "Spec2 client should pass baseUrl and token to super", + api2Content.contains("ApiClientBase(baseUrl)"), + "Spec2 client should pass only baseUrl to super", ) } From abc18e71fbf1643ce45f402fb9fcec5a918c8818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 9 Apr 2026 18:14:10 +0200 Subject: [PATCH 21/23] test(core): add tests for unsupported and undefined security schemes - Add handling for warnings regarding undefined schemes and unsupported types (Digest and OAuth2). - Expand `SpecParserSecurityTest` with new test cases for excluded and unreferenced schemes. - Update `security-schemes-spec.yaml` to include new scheme entries for testing. --- .../core/parser/SpecParserSecurityTest.kt | 39 +++++++++++++++++-- .../test/resources/security-schemes-spec.yaml | 8 +++- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserSecurityTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserSecurityTest.kt index 80a47bb..2d747a7 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserSecurityTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserSecurityTest.kt @@ -6,6 +6,7 @@ import com.avsystem.justworks.core.model.SecurityScheme import org.junit.jupiter.api.TestInstance import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertIs import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -56,15 +57,45 @@ class SpecParserSecurityTest : SpecParserTestBase() { } @Test - fun `excludes unreferenced OAuth2 scheme`() { + fun `excludes unsupported cookie API key scheme`() { val names = apiSpec.securitySchemes.map { it.name } - assertTrue("UnusedOAuth" !in names, "UnusedOAuth should not be in parsed schemes") + assertTrue("ApiKeyCookie" !in names, "ApiKeyCookie should not be in parsed schemes") } @Test - fun `excludes unsupported cookie API key scheme`() { + fun `excludes unsupported OAuth2 scheme`() { val names = apiSpec.securitySchemes.map { it.name } - assertTrue("ApiKeyCookie" !in names, "ApiKeyCookie should not be in parsed schemes") + assertTrue("OAuth2Auth" !in names, "OAuth2Auth should not be in parsed schemes") + } + + @Test + fun `excludes unsupported digest HTTP scheme`() { + val names = apiSpec.securitySchemes.map { it.name } + assertTrue("DigestAuth" !in names, "DigestAuth should not be in parsed schemes") + } + + @Test + fun `warns about undefined scheme reference`() { + val result = SpecParser.parse(loadResource("security-schemes-spec.yaml")) + assertIs>(result) + assertTrue( + result.warnings.any { it.message.contains("NonExistentScheme") }, + "Expected warning about undefined scheme 'NonExistentScheme'", + ) + } + + @Test + fun `warns about unsupported scheme types`() { + val result = SpecParser.parse(loadResource("security-schemes-spec.yaml")) + assertIs>(result) + assertTrue( + result.warnings.any { it.message.contains("digest") }, + "Expected warning about unsupported HTTP scheme 'digest'", + ) + assertTrue( + result.warnings.any { it.message.contains("OAuth2Auth") }, + "Expected warning about unsupported scheme type for 'OAuth2Auth'", + ) } @Test diff --git a/core/src/test/resources/security-schemes-spec.yaml b/core/src/test/resources/security-schemes-spec.yaml index 6185e45..5e70174 100644 --- a/core/src/test/resources/security-schemes-spec.yaml +++ b/core/src/test/resources/security-schemes-spec.yaml @@ -23,7 +23,10 @@ components: type: apiKey in: cookie name: session_id - UnusedOAuth: + DigestAuth: + type: http + scheme: digest + OAuth2Auth: type: oauth2 flows: implicit: @@ -37,6 +40,9 @@ security: - ApiKeyQuery: [] - BasicAuth: [] - ApiKeyCookie: [] + - DigestAuth: [] + - OAuth2Auth: [] + - NonExistentScheme: [] paths: /health: From 6616d6fff6d2f5f09351fad9d5e883d841a23dc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 9 Apr 2026 18:16:50 +0200 Subject: [PATCH 22/23] fmt --- .../avsystem/justworks/core/parser/SpecParserSecurityTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserSecurityTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserSecurityTest.kt index 2d747a7..c8ee890 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserSecurityTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserSecurityTest.kt @@ -6,8 +6,8 @@ import com.avsystem.justworks.core.model.SecurityScheme import org.junit.jupiter.api.TestInstance import kotlin.test.BeforeTest import kotlin.test.Test -import kotlin.test.assertIs import kotlin.test.assertEquals +import kotlin.test.assertIs import kotlin.test.assertTrue @TestInstance(TestInstance.Lifecycle.PER_CLASS) From 6a77be6ee3a8db5075246b8d35a3ff14ca70a531 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Fri, 10 Apr 2026 10:26:42 +0200 Subject: [PATCH 23/23] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94?= =?UTF-8?q?=20add=20AuthParam=20tests,=20simplify=20test=20setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../justworks/core/gen/AuthParamTest.kt | 48 +++++++++++++++++++ .../core/parser/SpecParserSecurityTest.kt | 10 +--- 2 files changed, 49 insertions(+), 9 deletions(-) create mode 100644 core/src/test/kotlin/com/avsystem/justworks/core/gen/AuthParamTest.kt diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/AuthParamTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/AuthParamTest.kt new file mode 100644 index 0000000..4990c87 --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/AuthParamTest.kt @@ -0,0 +1,48 @@ +package com.avsystem.justworks.core.gen + +import com.avsystem.justworks.core.gen.shared.AuthParam +import com.avsystem.justworks.core.gen.shared.toAuthParam +import com.avsystem.justworks.core.model.ApiKeyLocation +import com.avsystem.justworks.core.model.SecurityScheme +import kotlin.test.Test +import kotlin.test.assertEquals + +class AuthParamTest { + @Test + fun `Bearer param name includes scheme name and spec title`() { + val param = SecurityScheme.Bearer("BearerAuth").toAuthParam("Petstore") + assertEquals("bearerAuthPetstoreToken", param.name) + } + + @Test + fun `Basic param generates username and password`() { + val param = SecurityScheme.Basic("BasicAuth").toAuthParam("Petstore") + assertEquals("basicAuthPetstoreUsername", param.username) + assertEquals("basicAuthPetstorePassword", param.password) + } + + @Test + fun `ApiKey param name includes scheme name and spec title`() { + val param = SecurityScheme.ApiKey("ApiKeyHeader", "X-API-Key", ApiKeyLocation.HEADER).toAuthParam("Petstore") + assertEquals("apiKeyHeaderPetstore", param.name) + } + + @Test + fun `multi-word scheme name is camelCased`() { + val param = SecurityScheme.Bearer("my-bearer-auth").toAuthParam("My API") + assertEquals("myBearerAuthMyApiToken", param.name) + } + + @Test + fun `multi-word spec title is PascalCased`() { + val param = SecurityScheme.ApiKey("key", "X-Key", ApiKeyLocation.QUERY).toAuthParam("my cool api") + assertEquals("keyMyCoolApi", param.name) + } + + @Test + fun `Basic with multi-word names formats correctly`() { + val param = SecurityScheme.Basic("http-basic").toAuthParam("Admin Service") + assertEquals("httpBasicAdminServiceUsername", param.username) + assertEquals("httpBasicAdminServicePassword", param.password) + } +} diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserSecurityTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserSecurityTest.kt index c8ee890..f77f363 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserSecurityTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserSecurityTest.kt @@ -4,7 +4,6 @@ import com.avsystem.justworks.core.model.ApiKeyLocation import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.SecurityScheme import org.junit.jupiter.api.TestInstance -import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -12,14 +11,7 @@ import kotlin.test.assertTrue @TestInstance(TestInstance.Lifecycle.PER_CLASS) class SpecParserSecurityTest : SpecParserTestBase() { - private lateinit var apiSpec: ApiSpec - - @BeforeTest - fun setUp() { - if (!::apiSpec.isInitialized) { - apiSpec = parseSpec(loadResource("security-schemes-spec.yaml")) - } - } + private val apiSpec: ApiSpec by lazy { parseSpec(loadResource("security-schemes-spec.yaml")) } @Test fun `parses exactly 4 security schemes from fixture`() {