diff --git a/README.md b/README.md index 4dad22e..2d78f60 100644 --- a/README.md +++ b/README.md @@ -111,10 +111,36 @@ A `SerializersModule` is auto-generated when discriminated polymorphic types are | `application/json` request body | Supported | | Form data / multipart | Not supported | +### Security Schemes + +The plugin reads security schemes defined in the OpenAPI spec and generates authentication handling automatically. +Only schemes referenced in the top-level `security` requirement are included. + +Parameter names are derived as `{schemeName}{specTitle}{Suffix}` where `schemeName` and `specTitle` are camel/PascalCased +from the OpenAPI scheme key and `info.title` respectively. This scoping prevents collisions when multiple specs define +schemes with the same name. + +| Scheme type | Location | Generated constructor parameter(s) | +|-------------|----------|--------------------------------------------------------------------------------| +| HTTP Bearer | Header | `{name}{title}Token: () -> String` | +| HTTP Basic | Header | `{name}{title}Username: () -> String`, `{name}{title}Password: () -> String` | +| API Key | Header | `{name}{title}: () -> String` | +| API Key | Query | `{name}{title}: () -> String` | + +All auth parameters are `() -> String` lambdas, called on every request. This lets you supply providers that refresh +credentials automatically. + +Each generated client overrides an `applyAuth()` method that applies all credentials to each request: + +- Bearer tokens are sent as `Authorization: Bearer {token}` headers +- Basic auth is sent as `Authorization: Basic {base64(username:password)}` headers +- Header API keys are appended to request headers using the parameter name from the spec +- Query API keys are appended to URL query parameters + ### Not Supported -Callbacks, links, webhooks, XML content types, and OpenAPI vendor extensions (`x-*`) are not processed. The plugin logs -warnings for callbacks and links found in a spec. +Callbacks, links, webhooks, XML content types, OpenAPI vendor extensions (`x-*`), OAuth 2.0, OpenID Connect, and +cookie-based API keys are not processed. The plugin logs warnings for callbacks and links found in a spec. ## Generated Code Structure @@ -221,60 +247,75 @@ Here is how to use them. ### Dependencies -Add the required runtime dependencies and enable the experimental context parameters compiler flag: +Add the required runtime dependencies: ```kotlin -kotlin { - compilerOptions { - freeCompilerArgs.add("-Xcontext-parameters") - } -} - dependencies { implementation("io.ktor:ktor-client-core:3.1.1") implementation("io.ktor:ktor-client-cio:3.1.1") // or another engine (OkHttp, Apache, etc.) implementation("io.ktor:ktor-client-content-negotiation:3.1.1") implementation("io.ktor:ktor-serialization-kotlinx-json:3.1.1") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1") - implementation("io.arrow-kt:arrow-core:2.2.1.1") } ``` ### Creating the Client Each generated client extends `ApiClientBase` and creates its own pre-configured `HttpClient` internally. -You only need to provide the base URL and authentication credentials. +You only need to provide the base URL and authentication credentials (if the spec defines security schemes). Class names are derived from OpenAPI tags as `Api` (e.g., a `pets` tag produces `PetsApi`). Untagged endpoints go to `DefaultApi`. +**Bearer token** (spec title "Petstore", scheme name "BearerAuth"): + ```kotlin val client = PetsApi( baseUrl = "https://api.example.com", - token = { "your-bearer-token" }, + bearerAuthPetstoreToken = { "your-bearer-token" }, ) ``` -The `token` parameter is a `() -> String` lambda called on every request and sent as a `Bearer` token in the -`Authorization` header. This lets you supply a provider that refreshes automatically: +Auth parameters are `() -> String` lambdas called on every request, so you can supply a provider that refreshes +automatically: ```kotlin val client = PetsApi( baseUrl = "https://api.example.com", - token = { tokenStore.getAccessToken() }, + bearerAuthPetstoreToken = { tokenStore.getAccessToken() }, ) ``` +**Multiple security schemes** -- parameters are scoped by scheme name and spec title: + +```kotlin +val client = PetsApi( + baseUrl = "https://api.example.com", + bearerAuthPetstoreToken = { tokenStore.getAccessToken() }, + internalApiKeyPetstore = { secrets.getApiKey() }, +) +``` + +**Basic auth** (scheme name "BasicAuth"): + +```kotlin +val client = PetsApi( + baseUrl = "https://api.example.com", + basicAuthPetstoreUsername = { "user" }, + basicAuthPetstorePassword = { "pass" }, +) +``` + +See [Security Schemes](#security-schemes) for the full mapping of scheme types to constructor parameters. + The client implements `Closeable` -- call `client.close()` when done to release HTTP resources. ### Making Requests -Every endpoint becomes a `suspend` function on the client. Functions use -Arrow's [Raise](https://arrow-kt.io/docs/typed-errors/) for structured error handling -- they require a -`context(Raise)` 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 +329,21 @@ val result = client.findPets(status = "available", limit = 20) ### Error Handling -Generated endpoints use [Arrow's Raise](https://arrow-kt.io/docs/typed-errors/) -- errors are raised, not returned as -`Either`. Use Arrow's `either { ... }` block to obtain an `Either>`: +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 +364,7 @@ result.fold( | `Network` | I/O failures, timeouts, DNS issues | Network errors (connection timeouts, DNS failures) are caught and reported as -`HttpError(code = 0, ..., type = HttpErrorType.Network)` instead of propagating exceptions. +`HttpError(code = 0, ..., type = HttpErrorType.Network)` instead of propagating raw exceptions. ## Publishing 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..5c65781 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,33 @@ 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>) -inline fun ensureNotNullOrAccumulate(value: B?, error: () -> Error) { +context(iorRaise: IorRaise>) +inline fun ensureNotNullOrAccumulate(value: B?, error: () -> Error): B? { contract { callsInPlace(error, AT_MOST_ONCE) } if (value == null) { - warnings.accumulate(nonEmptyListOf(error())) + iorRaise.accumulate(nonEmptyListOf(error())) } + return value +} + +/** Accumulates a single error as a side effect, for use outside of expression context. */ +context(iorRaise: IorRaise>) +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? { + accumulate(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 098efed..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,9 +1,10 @@ +@file:OptIn(ExperimentalRaiseAccumulateApi::class) + package com.avsystem.justworks.core import arrow.core.Nel import arrow.core.raise.ExperimentalRaiseAccumulateApi import arrow.core.raise.IorRaise -import kotlin.contracts.ExperimentalContracts object Issue { data class Error(val message: String) 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 64f79ee..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 @@ -20,7 +20,9 @@ object CodeGenerator { apiPackage: String, outputDir: File, ): Result { - val hierarchy = Hierarchy(ModelPackage(modelPackage)).apply { addSchemas(spec.schemas) } + val hierarchy = Hierarchy(ModelPackage(modelPackage)).apply { + addSchemas(spec.schemas) + } val (modelFiles, resolvedSpec) = context(hierarchy, NameRegistry()) { ModelGenerator.generateWithResolvedSpec(spec) 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..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 @@ -7,8 +7,7 @@ import com.avsystem.justworks.core.model.TypeRef import com.squareup.kotlinpoet.ClassName internal class Hierarchy(val modelPackage: ModelPackage) { - private val schemas = mutableSetOf() - + private val schemaModels = mutableSetOf() private val memoScope = MemoScope() /** @@ -16,18 +15,18 @@ internal class Hierarchy(val modelPackage: ModelPackage) { * This is necessary when schemas are updated (e.g., after inlining types). */ fun addSchemas(newSchemas: List) { - schemas += newSchemas + schemaModels += newSchemas memoScope.reset() } /** All schemas indexed by name for quick lookup. */ val schemasById: Map by memoized(memoScope) { - schemas.associateBy { it.name } + 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() } + schemaModels.filterNot { it.variants().isNullOrEmpty() } } /** Maps parent schema name to its variant schema names (for both oneOf and anyOf). */ 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..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 @@ -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])") /** @@ -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/Names.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt index 85ec282..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 @@ -94,6 +94,7 @@ val HTTP_SUCCESS = ClassName("com.avsystem.justworks", "HttpSuccess") // Kotlin stdlib // ============================================================================ +val BASE64_CLASS = ClassName("java.util", "Base64") val CLOSEABLE = ClassName("java.io", "Closeable") val IO_EXCEPTION = ClassName("java.io", "IOException") val HTTP_REQUEST_TIMEOUT_EXCEPTION = ClassName("io.ktor.client.plugins", "HttpRequestTimeoutException") 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 7ffaf0d..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 @@ -1,13 +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_ERROR +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 @@ -17,12 +21,15 @@ 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.toAuthParam 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 +import com.avsystem.justworks.core.model.SecurityScheme import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.FileSpec @@ -50,7 +57,9 @@ internal object ClientGenerator { context(_: Hierarchy, _: ApiPackage, _: NameRegistry) 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) } + return grouped.map { (tag, endpoints) -> + generateClientFile(tag, endpoints, hasPolymorphicTypes, spec.securitySchemes, spec.title) + } } context(hierarchy: Hierarchy, apiPackage: ApiPackage, nameRegistry: NameRegistry) @@ -58,6 +67,8 @@ internal object ClientGenerator { tag: String, endpoints: List, hasPolymorphicTypes: Boolean, + securitySchemes: List, + specTitle: String, ): FileSpec { val className = ClassName(apiPackage, nameRegistry.register("${tag.toPascalCase()}$API_SUFFIX")) @@ -69,12 +80,48 @@ internal object ClientGenerator { } val tokenType = LambdaTypeName.get(returnType = STRING) + val isSingleBearer = securitySchemes.singleOrNull() is SecurityScheme.Bearer - 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) + + if (isSingleBearer) { + // Single Bearer: use plain "token" param name for ergonomics + constructorBuilder.addParameter(TOKEN, tokenType) + 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 + 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(), + ) + } + } val httpClientProperty = PropertySpec .builder(CLIENT, HTTP_CLIENT) @@ -82,14 +129,14 @@ internal object ClientGenerator { .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) + if (securitySchemes.isNotEmpty()) { + classBuilder.addFunction(buildApplyAuth(securitySchemes, isSingleBearer, specTitle)) + } + context(NameRegistry()) { classBuilder.addFunctions(endpoints.map { generateEndpointFunction(it) }) } @@ -100,6 +147,77 @@ internal object ClientGenerator { .build() } + private fun buildApplyAuth( + securitySchemes: List, + isSingleBearer: Boolean, + specTitle: String, + ): 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 { scheme -> scheme.location == ApiKeyLocation.QUERY } + + if (headerSchemes.isNotEmpty()) { + builder.beginControlFlow("%M", HEADERS_FUN) + for (scheme in headerSchemes) { + when (scheme) { + is SecurityScheme.Bearer -> { + val tokenRef = if (isSingleBearer) TOKEN else scheme.toAuthParam(specTitle).name + builder.addStatement( + "append(%T.Authorization, %P)", + HTTP_HEADERS, + CodeBlock.of($$"Bearer ${$$tokenRef()}"), + ) + } + + is SecurityScheme.Basic -> { + val authParam = scheme.toAuthParam(specTitle) + builder.addStatement( + "append(%T.Authorization, %P)", + HTTP_HEADERS, + CodeBlock.of( + $$"Basic ${%T.getEncoder().encodeToString(\"${$${authParam.username}()}:${$${authParam.password}()}\".toByteArray(Charsets.UTF_8))}", + BASE64_CLASS, + ), + ) + } + + is SecurityScheme.ApiKey -> { + val authParam = scheme.toAuthParam(specTitle) + builder.addStatement( + "append(%S, ${authParam.name}())", + scheme.parameterName, + ) + } + } + } + builder.endControlFlow() + } + + if (querySchemes.isNotEmpty()) { + builder.beginControlFlow("url") + for (scheme in querySchemes) { + val authParam = scheme.toAuthParam(specTitle) + builder.addStatement( + "parameters.append(%S, ${authParam.name}())", + 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 55fd18f..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 @@ -122,12 +118,9 @@ internal object ApiClientBaseGenerator { .build() private fun buildApiClientBaseClass(): TypeSpec { - val tokenType = LambdaTypeName.get(returnType = STRING) - val constructor = FunSpec .constructorBuilder() .addParameter(BASE_URL, STRING) - .addParameter(TOKEN, tokenType) .build() val baseUrlProp = PropertySpec @@ -136,12 +129,6 @@ internal object ApiClientBaseGenerator { .addModifiers(KModifier.PROTECTED) .build() - val tokenProp = PropertySpec - .builder(TOKEN, tokenType) - .initializer(TOKEN) - .addModifiers(KModifier.PRIVATE) - .build() - val clientProp = PropertySpec .builder(CLIENT, HTTP_CLIENT) .addModifiers(KModifier.PROTECTED, KModifier.ABSTRACT) @@ -159,7 +146,6 @@ internal object ApiClientBaseGenerator { .addSuperinterface(CLOSEABLE) .primaryConstructor(constructor) .addProperty(baseUrlProp) - .addProperty(tokenProp) .addProperty(clientProp) .addFunction(closeFun) .addFunction(buildApplyAuth()) @@ -170,14 +156,8 @@ internal object ApiClientBaseGenerator { private fun buildApplyAuth(): FunSpec = FunSpec .builder(APPLY_AUTH) - .addModifiers(KModifier.PROTECTED) + .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/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/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..4d4f63b --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/AuthParam.kt @@ -0,0 +1,35 @@ +package com.avsystem.justworks.core.gen.shared + +import com.avsystem.justworks.core.gen.toCamelCase +import com.avsystem.justworks.core.gen.toPascalCase +import com.avsystem.justworks.core.model.SecurityScheme + +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 { + @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") + } + } + } + + @ConsistentCopyVisibility + data class Bearer private constructor(val name: String) : AuthParam { + 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)) + } +} + +private fun formatBase(base: String, specTitle: String) = base.toCamelCase() + specTitle.toPascalCase() 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 ed87933..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 @@ -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( @@ -79,9 +96,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 131443e..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 @@ -10,10 +10,13 @@ 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.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 import com.avsystem.justworks.core.model.ContentType import com.avsystem.justworks.core.model.Discriminator @@ -28,6 +31,7 @@ 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.toEnumOrNull import io.swagger.parser.OpenAPIParser @@ -35,11 +39,13 @@ import io.swagger.v3.oas.models.OpenAPI import io.swagger.v3.oas.models.PathItem import io.swagger.v3.oas.models.media.Content 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.collections.emptyMap 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. @@ -47,7 +53,7 @@ import io.swagger.v3.oas.models.parameters.Parameter as SwaggerParameter * Use pattern matching to handle both outcomes: * ```kotlin * when (val result = SpecParser.parse(file)) { - * is ParseResult.Success -> result.apiSpec + * is ParseResult.Success -> result.value * is ParseResult.Failure -> handleErrors(result.error) * } * ``` @@ -56,12 +62,12 @@ import io.swagger.v3.oas.models.parameters.Parameter as SwaggerParameter * 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 { @@ -79,49 +85,69 @@ 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() + } + @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> context(_: Raise, _: Warnings) private fun OpenAPI.toApiSpec(): ApiSpec { val allSchemas = components?.schemas.orEmpty() + val title = info?.title ?: "Untitled" + + val securitySchemes = extractSecuritySchemes( + components?.securitySchemes.orEmpty(), + security.orEmpty(), + ) val componentSchemaIdentity = ComponentSchemaIdentity(allSchemas.size).apply { allSchemas.forEach { (name, schema) -> this[schema] = name } @@ -161,15 +187,52 @@ 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, enums = enumModels, + securitySchemes = securitySchemes, ) } } + context(_: Warnings) + private fun extractSecuritySchemes( + definitions: Map, + requirements: List, + ): List { + val referencedNames = requirements.flatMap { it.keys }.toSet() + return referencedNames.mapNotNull { name -> + ensureNotNullOrAccumulate(definitions[name]) { + Issue.Warning("Security requirement references undefined scheme '$name'") + }?.toSecurityScheme(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 -> 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) + else -> accumulateAndReturnNull(Issue.Warning("Unsupported API key location '${`in`}' for '$name'")) + } + } + + else -> { + accumulateAndReturnNull(Issue.Warning("Unsupported security scheme type '$type' for '$name'")) + } + } + context(_: ComponentSchemaIdentity, _: ComponentSchemas) private fun extractEndpoints(paths: Map): List = paths .asSequence() 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 fd060a5..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`() { + 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/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/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/gen/ClientGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt index fcd4064..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 @@ -1,6 +1,7 @@ package com.avsystem.justworks.core.gen import com.avsystem.justworks.core.gen.client.ClientGenerator +import com.avsystem.justworks.core.model.ApiKeyLocation import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.ContentType import com.avsystem.justworks.core.model.Endpoint @@ -11,6 +12,7 @@ import com.avsystem.justworks.core.model.PrimitiveType 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.SecurityScheme import com.avsystem.justworks.core.model.TypeRef import com.squareup.kotlinpoet.ExperimentalKotlinPoetApi import com.squareup.kotlinpoet.FileSpec @@ -37,12 +39,15 @@ class ClientGeneratorTest { ClientGenerator.generate(spec, hasPolymorphicTypes) } - private fun spec(vararg endpoints: Endpoint) = ApiSpec( + private fun spec(vararg endpoints: Endpoint) = spec(endpoints.toList()) + + private fun spec(endpoints: List, securitySchemes: List = emptyList()) = ApiSpec( title = "Test", version = "1.0", endpoints = endpoints.toList(), schemas = emptyList(), enums = emptyList(), + securitySchemes = securitySchemes, ) private fun endpoint( @@ -70,8 +75,10 @@ class ClientGeneratorTest { responses = responses, ) - private fun clientClass(vararg endpoints: Endpoint): TypeSpec { - val files = generate(spec(*endpoints)) + private fun clientClass(vararg endpoints: Endpoint): TypeSpec = clientClass(endpoints.toList()) + + private fun clientClass(endpoints: List, securitySchemes: List = emptyList()): TypeSpec { + val files = generate(spec(endpoints, securitySchemes)) return files .first() .members @@ -234,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)") @@ -294,14 +302,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(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 -- @@ -651,6 +659,146 @@ class ClientGeneratorTest { assertTrue(body.contains("toEmptyResult"), "Expected toEmptyResult call") } + // -- SECU: Security-aware constructor generation -- + + @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("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")), + ) + val constructor = assertNotNull(cls.primaryConstructor) + val paramNames = constructor.parameters.map { it.name } + assertTrue("baseUrl" in paramNames, "Expected baseUrl param") + assertTrue("basicAuthTestUsername" in paramNames, "Expected basicAuthTestUsername param") + assertTrue("basicAuthTestPassword" in paramNames, "Expected basicAuthTestPassword 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("bearerAuthTestToken" in paramNames, "Expected bearerAuthTestToken param") + assertTrue("apiKeyHeaderTest" in paramNames, "Expected apiKeyHeaderTest param") + + // Verify only baseUrl is passed to super + val superParams = cls.superclassConstructorParameters.map { it.toString().trim() } + assertEquals(1, superParams.size, "Expected only baseUrl passed to super") + assertEquals("baseUrl", superParams[0]) + } + + @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 = 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 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 } + assertEquals(listOf("baseUrl", "token"), paramNames, "Single Bearer should use plain token param") + } + + @Test + fun `single Bearer scheme overrides applyAuth with Bearer token`() { + 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("token()"), "Expected token() invocation") + } + + // -- SECU: applyAuth body assertions -- + + @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 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/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 b44b52f..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 @@ -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()) @@ -63,7 +63,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) @@ -99,7 +99,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() @@ -120,7 +120,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") @@ -143,7 +143,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") @@ -173,7 +173,7 @@ 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) @@ -193,7 +193,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/gen/ModelGeneratorPolymorphicTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorPolymorphicTest.kt index 8edb5f4..742bae2 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 @@ -35,6 +35,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/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/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorTest.kt index 5dd0536..a43e719 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 @@ -38,6 +38,7 @@ class ModelGeneratorTest { endpoints = emptyList(), schemas = schemas, enums = enums, + securitySchemes = emptyList(), ) private val petSchema = @@ -1384,6 +1385,7 @@ class ModelGeneratorTest { endpoints = listOf(endpoint), schemas = emptyList(), enums = emptyList(), + securitySchemes = emptyList(), ) val files = generate(apiSpec) val uuidSerializerFile = files.find { it.name == "UuidSerializer" } 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 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..f77f363 --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserSecurityTest.kt @@ -0,0 +1,98 @@ +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.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class SpecParserSecurityTest : SpecParserTestBase() { + private val apiSpec: ApiSpec by lazy { 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 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 `excludes unsupported OAuth2 scheme`() { + val names = apiSpec.securitySchemes.map { it.name } + 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 + 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/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt index e531d94..ad055d5 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 @@ -229,14 +229,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'") @@ -248,7 +248,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/core/src/test/resources/security-schemes-spec.yaml b/core/src/test/resources/security-schemes-spec.yaml new file mode 100644 index 0000000..5e70174 --- /dev/null +++ b/core/src/test/resources/security-schemes-spec.yaml @@ -0,0 +1,54 @@ +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 + ApiKeyCookie: + type: apiKey + in: cookie + name: session_id + DigestAuth: + type: http + scheme: digest + OAuth2Auth: + type: oauth2 + flows: + implicit: + authorizationUrl: https://example.com/oauth/authorize + scopes: + read: Read access + +security: + - BearerAuth: [] + - ApiKeyHeader: [] + - ApiKeyQuery: [] + - BasicAuth: [] + - ApiKeyCookie: [] + - DigestAuth: [] + - OAuth2Auth: [] + - NonExistentScheme: [] + +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 661ccf3..566691f 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,129 @@ 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, + ) + + // 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 = 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 override") + assertTrue(content.contains("Authorization"), "Should contain Authorization header for Basic auth") + + // 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() + 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 without auth`() { + 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() + assertFalse(content.contains("token"), "Should not contain token param") + assertFalse(content.contains("Bearer"), "Should not contain Bearer auth") + } + @Test fun `empty specs container logs warning`() { writeFile( @@ -517,4 +640,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/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 a6b915e..52f3203 100644 --- a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt +++ b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt @@ -8,7 +8,7 @@ import org.gradle.api.tasks.OutputDirectory 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. */ @CacheableTask @@ -20,9 +20,7 @@ abstract class JustworksSharedTypesTask : DefaultTask() { @TaskAction fun generate() { val outDir = outputDir.get().asFile.recreateDirectory() - val count = CodeGenerator.generateSharedTypes(outDir) - logger.lifecycle("Generated $count shared type files") } }