From 409cc7e60450525c6d70785f8c45838b9fd726c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Mon, 23 Mar 2026 11:04:12 +0100 Subject: [PATCH 01/17] test(05-01): add SecurityScheme model types and failing parser tests - SecurityScheme sealed interface with Bearer, ApiKey, Basic variants - ApiKeyLocation enum (HEADER, QUERY) - ApiSpec.securitySchemes field with default emptyList() - Test fixture YAML with all scheme types including unreferenced OAuth2 - Tests RED: parser doesn't extract security schemes yet Co-Authored-By: Claude Opus 4.6 (1M context) --- .../avsystem/justworks/core/model/ApiSpec.kt | 17 +++++ .../core/parser/SpecParserSecurityTest.kt | 70 +++++++++++++++++++ .../test/resources/security-schemes-spec.yaml | 43 ++++++++++++ 3 files changed, 130 insertions(+) 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/model/ApiSpec.kt b/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt index 2117a40..dd0150f 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 = emptyList(), ) data class Endpoint( 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 From 64a3464d97fadd902600b3b4b1613b0a957fd317 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Mon, 23 Mar 2026 11:06:29 +0100 Subject: [PATCH 02/17] feat(05-01): implement SpecParser security scheme extraction - Add extractSecuritySchemes() that reads from Swagger Parser SecurityScheme model - Only includes schemes referenced in global security array (not all defined) - Supports HTTP bearer, HTTP basic, and apiKey (header/query) types - Wire into OpenAPI.toApiSpec() to populate ApiSpec.securitySchemes - All 7 SpecParserSecurityTest tests pass, full suite green Co-Authored-By: Claude Opus 4.6 (1M context) --- .../justworks/core/parser/SpecParser.kt | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) 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 a9239d5..f63ff99 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 @@ -7,6 +7,7 @@ import arrow.core.raise.context.ensure import arrow.core.raise.context.ensureNotNull import arrow.core.raise.either import arrow.core.raise.nullable +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 @@ -20,6 +21,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 io.swagger.parser.OpenAPIParser import io.swagger.v3.oas.models.OpenAPI @@ -98,6 +100,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 } } @@ -123,10 +130,43 @@ object SpecParser { endpoints = endpoints, schemas = schemaModels, enums = enumModels, + securitySchemes = securitySchemes, ) } } + private fun extractSecuritySchemes( + definitions: Map, + requirements: List, + ): List { + val referencedNames = requirements.flatMap { it.keys }.toSet() + return referencedNames.mapNotNull { name -> + val scheme = definitions[name] ?: return@mapNotNull null + when (scheme.type) { + io.swagger.v3.oas.models.security.SecurityScheme.Type.HTTP -> { + when (scheme.scheme?.lowercase()) { + "bearer" -> SecurityScheme.Bearer(name) + "basic" -> SecurityScheme.Basic(name) + else -> null + } + } + + io.swagger.v3.oas.models.security.SecurityScheme.Type.APIKEY -> { + val location = when (scheme.`in`) { + io.swagger.v3.oas.models.security.SecurityScheme.In.HEADER -> ApiKeyLocation.HEADER + io.swagger.v3.oas.models.security.SecurityScheme.In.QUERY -> ApiKeyLocation.QUERY + else -> null + } ?: return@mapNotNull null + SecurityScheme.ApiKey(name, scheme.name, location) + } + + else -> { + null + } + } + } + } + context(_: ComponentSchemaIdentity, _: ComponentSchemas) private fun extractEndpoints(paths: Map): List = paths .asSequence() From 78c62f4b56b38ba93d65ba4ca1ee7e982040afb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Mon, 23 Mar 2026 11:13:50 +0100 Subject: [PATCH 03/17] feat(05-02): dynamic auth generation in ApiClientBaseGenerator - generate() accepts List? param (null = backward compat) - Bearer single-scheme uses 'token' param name for backward compat - ApiKey HEADER/QUERY generates correct header/query appends in applyAuth - HTTP Basic generates Base64 Authorization header with username/password - Multi-scheme support: all auth types coexist in single applyAuth - Empty schemes list = no auth (spec with no security) - Add BASE64_CLASS constant to Names.kt - 26 tests covering all scheme types, multi-scheme, empty, backward compat Co-Authored-By: Claude Opus 4.6 (1M context) --- .../core/gen/ApiClientBaseGenerator.kt | 186 +++++++++++++++--- .../com/avsystem/justworks/core/gen/Names.kt | 1 + .../core/gen/ApiClientBaseGeneratorTest.kt | 152 +++++++++++++- 3 files changed, 311 insertions(+), 28 deletions(-) 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..2873d55 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? = null): 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 ?: emptyList(), isExplicit = securitySchemes != null)) .build() } @@ -103,26 +105,45 @@ object ApiClientBaseGenerator { .addStatement("return %L { Unit }", MAP_TO_RESULT) .build() - private fun buildApiClientBaseClass(): TypeSpec { + private fun buildApiClientBaseClass(securitySchemes: List, isExplicit: Boolean): 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() + if (authParams.isEmpty() && !isExplicit) { + // Backward compat: no securitySchemes info -> default token param + constructorBuilder.addParameter(TOKEN, tokenType) + propertySpecs.add( + PropertySpec + .builder(TOKEN, tokenType) + .initializer(TOKEN) + .addModifiers(KModifier.PRIVATE) + .build(), + ) + } else { + 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 +156,143 @@ 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, isExplicit)) .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> { + if (securitySchemes.isEmpty()) return emptyList() + + val isSingleBearer = securitySchemes.size == 1 && securitySchemes.first() is SecurityScheme.Bearer + + return securitySchemes.flatMap { scheme -> + when (scheme) { + 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, isExplicit: Boolean): FunSpec { + val builder = FunSpec + .builder(APPLY_AUTH) + .addModifiers(KModifier.PROTECTED) + .receiver(HTTP_REQUEST_BUILDER) + + // Explicitly empty schemes = no security at all -> empty applyAuth + if (isExplicit && securitySchemes.isEmpty()) { + return builder.build() + } + + // Backward compat: no schemes info means hardcoded Bearer with token param + if (securitySchemes.isEmpty()) { + builder.beginControlFlow("%M", HEADERS_FUN) + builder.addStatement( + "append(%T.Authorization, %P)", + HTTP_HEADERS, + CodeBlock.of($$"Bearer ${'$'}{$$TOKEN()}"), + ) + builder.endControlFlow() + return builder.build() + } + + val isSingleBearer = securitySchemes.size == 1 && securitySchemes.first() is SecurityScheme.Bearer + + 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 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/Names.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt index bb18fb1..7156b84 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 @@ -67,6 +67,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/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGeneratorTest.kt index b2f98a6..1ea9db8 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 @@ -19,7 +21,25 @@ class ApiClientBaseGeneratorTest { 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`() { @@ -131,4 +151,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 (backward compat)") + } + + @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") + } } From a65fd45b841367b0d3d518a8e3c85650b54751e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Mon, 23 Mar 2026 11:17:48 +0100 Subject: [PATCH 04/17] feat(05-02): security-aware constructor generation in ClientGenerator - ClientGenerator reads spec.securitySchemes for constructor param generation - Reuses ApiClientBaseGenerator.buildAuthConstructorParams for consistent naming - CodeGenerator.generateSharedTypes accepts securitySchemes parameter - Empty securitySchemes preserves backward-compat token constructor param - Non-empty schemes generate matching params passed to super constructor - 6 new tests for security-aware client constructor generation Co-Authored-By: Claude Opus 4.6 (1M context) --- .../justworks/core/gen/ClientGenerator.kt | 35 ++++-- .../justworks/core/gen/CodeGenerator.kt | 5 +- .../justworks/core/gen/ClientGeneratorTest.kt | 104 +++++++++++++++++- 3 files changed, 129 insertions(+), 15 deletions(-) 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..a1312a6 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 = emptyList(), ): FileSpec { val className = ClassName(apiPackage, "${tag.toPascalCase()}$API_SUFFIX") @@ -52,12 +56,27 @@ 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) + + if (authParams.isEmpty() && securitySchemes.isEmpty()) { + // Backward compat: no security info -> default token param + constructorBuilder.addParameter(TOKEN, tokenType) + classBuilder.addSuperclassConstructorParameter(TOKEN) + } else { + for ((paramName, _) in authParams) { + constructorBuilder.addParameter(paramName, tokenType) + classBuilder.addSuperclassConstructorParameter(paramName) + } + } val httpClientProperty = PropertySpec .builder(CLIENT, HTTP_CLIENT) @@ -65,12 +84,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..83acedd 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 @@ -1,6 +1,7 @@ package com.avsystem.justworks.core.gen import com.avsystem.justworks.core.model.ApiSpec +import com.avsystem.justworks.core.model.SecurityScheme import java.io.File /** @@ -27,8 +28,8 @@ object CodeGenerator { return Result(modelFiles.size, clientFiles.size) } - fun generateSharedTypes(outputDir: File): Int { - val files = ApiResponseGenerator.generate() + ApiClientBaseGenerator.generate() + fun generateSharedTypes(outputDir: File, securitySchemes: List = emptyList(),): Int { + val files = ApiResponseGenerator.generate() + ApiClientBaseGenerator.generate(securitySchemes) files.forEach { it.writeTo(outputDir) } return files.size } 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..42f4c05 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 @@ -425,4 +428,99 @@ class ClientGeneratorTest { val body = funSpec.body.toString() assertTrue(body.contains("toEmptyResult"), "Expected toEmptyResult call") } + + // -- SECU: Security-aware constructor generation -- + + @Test + fun `no securitySchemes generates backward compat constructor with baseUrl and token`() { + val cls = clientClass(listOf(endpoint())) + val constructor = assertNotNull(cls.primaryConstructor) + val paramNames = constructor.parameters.map { it.name } + assertEquals(listOf("baseUrl", "token"), 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 `empty securitySchemes generates backward compat constructor with token`() { + // Empty securitySchemes = backward compat (spec doesn't define security info) + // ClientGenerator always gets spec.securitySchemes which defaults to emptyList() + 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", "token"), + paramNames, + "Expected backward compat params for empty security schemes", + ) + } + + @Test + fun `single Bearer scheme uses token param name for backward compat`() { + 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 (backward compat)") + } } From eef4bd9a81efceb8cd6879a37833a6fba333b142 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Mon, 23 Mar 2026 11:31:43 +0100 Subject: [PATCH 05/17] feat(05-03): wire spec files into JustworksSharedTypesTask for security scheme extraction - Add specFiles ConfigurableFileCollection input to JustworksSharedTypesTask - Parse spec files to extract and deduplicate securitySchemes - Pass merged securitySchemes to CodeGenerator.generateSharedTypes (null when empty for backward compat) - Wire each spec's specFile into shared types task via JustworksPlugin - Change CodeGenerator.generateSharedTypes to accept nullable securitySchemes for backward compat distinction Co-Authored-By: Claude Opus 4.6 (1M context) --- .../justworks/core/gen/CodeGenerator.kt | 2 +- .../justworks/gradle/JustworksPlugin.kt | 5 +++ .../gradle/JustworksSharedTypesTask.kt | 35 +++++++++++++++++-- 3 files changed, 39 insertions(+), 3 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 83acedd..7255495 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 @@ -28,7 +28,7 @@ object CodeGenerator { return Result(modelFiles.size, clientFiles.size) } - fun generateSharedTypes(outputDir: File, securitySchemes: List = emptyList(),): Int { + fun generateSharedTypes(outputDir: File, securitySchemes: List? = null): Int { val files = ApiResponseGenerator.generate() + ApiClientBaseGenerator.generate(securitySchemes) files.forEach { it.writeTo(outputDir) } return files.size 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..bf8cdd0 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,34 @@ package com.avsystem.justworks.gradle import com.avsystem.justworks.core.gen.CodeGenerator +import com.avsystem.justworks.core.model.SecurityScheme +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 +37,23 @@ abstract class JustworksSharedTypesTask : DefaultTask() { fun generate() { val outDir = outputDir.get().asFile.recreateDirectory() - val count = CodeGenerator.generateSharedTypes(outDir) + val securitySchemes = extractSecuritySchemes() + + val count = CodeGenerator.generateSharedTypes(outDir, securitySchemes.ifEmpty { null }) logger.lifecycle("Generated $count shared type files") } + + private fun extractSecuritySchemes(): List = specFiles.files + .mapNotNull { file -> + when (val result = SpecParser.parse(file)) { + is ParseResult.Success -> result.apiSpec.securitySchemes + is ParseResult.Failure -> { + logger.warn("Failed to parse spec '${file.name}': ${result.errors.joinToString()}") + null + } + } + } + .flatten() + .distinctBy { it.name } } From 5b03af72ab3ae27e6d66e460cfd814bea69d31b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Mon, 23 Mar 2026 11:36:39 +0100 Subject: [PATCH 06/17] test(05-03): add functional tests for security schemes in plugin-generated ApiClientBase - Test spec with apiKey+basic auth generates ApiClientBase with correct auth params - Test spec without security schemes generates backward-compatible Bearer auth - Verify generated applyAuth() contains correct header names and auth logic Co-Authored-By: Claude Opus 4.6 (1M context) --- .../gradle/JustworksPluginFunctionalTest.kt | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) 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..fb50eb7 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 backward-compatible ApiClientBase`() { + 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 contain backward-compat token param") + assertTrue(content.contains("Bearer"), "Should contain Bearer in applyAuth body") + } + @Test fun `empty specs container logs warning`() { writeFile( From b71be5d1e3f9133c2ede0486a826d84006951c5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Mon, 23 Mar 2026 12:10:59 +0100 Subject: [PATCH 07/17] feat(10-01): rewrite ApiResponseGenerator with sealed HttpError hierarchy - Replace flat HttpError data class with sealed class HttpError - Add 12 predefined HTTP error subtypes (BadRequest, Unauthorized, etc.) - Add Other and Network catch-all subtypes - Add HttpResult typealias as Either, HttpSuccess> - Add EITHER and HTTP_RESULT constants to Names.kt - Keep HTTP_ERROR_TYPE temporarily for ApiClientBaseGenerator compat Co-Authored-By: Claude Opus 4.6 (1M context) --- .../core/gen/ApiResponseGenerator.kt | 189 +++++++++++++++--- .../com/avsystem/justworks/core/gen/Names.kt | 5 +- 2 files changed, 166 insertions(+), 28 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiResponseGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiResponseGenerator.kt index f983258..a16cb39 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiResponseGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiResponseGenerator.kt @@ -4,56 +4,191 @@ import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.INT import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.NOTHING +import com.squareup.kotlinpoet.ParameterSpec +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.PropertySpec -import com.squareup.kotlinpoet.STRING +import com.squareup.kotlinpoet.THROWABLE +import com.squareup.kotlinpoet.TypeAliasSpec import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.TypeVariableName /** * Generates [FileSpec]s containing: - * - `HttpErrorType` enum class with Client, Server, Network values - * - `HttpError` data class with code, message, type fields + * - `HttpError` sealed class hierarchy with predefined HTTP error subtypes + * - `HttpResult` typealias as `Either, HttpSuccess>` * - `HttpSuccess` data class wrapping successful responses */ object ApiResponseGenerator { private const val CODE = "code" - private const val MESSAGE = "message" - private const val TYPE = "type" + + private val HTTP_ERROR_SUBTYPES = listOf( + "BadRequest" to 400, + "Unauthorized" to 401, + "Forbidden" to 403, + "NotFound" to 404, + "MethodNotAllowed" to 405, + "Conflict" to 409, + "Gone" to 410, + "UnprocessableEntity" to 422, + "TooManyRequests" to 429, + "InternalServerError" to 500, + "BadGateway" to 502, + "ServiceUnavailable" to 503, + ) fun generate(): List = listOf(generateHttpError(), generateHttpSuccess()) fun generateHttpError(): FileSpec { - val enumType = TypeSpec - .enumBuilder(HTTP_ERROR_TYPE) - .addEnumConstant("Client") - .addEnumConstant("Server") - .addEnumConstant("Redirect") - .addEnumConstant("Network") - .build() - - val primaryConstructor = FunSpec - .constructorBuilder() - .addParameter(CODE, INT) - .addParameter(MESSAGE, STRING) - .addParameter(TYPE, HTTP_ERROR_TYPE) - .build() + val b = TypeVariableName("B", variance = KModifier.OUT) - val dataClassType = TypeSpec + val sealedClass = TypeSpec .classBuilder(HTTP_ERROR) - .addModifiers(KModifier.DATA) - .primaryConstructor(primaryConstructor) - .addProperty(PropertySpec.builder(CODE, INT).initializer(CODE).build()) - .addProperty(PropertySpec.builder(MESSAGE, STRING).initializer(MESSAGE).build()) - .addProperty(PropertySpec.builder(TYPE, HTTP_ERROR_TYPE).initializer(TYPE).build()) + .addModifiers(KModifier.SEALED) + .addTypeVariable(b) + .addProperty( + PropertySpec + .builder(CODE, INT) + .addModifiers(KModifier.ABSTRACT) + .build(), + ).addProperty( + PropertySpec + .builder(BODY, b.copy(nullable = true)) + .addModifiers(KModifier.ABSTRACT) + .build(), + ) + + // Predefined HTTP error subtypes with body + for ((name, statusCode) in HTTP_ERROR_SUBTYPES) { + sealedClass.addType(buildBodySubtype(name, statusCode)) + } + + // Other: both code and body in constructor + sealedClass.addType(buildOtherSubtype()) + + // Network: no type variable, extends HttpError + sealedClass.addType(buildNetworkSubtype()) + + // HttpResult typealias + val e = TypeVariableName("E") + val t = TypeVariableName("T") + val httpResultAlias = TypeAliasSpec + .builder( + "HttpResult", + EITHER.parameterizedBy( + HTTP_ERROR.parameterizedBy(e), + HTTP_SUCCESS.parameterizedBy(t), + ), + ).addTypeVariable(e) + .addTypeVariable(t) .build() return FileSpec .builder(HTTP_ERROR) - .addType(enumType) - .addType(dataClassType) + .addType(sealedClass.build()) + .addTypeAlias(httpResultAlias) .build() } + private fun buildBodySubtype(name: String, statusCode: Int): TypeSpec { + val b = TypeVariableName("B", variance = KModifier.OUT) + return TypeSpec + .classBuilder(name) + .addModifiers(KModifier.DATA) + .addTypeVariable(b) + .superclass(HTTP_ERROR.parameterizedBy(b)) + .primaryConstructor( + FunSpec + .constructorBuilder() + .addParameter(BODY, b) + .build(), + ).addProperty( + PropertySpec + .builder(BODY, b) + .initializer(BODY) + .addModifiers(KModifier.OVERRIDE) + .build(), + ).addProperty( + PropertySpec + .builder(CODE, INT) + .addModifiers(KModifier.OVERRIDE) + .getter( + FunSpec + .getterBuilder() + .addStatement("return %L", statusCode) + .build(), + ).build(), + ).build() + } + + private fun buildOtherSubtype(): TypeSpec { + val b = TypeVariableName("B", variance = KModifier.OUT) + return TypeSpec + .classBuilder("Other") + .addModifiers(KModifier.DATA) + .addTypeVariable(b) + .superclass(HTTP_ERROR.parameterizedBy(b)) + .primaryConstructor( + FunSpec + .constructorBuilder() + .addParameter(CODE, INT) + .addParameter(BODY, b) + .build(), + ).addProperty( + PropertySpec + .builder(CODE, INT) + .initializer(CODE) + .addModifiers(KModifier.OVERRIDE) + .build(), + ).addProperty( + PropertySpec + .builder(BODY, b) + .initializer(BODY) + .addModifiers(KModifier.OVERRIDE) + .build(), + ).build() + } + + private fun buildNetworkSubtype(): TypeSpec = TypeSpec + .classBuilder("Network") + .addModifiers(KModifier.DATA) + .superclass(HTTP_ERROR.parameterizedBy(NOTHING)) + .primaryConstructor( + FunSpec + .constructorBuilder() + .addParameter( + ParameterSpec + .builder("cause", THROWABLE.copy(nullable = true)) + .defaultValue("null") + .build(), + ).build(), + ).addProperty( + PropertySpec + .builder("cause", THROWABLE.copy(nullable = true)) + .initializer("cause") + .build(), + ).addProperty( + PropertySpec + .builder(CODE, INT) + .addModifiers(KModifier.OVERRIDE) + .getter( + FunSpec + .getterBuilder() + .addStatement("return 0") + .build(), + ).build(), + ).addProperty( + PropertySpec + .builder(BODY, NOTHING.copy(nullable = true)) + .addModifiers(KModifier.OVERRIDE) + .getter( + FunSpec + .getterBuilder() + .addStatement("return null") + .build(), + ).build(), + ).build() + fun generateHttpSuccess(): FileSpec { val t = TypeVariableName("T") 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 7156b84..6108309 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 @@ -59,9 +59,12 @@ val LOCAL_DATE = ClassName("kotlinx.datetime", "LocalDate") val RAISE = ClassName("arrow.core.raise", "Raise") val RAISE_FUN = MemberName("arrow.core.raise.context", "raise") +val EITHER = ClassName("arrow.core", "Either") + val HTTP_ERROR = ClassName("com.avsystem.justworks", "HttpError") -val HTTP_ERROR_TYPE = ClassName("com.avsystem.justworks", "HttpErrorType") +val HTTP_ERROR_TYPE = ClassName("com.avsystem.justworks", "HttpErrorType") // TODO: remove in Plan 02 val HTTP_SUCCESS = ClassName("com.avsystem.justworks", "HttpSuccess") +val HTTP_RESULT = ClassName("com.avsystem.justworks", "HttpResult") // ============================================================================ // Kotlin stdlib From 8d476d3147efa3063b49a8911c97a736d4f93374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Mon, 23 Mar 2026 12:12:47 +0100 Subject: [PATCH 08/17] test(10-01): rewrite ApiResponseGeneratorTest for sealed HttpError hierarchy - Test sealed class modifiers and type variable B with OUT variance - Test abstract code and body properties - Test all 14 subtypes (12 HTTP codes + Other + Network) - Test BadRequest body/code, Other dual constructor, Network cause param - Test HttpResult typealias with Either, HttpSuccess> - Test HttpErrorType enum absence - Test HttpSuccess unchanged Co-Authored-By: Claude Opus 4.6 (1M context) --- .../core/gen/ApiResponseGeneratorTest.kt | 169 ++++++++++++++---- 1 file changed, 139 insertions(+), 30 deletions(-) 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 f4fd938..501dd90 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 @@ -1,6 +1,7 @@ package com.avsystem.justworks.core.gen import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.TypeAliasSpec import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.TypeVariableName import kotlin.test.Test @@ -9,68 +10,176 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue class ApiResponseGeneratorTest { - private fun httpErrorClass(): TypeSpec { - val files = listOf(ApiResponseGenerator.generateHttpError(), ApiResponseGenerator.generateHttpSuccess()) - val httpErrorFile = files.first { it.name == "HttpError" } - return httpErrorFile.members.filterIsInstance().first { it.name == "HttpError" } - } + private val files = ApiResponseGenerator.generate() + private val httpErrorFile = files.first { it.name == "HttpError" } - private fun httpErrorTypeEnum(): TypeSpec { - val files = listOf(ApiResponseGenerator.generateHttpError(), ApiResponseGenerator.generateHttpSuccess()) - val httpErrorFile = files.first { it.name == "HttpError" } - return httpErrorFile.members.filterIsInstance().first { it.name == "HttpErrorType" } - } + private fun httpErrorClass(): TypeSpec = + httpErrorFile.members.filterIsInstance().first { it.name == "HttpError" } + + private fun httpResultAlias(): TypeAliasSpec = + httpErrorFile.members.filterIsInstance().first { it.name == "HttpResult" } private fun successClass(): TypeSpec { - val files = listOf(ApiResponseGenerator.generateHttpError(), ApiResponseGenerator.generateHttpSuccess()) val successFile = files.first { it.name == "HttpSuccess" } return successFile.members.filterIsInstance().first() } @Test - fun `generates data class HttpError`() { + fun `HttpError is a sealed class`() { val typeSpec = httpErrorClass() assertEquals("HttpError", typeSpec.name) - assertTrue(KModifier.DATA in typeSpec.modifiers, "Expected DATA modifier") + assertTrue(KModifier.SEALED in typeSpec.modifiers, "Expected SEALED modifier") + assertTrue(KModifier.DATA !in typeSpec.modifiers, "Should NOT have DATA modifier") + } + + @Test + fun `HttpError has type variable B with out variance`() { + val typeSpec = httpErrorClass() + assertEquals(1, typeSpec.typeVariables.size) + val typeVar = typeSpec.typeVariables.first() + assertEquals("B", typeVar.name) + assertTrue(typeVar.variance == KModifier.OUT, "Expected OUT variance on B") + } + + @Test + fun `HttpError has abstract code and body properties`() { + val typeSpec = httpErrorClass() + val codeProp = typeSpec.propertySpecs.first { it.name == "code" } + assertTrue(KModifier.ABSTRACT in codeProp.modifiers, "code should be abstract") + assertEquals("kotlin.Int", codeProp.type.toString()) + + val bodyProp = typeSpec.propertySpecs.first { it.name == "body" } + assertTrue(KModifier.ABSTRACT in bodyProp.modifiers, "body should be abstract") + assertEquals("B?", bodyProp.type.toString()) + } + + @Test + fun `HttpError has all predefined subtypes`() { + val typeSpec = httpErrorClass() + val subtypeNames = typeSpec.typeSpecs.mapNotNull { it.name }.sorted() + val expected = listOf( + "BadGateway", + "BadRequest", + "Conflict", + "Forbidden", + "Gone", + "InternalServerError", + "MethodNotAllowed", + "Network", + "NotFound", + "Other", + "ServiceUnavailable", + "TooManyRequests", + "Unauthorized", + "UnprocessableEntity", + ) + assertEquals(expected, subtypeNames) + assertEquals(14, subtypeNames.size) + } + + @Test + fun `predefined subtypes are data classes`() { + val typeSpec = httpErrorClass() + val badRequest = typeSpec.typeSpecs.first { it.name == "BadRequest" } + assertTrue(KModifier.DATA in badRequest.modifiers, "BadRequest should be DATA") + + val other = typeSpec.typeSpecs.first { it.name == "Other" } + assertTrue(KModifier.DATA in other.modifiers, "Other should be DATA") + + val network = typeSpec.typeSpecs.first { it.name == "Network" } + assertTrue(KModifier.DATA in network.modifiers, "Network should be DATA") } @Test - fun `HttpError data class has code message and type fields`() { + fun `BadRequest subtype has body parameter and code 400`() { val typeSpec = httpErrorClass() - val constructor = assertNotNull(typeSpec.primaryConstructor) - assertEquals(3, constructor.parameters.size) - val codeParam = constructor.parameters.first { it.name == "code" } - assertEquals("kotlin.Int", codeParam.type.toString()) - val messageParam = constructor.parameters.first { it.name == "message" } - assertEquals("kotlin.String", messageParam.type.toString()) - val typeParam = constructor.parameters.first { it.name == "type" } - assertEquals("com.avsystem.justworks.HttpErrorType", typeParam.type.toString()) + val badRequest = typeSpec.typeSpecs.first { it.name == "BadRequest" } + + val constructor = assertNotNull(badRequest.primaryConstructor) + assertEquals(1, constructor.parameters.size) + assertEquals("body", constructor.parameters.first().name) + + val codeProp = badRequest.propertySpecs.first { it.name == "code" } + assertTrue(KModifier.OVERRIDE in codeProp.modifiers) + assertNotNull(codeProp.getter, "code should have a getter") + assertTrue(codeProp.getter.toString().contains("400"), "code getter should return 400") + } + + @Test + fun `Other subtype has both code and body in constructor`() { + val typeSpec = httpErrorClass() + val other = typeSpec.typeSpecs.first { it.name == "Other" } + + val constructor = assertNotNull(other.primaryConstructor) + assertEquals(2, constructor.parameters.size) + val paramNames = constructor.parameters.map { it.name } + assertTrue("code" in paramNames, "Other should have code param") + assertTrue("body" in paramNames, "Other should have body param") + } + + @Test + fun `Network subtype has cause parameter and no type variable`() { + val typeSpec = httpErrorClass() + val network = typeSpec.typeSpecs.first { it.name == "Network" } + + assertTrue(network.typeVariables.isEmpty(), "Network should have no type variables") + + val constructor = assertNotNull(network.primaryConstructor) + assertEquals(1, constructor.parameters.size) + val causeParam = constructor.parameters.first() + assertEquals("cause", causeParam.name) + assertTrue(causeParam.type.toString().contains("Throwable"), "cause should be Throwable?") + assertTrue(causeParam.type.isNullable, "cause should be nullable") + + val codeProp = network.propertySpecs.first { it.name == "code" } + assertTrue(KModifier.OVERRIDE in codeProp.modifiers) + assertNotNull(codeProp.getter, "code should have a getter") + assertTrue(codeProp.getter.toString().contains("0"), "code getter should return 0") + + val bodyProp = network.propertySpecs.first { it.name == "body" } + assertTrue(KModifier.OVERRIDE in bodyProp.modifiers) + assertNotNull(bodyProp.getter, "body should have a getter") + assertTrue(bodyProp.getter.toString().contains("null"), "body getter should return null") } @Test - fun `generates HttpErrorType enum with four values`() { - val typeSpec = httpErrorTypeEnum() - assertEquals("HttpErrorType", typeSpec.name) - val constantNames = typeSpec.enumConstants.keys.sorted() - assertEquals(listOf("Client", "Network", "Redirect", "Server"), constantNames) + fun `HttpResult typealias is generated`() { + val alias = httpResultAlias() + assertEquals("HttpResult", alias.name) + assertEquals(2, alias.typeVariables.size) + assertEquals("E", alias.typeVariables[0].name) + assertEquals("T", alias.typeVariables[1].name) + assertTrue(alias.type.toString().contains("Either"), "Should reference Either") + assertTrue(alias.type.toString().contains("HttpError"), "Should reference HttpError") + assertTrue(alias.type.toString().contains("HttpSuccess"), "Should reference HttpSuccess") } @Test - fun `Success is a data class with body and statusCode`() { + fun `HttpErrorType enum is not generated`() { + val allTypes = httpErrorFile.members.filterIsInstance() + assertTrue(allTypes.none { it.name == "HttpErrorType" }, "HttpErrorType should not exist") + } + + @Test + fun `HttpSuccess is unchanged`() { val success = successClass() assertEquals("HttpSuccess", success.name) - assertTrue(KModifier.DATA in success.modifiers, "Expected DATA modifier on Success") + assertTrue(KModifier.DATA in success.modifiers, "Expected DATA modifier on HttpSuccess") + + assertEquals(1, success.typeVariables.size) + assertEquals("T", success.typeVariables.first().name) + val constructor = assertNotNull(success.primaryConstructor) val paramNames = constructor.parameters.map { it.name } assertTrue("body" in paramNames, "Expected 'body' parameter") assertTrue("code" in paramNames, "Expected 'code' parameter") + val bodyParam = constructor.parameters.first { it.name == "body" } assertTrue(bodyParam.type is TypeVariableName, "body should be type variable T") } @Test fun `generates two files`() { - val files = listOf(ApiResponseGenerator.generateHttpError(), ApiResponseGenerator.generateHttpSuccess()) assertEquals(2, files.size) val fileNames = files.map { it.name }.sorted() assertEquals(listOf("HttpError", "HttpSuccess"), fileNames) From d0f0e6f5d45906e944d8d01d70b6c86867847480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Mon, 23 Mar 2026 12:18:28 +0100 Subject: [PATCH 09/17] feat(10-02): rewrite generators for Either-based error returns - mapToResult branches on 12 specific HTTP status codes + Other catchall - deserializeErrorBody helper with try/catch fallback to null - safeCall returns Either.Left(HttpError.Network) on IOException/timeout - ClientGenerator endpoints return HttpResult without Raise context - toResult/toEmptyResult use two type variables (E, T) with no context parameters - Remove HTTP_ERROR_TYPE constant (no longer needed) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../core/gen/ApiClientBaseGenerator.kt | 173 ++++++++++++------ .../justworks/core/gen/ClientGenerator.kt | 7 +- .../com/avsystem/justworks/core/gen/Names.kt | 2 +- 3 files changed, 123 insertions(+), 59 deletions(-) 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 2873d55..b206f42 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 @@ -3,7 +3,6 @@ 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 import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.FunSpec @@ -20,9 +19,10 @@ import com.squareup.kotlinpoet.UNIT /** * Generates the shared `ApiClientBase.kt` file containing: * - `encodeParam()` top-level utility function - * - `HttpResponse.mapToResult()` private extension with response mapping logic - * - `HttpResponse.toResult()` extension for typed response mapping - * - `HttpResponse.toEmptyResult()` extension for Unit response mapping + * - `HttpResponse.deserializeErrorBody()` internal helper for error body deserialization + * - `HttpResponse.mapToResult()` private extension with response mapping logic + * - `HttpResponse.toResult()` extension for typed response mapping + * - `HttpResponse.toEmptyResult()` extension for Unit response mapping * - `ApiClientBase` abstract class with common client infrastructure */ @OptIn(ExperimentalKotlinPoetApi::class) @@ -34,13 +34,15 @@ object ApiClientBaseGenerator { fun generate(securitySchemes: List? = null): FileSpec { val t = TypeVariableName("T").copy(reified = true) + val e = TypeVariableName("E").copy(reified = true) return FileSpec .builder(API_CLIENT_BASE) .addFunction(buildEncodeParam(t)) - .addFunction(buildMapToResult(t)) - .addFunction(buildToResult(t)) - .addFunction(buildToEmptyResult()) + .addFunction(buildDeserializeErrorBody(e)) + .addFunction(buildMapToResult(e, t)) + .addFunction(buildToResult(e, t)) + .addFunction(buildToEmptyResult(e)) .addType(buildApiClientBaseClass(securitySchemes ?: emptyList(), isExplicit = securitySchemes != null)) .build() } @@ -54,54 +56,119 @@ object ApiClientBaseGenerator { .addStatement("return %T.%M(value).trim('\"')", JSON_CLASS, ENCODE_TO_STRING_FUN) .build() - private fun buildMapToResult(t: TypeVariableName): FunSpec = FunSpec + private fun buildDeserializeErrorBody(e: TypeVariableName): FunSpec = FunSpec + .builder("deserializeErrorBody") + .addAnnotation(PublishedApi::class) + .addModifiers(KModifier.INTERNAL, KModifier.SUSPEND, KModifier.INLINE) + .addTypeVariable(e) + .receiver(HTTP_RESPONSE) + .returns(TypeVariableName("E").copy(nullable = true)) + .beginControlFlow("return try") + .addStatement("%M()", BODY_FUN) + .nextControlFlow("catch (_: %T)", Exception::class) + .addStatement("null") + .endControlFlow() + .build() + + private fun buildMapToResult(e: TypeVariableName, t: TypeVariableName): FunSpec = FunSpec .builder(MAP_TO_RESULT) .addAnnotation(PublishedApi::class) .addModifiers(KModifier.INTERNAL, KModifier.SUSPEND, KModifier.INLINE) + .addTypeVariable(e) .addTypeVariable(t) .receiver(HTTP_RESPONSE) - .contextParameters(listOf(ContextParameter(RAISE.parameterizedBy(HTTP_ERROR)))) .addParameter(SUCCESS_BODY, LambdaTypeName.get(returnType = TypeVariableName("T"))) - .returns(HTTP_SUCCESS.parameterizedBy(TypeVariableName("T"))) + .returns(HTTP_RESULT.parameterizedBy(TypeVariableName("E"), TypeVariableName("T"))) .beginControlFlow("return when (status.value)") - .addStatement("in 200..299 -> %T(status.value, %L())", HTTP_SUCCESS, SUCCESS_BODY) .addStatement( - "in 300..399 -> %M(%T(status.value, %M(), %T.Redirect))", - RAISE_FUN, + "in 200..299 -> %T.Right(%T(status.value, %L()))", + EITHER, + HTTP_SUCCESS, + SUCCESS_BODY, + ).addStatement( + "400 -> %T.Left(%T.BadRequest(%M()))", + EITHER, + HTTP_ERROR, + DESERIALIZE_ERROR_BODY_FUN, + ).addStatement( + "401 -> %T.Left(%T.Unauthorized(%M()))", + EITHER, + HTTP_ERROR, + DESERIALIZE_ERROR_BODY_FUN, + ).addStatement( + "403 -> %T.Left(%T.Forbidden(%M()))", + EITHER, + HTTP_ERROR, + DESERIALIZE_ERROR_BODY_FUN, + ).addStatement( + "404 -> %T.Left(%T.NotFound(%M()))", + EITHER, + HTTP_ERROR, + DESERIALIZE_ERROR_BODY_FUN, + ).addStatement( + "405 -> %T.Left(%T.MethodNotAllowed(%M()))", + EITHER, + HTTP_ERROR, + DESERIALIZE_ERROR_BODY_FUN, + ).addStatement( + "409 -> %T.Left(%T.Conflict(%M()))", + EITHER, + HTTP_ERROR, + DESERIALIZE_ERROR_BODY_FUN, + ).addStatement( + "410 -> %T.Left(%T.Gone(%M()))", + EITHER, HTTP_ERROR, - BODY_AS_TEXT_FUN, - HTTP_ERROR_TYPE, + DESERIALIZE_ERROR_BODY_FUN, ).addStatement( - "in 400..499 -> %M(%T(status.value, %M(), %T.Client))", - RAISE_FUN, + "422 -> %T.Left(%T.UnprocessableEntity(%M()))", + EITHER, HTTP_ERROR, - BODY_AS_TEXT_FUN, - HTTP_ERROR_TYPE, + DESERIALIZE_ERROR_BODY_FUN, ).addStatement( - "else -> %M(%T(status.value, %M(), %T.Server))", - RAISE_FUN, + "429 -> %T.Left(%T.TooManyRequests(%M()))", + EITHER, HTTP_ERROR, - BODY_AS_TEXT_FUN, - HTTP_ERROR_TYPE, + DESERIALIZE_ERROR_BODY_FUN, + ).addStatement( + "500 -> %T.Left(%T.InternalServerError(%M()))", + EITHER, + HTTP_ERROR, + DESERIALIZE_ERROR_BODY_FUN, + ).addStatement( + "502 -> %T.Left(%T.BadGateway(%M()))", + EITHER, + HTTP_ERROR, + DESERIALIZE_ERROR_BODY_FUN, + ).addStatement( + "503 -> %T.Left(%T.ServiceUnavailable(%M()))", + EITHER, + HTTP_ERROR, + DESERIALIZE_ERROR_BODY_FUN, + ).addStatement( + "else -> %T.Left(%T.Other(status.value, %M()))", + EITHER, + HTTP_ERROR, + DESERIALIZE_ERROR_BODY_FUN, ).endControlFlow() .build() - private fun buildToResult(t: TypeVariableName): FunSpec = FunSpec + private fun buildToResult(e: TypeVariableName, t: TypeVariableName): FunSpec = FunSpec .builder("toResult") .addModifiers(KModifier.SUSPEND, KModifier.INLINE) + .addTypeVariable(e) .addTypeVariable(t) .receiver(HTTP_RESPONSE) - .contextParameters(listOf(ContextParameter(RAISE.parameterizedBy(HTTP_ERROR)))) - .returns(HTTP_SUCCESS.parameterizedBy(TypeVariableName("T"))) + .returns(HTTP_RESULT.parameterizedBy(TypeVariableName("E"), TypeVariableName("T"))) .addStatement("return %L { %M() }", MAP_TO_RESULT, BODY_FUN) .build() - private fun buildToEmptyResult(): FunSpec = FunSpec + private fun buildToEmptyResult(e: TypeVariableName): FunSpec = FunSpec .builder("toEmptyResult") - .addModifiers(KModifier.SUSPEND) + .addModifiers(KModifier.SUSPEND, KModifier.INLINE) + .addTypeVariable(e) .receiver(HTTP_RESPONSE) - .contextParameters(listOf(ContextParameter(RAISE.parameterizedBy(HTTP_ERROR)))) - .returns(HTTP_SUCCESS.parameterizedBy(UNIT)) + .returns(HTTP_RESULT.parameterizedBy(TypeVariableName("E"), UNIT)) .addStatement("return %L { Unit }", MAP_TO_RESULT) .build() @@ -294,30 +361,28 @@ object ApiClientBaseGenerator { return builder.build() } - private fun buildSafeCall(): FunSpec = FunSpec - .builder(SAFE_CALL) - .addModifiers(KModifier.PROTECTED, KModifier.SUSPEND) - .contextParameters(listOf(ContextParameter(RAISE.parameterizedBy(HTTP_ERROR)))) - .addParameter(BLOCK, LambdaTypeName.get(returnType = HTTP_RESPONSE).copy(suspending = true)) - .returns(HTTP_RESPONSE) - .beginControlFlow("return try") - .addStatement("%L()", BLOCK) - .nextControlFlow("catch (e: %T)", IO_EXCEPTION) - .addStatement( - "%M(%T(0, e.message ?: %S, %T.Network))", - RAISE_FUN, - HTTP_ERROR, - NETWORK_ERROR, - HTTP_ERROR_TYPE, - ).nextControlFlow("catch (e: %T)", HTTP_REQUEST_TIMEOUT_EXCEPTION) - .addStatement( - "%M(%T(0, e.message ?: %S, %T.Network))", - RAISE_FUN, - HTTP_ERROR, - NETWORK_ERROR, - HTTP_ERROR_TYPE, - ).endControlFlow() - .build() + private fun buildSafeCall(): FunSpec { + val e = TypeVariableName("E").copy(reified = true) + val t = TypeVariableName("T").copy(reified = true) + val resultType = HTTP_RESULT.parameterizedBy(TypeVariableName("E"), TypeVariableName("T")) + val blockType = LambdaTypeName.get(returnType = resultType).copy(suspending = true) + + return FunSpec + .builder(SAFE_CALL) + .addModifiers(KModifier.PROTECTED, KModifier.SUSPEND, KModifier.INLINE) + .addTypeVariable(e) + .addTypeVariable(t) + .addParameter(BLOCK, blockType) + .returns(resultType) + .beginControlFlow("return try") + .addStatement("%L()", BLOCK) + .nextControlFlow("catch (e: %T)", IO_EXCEPTION) + .addStatement("%T.Left(%T.Network(e))", EITHER, HTTP_ERROR) + .nextControlFlow("catch (e: %T)", HTTP_REQUEST_TIMEOUT_EXCEPTION) + .addStatement("%T.Left(%T.Network(e))", EITHER, HTTP_ERROR) + .endControlFlow() + .build() + } private fun buildCreateHttpClient(): FunSpec = FunSpec .builder(CREATE_HTTP_CLIENT) 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 a1312a6..d7f2a23 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 @@ -9,7 +9,6 @@ import com.avsystem.justworks.core.model.SecurityScheme import com.avsystem.justworks.core.model.TypeRef import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock -import com.squareup.kotlinpoet.ContextParameter import com.squareup.kotlinpoet.ExperimentalKotlinPoetApi import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.FunSpec @@ -99,12 +98,11 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: private fun generateEndpointFunction(endpoint: Endpoint): FunSpec { val functionName = endpoint.operationId.toCamelCase() val returnBodyType = resolveReturnType(endpoint) - val returnType = HTTP_SUCCESS.parameterizedBy(returnBodyType) + val returnType = HTTP_RESULT.parameterizedBy(JSON_ELEMENT, returnBodyType) val funBuilder = FunSpec .builder(functionName) .addModifiers(KModifier.SUSPEND) - .contextParameters(listOf(ContextParameter(RAISE.parameterizedBy(HTTP_ERROR)))) .returns(returnType) val params = endpoint.parameters.groupBy { it.location } @@ -206,9 +204,10 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: } } - code.endControlFlow() // client.METHOD + // Close client.METHOD block and chain .toResult() / .toEmptyResult() code.unindent() code.add("}.%M()\n", resultFun) + code.endControlFlow() // safeCall return code.build() } 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 6108309..31f8fef 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 @@ -62,9 +62,9 @@ val RAISE_FUN = MemberName("arrow.core.raise.context", "raise") val EITHER = ClassName("arrow.core", "Either") val HTTP_ERROR = ClassName("com.avsystem.justworks", "HttpError") -val HTTP_ERROR_TYPE = ClassName("com.avsystem.justworks", "HttpErrorType") // TODO: remove in Plan 02 val HTTP_SUCCESS = ClassName("com.avsystem.justworks", "HttpSuccess") val HTTP_RESULT = ClassName("com.avsystem.justworks", "HttpResult") +val DESERIALIZE_ERROR_BODY_FUN = MemberName("com.avsystem.justworks", "deserializeErrorBody") // ============================================================================ // Kotlin stdlib From 6f2027f0234122b63f50fe1649ced2e94d0ff6eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Mon, 23 Mar 2026 12:20:43 +0100 Subject: [PATCH 10/17] test(10-02): update tests for Either-based error returns - safeCall test: verify no context params, inline, Either.Left + HttpError.Network - toResult test: verify two reified type vars (E, T), no context, HttpResult return - toEmptyResult test: verify one reified type var (E), no context, HttpResult return - Add mapToResult status code branching test (all 12 codes + Other) - Add deserializeErrorBody helper test (inline, reified, try/catch) - ClientGenerator: return type is HttpResult not HttpSuccess - ClientGenerator: endpoint functions have no context parameters Co-Authored-By: Claude Opus 4.6 (1M context) --- .../core/gen/ApiClientBaseGeneratorTest.kt | 71 +++++++++++++++---- .../justworks/core/gen/ClientGeneratorTest.kt | 43 +++++++---- 2 files changed, 85 insertions(+), 29 deletions(-) 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 1ea9db8..b2920b1 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 @@ -89,15 +89,19 @@ class ApiClientBaseGeneratorTest { } @Test - fun `ApiClientBase has safeCall function`() { + fun `ApiClientBase has safeCall function with no context parameters`() { val safeCall = classSpec.funSpecs.first { it.name == "safeCall" } assertTrue(KModifier.PROTECTED in safeCall.modifiers) assertTrue(KModifier.SUSPEND in safeCall.modifiers) - assertTrue(safeCall.contextParameters.isNotEmpty(), "Expected context parameter") + assertTrue(KModifier.INLINE in safeCall.modifiers) + assertTrue(safeCall.contextParameters.isEmpty(), "Expected no context parameters") + assertEquals(2, safeCall.typeVariables.size, "Expected E and T type variables") + assertTrue(safeCall.typeVariables.all { it.isReified }, "Expected reified type variables") val body = safeCall.body.toString() assertTrue(body.contains("IOException"), "Expected IOException catch") assertTrue(body.contains("HttpRequestTimeoutException"), "Expected HttpRequestTimeoutException catch") - assertTrue(body.contains("Network error"), "Expected Network error message") + assertTrue(body.contains("Either.Left"), "Expected Either.Left in body") + assertTrue(body.contains("HttpError.Network"), "Expected HttpError.Network in body") } @Test @@ -123,28 +127,67 @@ class ApiClientBaseGeneratorTest { } @Test - fun `toResult is suspend inline with reified T and context parameter`() { + fun `toResult is suspend inline with reified E and T, no context parameter`() { val fn = topLevelFun("toResult") assertTrue(KModifier.SUSPEND in fn.modifiers) assertTrue(KModifier.INLINE in fn.modifiers) - val typeVar = fn.typeVariables.first() - assertTrue(typeVar.isReified, "Expected reified type variable") + assertEquals(2, fn.typeVariables.size, "Expected E and T type variables") + assertTrue(fn.typeVariables.all { it.isReified }, "Expected reified type variables") assertNotNull(fn.receiverType, "Expected HttpResponse receiver") - assertTrue(fn.contextParameters.isNotEmpty(), "Expected context parameter") - val contextType = fn.contextParameters.first().type - assertTrue(contextType is ParameterizedTypeName) - assertEquals("arrow.core.raise.Raise", contextType.rawType.toString()) + assertTrue(fn.contextParameters.isEmpty(), "Expected no context parameters") + val returnType = fn.returnType as ParameterizedTypeName + assertEquals("com.avsystem.justworks.HttpResult", returnType.rawType.toString()) } @Test - fun `toEmptyResult is suspend with context parameter and returns HttpSuccess Unit`() { + fun `toEmptyResult returns HttpResult E Unit with no context parameter`() { val fn = topLevelFun("toEmptyResult") assertTrue(KModifier.SUSPEND in fn.modifiers) + assertTrue(KModifier.INLINE in fn.modifiers) + assertEquals(1, fn.typeVariables.size, "Expected E type variable") + assertTrue(fn.typeVariables.first().isReified, "Expected reified type variable") assertNotNull(fn.receiverType, "Expected HttpResponse receiver") - assertTrue(fn.contextParameters.isNotEmpty(), "Expected context parameter") + assertTrue(fn.contextParameters.isEmpty(), "Expected no context parameters") val returnType = fn.returnType as ParameterizedTypeName - assertEquals("com.avsystem.justworks.HttpSuccess", returnType.rawType.toString()) - assertEquals("kotlin.Unit", returnType.typeArguments.first().toString()) + assertEquals("com.avsystem.justworks.HttpResult", returnType.rawType.toString()) + } + + @Test + fun `mapToResult branches on specific status codes`() { + val fn = topLevelFun("mapToResult") + val body = fn.body.toString() + assertTrue(body.contains("400 ->"), "Expected 400 branch") + assertTrue(body.contains("401 ->"), "Expected 401 branch") + assertTrue(body.contains("403 ->"), "Expected 403 branch") + assertTrue(body.contains("404 ->"), "Expected 404 branch") + assertTrue(body.contains("405 ->"), "Expected 405 branch") + assertTrue(body.contains("409 ->"), "Expected 409 branch") + assertTrue(body.contains("410 ->"), "Expected 410 branch") + assertTrue(body.contains("422 ->"), "Expected 422 branch") + assertTrue(body.contains("429 ->"), "Expected 429 branch") + assertTrue(body.contains("500 ->"), "Expected 500 branch") + assertTrue(body.contains("502 ->"), "Expected 502 branch") + assertTrue(body.contains("503 ->"), "Expected 503 branch") + assertTrue(body.contains("HttpError.BadRequest"), "Expected HttpError.BadRequest") + assertTrue(body.contains("HttpError.NotFound"), "Expected HttpError.NotFound") + assertTrue(body.contains("HttpError.InternalServerError"), "Expected HttpError.InternalServerError") + assertTrue(body.contains("HttpError.Other"), "Expected HttpError.Other catchall") + assertTrue(body.contains("Either.Right"), "Expected Either.Right for success") + assertTrue(body.contains("Either.Left"), "Expected Either.Left for errors") + } + + @Test + fun `deserializeErrorBody helper function exists`() { + val fn = topLevelFun("deserializeErrorBody") + assertTrue(KModifier.INTERNAL in fn.modifiers) + assertTrue(KModifier.INLINE in fn.modifiers) + assertTrue(KModifier.SUSPEND in fn.modifiers) + assertEquals(1, fn.typeVariables.size, "Expected E type variable") + assertTrue(fn.typeVariables.first().isReified, "Expected reified type variable") + assertNotNull(fn.receiverType, "Expected HttpResponse receiver") + val body = fn.body.toString() + assertTrue(body.contains("body"), "Expected body() call") + assertTrue(body.contains("catch"), "Expected catch block for fallback") } @Test 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 42f4c05..4aeb510 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 @@ -212,32 +212,36 @@ class ClientGeneratorTest { assertEquals("com.example.model.Pet", bodyParam.type.toString()) } - // -- CLNT-08: Return type is Success parameterized -- + // -- CLNT-08: Return type is HttpResult parameterized -- @Test - fun `return type is Success parameterized`() { + fun `return type is HttpResult parameterized`() { val cls = clientClass(listOf(endpoint())) val funSpec = cls.funSpecs.first { it.name == "listPets" } val returnType = funSpec.returnType assertNotNull(returnType) assertTrue(returnType is ParameterizedTypeName, "Expected ParameterizedTypeName") - assertEquals("com.avsystem.justworks.HttpSuccess", returnType.rawType.toString()) - assertEquals("com.example.model.Pet", returnType.typeArguments.first().toString()) + assertEquals("com.avsystem.justworks.HttpResult", returnType.rawType.toString()) + assertEquals( + "kotlinx.serialization.json.JsonElement", + returnType.typeArguments[0].toString(), + "Expected JsonElement as error type", + ) + assertEquals( + "com.example.model.Pet", + returnType.typeArguments[1].toString(), + "Expected Pet as success body type", + ) } - // -- Context receiver: Raise -- + // -- ERR-01: No Raise context on endpoint functions -- @OptIn(ExperimentalKotlinPoetApi::class) @Test - fun `endpoint functions have Raise HttpError context parameter`() { + fun `endpoint functions have no context parameters`() { val cls = clientClass(listOf(endpoint())) val funSpec = cls.funSpecs.first { it.name == "listPets" } - val contextParameters = funSpec.contextParameters - assertTrue(contextParameters.isNotEmpty(), "Expected context parameter") - val contextType = contextParameters.first().type - assertTrue(contextType is ParameterizedTypeName, "Expected parameterized Raise type") - assertEquals("arrow.core.raise.Raise", contextType.rawType.toString()) - assertEquals("com.avsystem.justworks.HttpError", contextType.typeArguments.first().toString()) + assertTrue(funSpec.contextParameters.isEmpty(), "Expected no context parameters") } // -- CLNT-09: Header parameters become function parameters -- @@ -326,7 +330,7 @@ class ClientGeneratorTest { // -- Pitfall 5: Void response uses Unit type parameter -- @Test - fun `void response uses Unit type parameter`() { + fun `void response uses HttpResult with Unit type parameter`() { val ep = endpoint( method = HttpMethod.DELETE, @@ -336,8 +340,17 @@ class ClientGeneratorTest { val cls = clientClass(listOf(ep)) val funSpec = cls.funSpecs.first { it.name == "deletePet" } val returnType = funSpec.returnType as ParameterizedTypeName - assertEquals("com.avsystem.justworks.HttpSuccess", returnType.rawType.toString()) - assertEquals("kotlin.Unit", returnType.typeArguments.first().toString()) + assertEquals("com.avsystem.justworks.HttpResult", returnType.rawType.toString()) + assertEquals( + "kotlinx.serialization.json.JsonElement", + returnType.typeArguments[0].toString(), + "Expected JsonElement as error type", + ) + assertEquals( + "kotlin.Unit", + returnType.typeArguments[1].toString(), + "Expected Unit as success body type", + ) } // -- Client class extends ApiClientBase -- From 442566c733ba8acd3d7d6cdd68ba31116bd6021a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Mon, 23 Mar 2026 12:31:48 +0100 Subject: [PATCH 11/17] test(10-03): add failing tests for error type resolution - Single error schema -> typed error in HttpResult - Multiple same error schemas -> typed error - Multiple different error schemas -> JsonElement fallback - Null error schema -> JsonElement fallback Co-Authored-By: Claude Opus 4.6 (1M context) --- .../justworks/core/gen/ClientGeneratorTest.kt | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) 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 4aeb510..9d44ec4 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 @@ -526,6 +526,82 @@ class ClientGeneratorTest { ) } + // -- ERR-01: Error type resolution from OpenAPI error response schemas -- + + @Test + fun `single error response schema generates typed error in HttpResult`() { + val ep = endpoint( + responses = mapOf( + "200" to Response("200", "OK", TypeRef.Reference("Pet")), + "400" to Response("400", "Bad request", TypeRef.Reference("ValidationError")), + ), + ) + val cls = clientClass(listOf(ep)) + val funSpec = cls.funSpecs.first { it.name == "listPets" } + val returnType = funSpec.returnType as ParameterizedTypeName + assertEquals( + "com.example.model.ValidationError", + returnType.typeArguments[0].toString(), + "Expected typed error for single error schema", + ) + } + + @Test + fun `multiple error responses with same schema generates typed error`() { + val ep = endpoint( + responses = mapOf( + "200" to Response("200", "OK", TypeRef.Reference("Pet")), + "400" to Response("400", "Bad request", TypeRef.Reference("ValidationError")), + "422" to Response("422", "Unprocessable", TypeRef.Reference("ValidationError")), + ), + ) + val cls = clientClass(listOf(ep)) + val funSpec = cls.funSpecs.first { it.name == "listPets" } + val returnType = funSpec.returnType as ParameterizedTypeName + assertEquals( + "com.example.model.ValidationError", + returnType.typeArguments[0].toString(), + "Expected typed error when all error schemas are the same", + ) + } + + @Test + fun `multiple error responses with different schemas falls back to JsonElement`() { + val ep = endpoint( + responses = mapOf( + "200" to Response("200", "OK", TypeRef.Reference("Pet")), + "400" to Response("400", "Bad request", TypeRef.Reference("ValidationError")), + "404" to Response("404", "Not found", TypeRef.Reference("NotFoundError")), + ), + ) + val cls = clientClass(listOf(ep)) + val funSpec = cls.funSpecs.first { it.name == "listPets" } + val returnType = funSpec.returnType as ParameterizedTypeName + assertEquals( + "kotlinx.serialization.json.JsonElement", + returnType.typeArguments[0].toString(), + "Expected JsonElement fallback for different error schemas", + ) + } + + @Test + fun `error response with null schema falls back to JsonElement`() { + val ep = endpoint( + responses = mapOf( + "200" to Response("200", "OK", TypeRef.Reference("Pet")), + "401" to Response("401", "Unauthorized", null), + ), + ) + val cls = clientClass(listOf(ep)) + val funSpec = cls.funSpecs.first { it.name == "listPets" } + val returnType = funSpec.returnType as ParameterizedTypeName + assertEquals( + "kotlinx.serialization.json.JsonElement", + returnType.typeArguments[0].toString(), + "Expected JsonElement fallback for null error schema", + ) + } + @Test fun `single Bearer scheme uses token param name for backward compat`() { val cls = clientClass( From c9b7bdd0ae3905ebb7604935e81cfbd292e8cdff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Mon, 23 Mar 2026 12:32:35 +0100 Subject: [PATCH 12/17] feat(10-03): add resolveErrorType() for typed error bodies in HttpResult - resolveErrorType() reads non-2xx response schemas from endpoint - Single shared error schema produces typed HttpResult - Multiple different schemas or no schemas fall back to JsonElement - Wired into generateEndpointFunction replacing hardcoded JSON_ELEMENT Co-Authored-By: Claude Opus 4.6 (1M context) --- .../justworks/core/gen/ClientGenerator.kt | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) 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 d7f2a23..6124868 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 @@ -98,7 +98,8 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: private fun generateEndpointFunction(endpoint: Endpoint): FunSpec { val functionName = endpoint.operationId.toCamelCase() val returnBodyType = resolveReturnType(endpoint) - val returnType = HTTP_RESULT.parameterizedBy(JSON_ELEMENT, returnBodyType) + val errorType = resolveErrorType(endpoint) + val returnType = HTTP_RESULT.parameterizedBy(errorType, returnBodyType) val funBuilder = FunSpec .builder(functionName) @@ -212,6 +213,21 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: return code.build() } + private fun resolveErrorType(endpoint: Endpoint): TypeName { + val errorSchemas = endpoint.responses.entries + .asSequence() + .filter { !it.key.startsWith("2") } + .mapNotNull { it.value.schema } + .map { TypeMapping.toTypeName(it, modelPackage) } + .distinct() + .toList() + + return when { + errorSchemas.size == 1 -> errorSchemas.single() + else -> JSON_ELEMENT + } + } + private fun resolveReturnType(endpoint: Endpoint): TypeName = endpoint.responses.entries .asSequence() .filter { it.key.startsWith("2") } From cf766ffbfbbfff76a0f86d70dc64d1b3ed5d6616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 2 Apr 2026 18:05:34 +0200 Subject: [PATCH 13/17] Merge feat/security-schemes into feat/typed-error-responses Integrate security-schemes refactoring (subpackage structure, NameRegistry, warning-based parser, lightweight parseSecuritySchemes) with typed error responses (sealed HttpError, Either-based HttpResult, resolveErrorType). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../avsystem/justworks/core/ArrowHelpers.kt | 34 + .../com/avsystem/justworks/core/Issue.kt | 16 + .../com/avsystem/justworks/core/Utils.kt | 5 + .../justworks/core/gen/ClientGenerator.kt | 250 ----- .../justworks/core/gen/CodeGenerator.kt | 21 +- .../core/gen/InlineSchemaDeduplicator.kt | 50 - .../justworks/core/gen/InlineSchemaKey.kt | 52 + .../justworks/core/gen/InlineTypeResolver.kt | 75 ++ .../justworks/core/gen/NameRegistry.kt | 32 + .../avsystem/justworks/core/gen/NameUtils.kt | 7 + .../com/avsystem/justworks/core/gen/Names.kt | 30 +- .../avsystem/justworks/core/gen/Packages.kt | 20 + .../justworks/core/gen/TypeMapping.kt | 58 -- .../com/avsystem/justworks/core/gen/Utils.kt | 69 ++ .../core/gen/client/BodyGenerator.kt | 244 +++++ .../core/gen/client/ClientGenerator.kt | 198 ++++ .../core/gen/client/ParametersGenerator.kt | 59 ++ .../core/gen/{ => model}/ModelGenerator.kt | 263 ++++- .../{ => shared}/ApiClientBaseGenerator.kt | 166 ++- .../gen/{ => shared}/ApiResponseGenerator.kt | 9 +- .../SerializersModuleGenerator.kt | 33 +- .../avsystem/justworks/core/model/ApiSpec.kt | 35 +- .../avsystem/justworks/core/model/TypeRef.kt | 2 +- .../justworks/core/parser/SpecParser.kt | 295 ++++-- .../justworks/core/parser/SpecValidator.kt | 71 +- .../core/gen/ApiClientBaseGeneratorTest.kt | 18 +- .../core/gen/ApiResponseGeneratorTest.kt | 1 + .../justworks/core/gen/ClientGeneratorTest.kt | 547 +++++++--- .../justworks/core/gen/CodeGeneratorTest.kt | 55 + .../core/gen/InlineSchemaDedupTest.kt | 125 ++- .../core/gen/InlineTypeResolverTest.kt | 174 ++++ .../justworks/core/gen/IntegrationTest.kt | 91 +- .../core/gen/ModelGeneratorPolymorphicTest.kt | 211 +++- .../justworks/core/gen/ModelGeneratorTest.kt | 980 ++++++++++++++---- .../justworks/core/gen/NameRegistryTest.kt | 73 ++ .../gen/SerializersModuleGeneratorTest.kt | 19 +- .../justworks/core/gen/TypeMappingTest.kt | 63 +- .../justworks/core/model/ContentTypeTest.kt | 20 + .../core/parser/SpecParserPolymorphicTest.kt | 78 ++ .../core/parser/SpecParserSecurityTest.kt | 7 +- .../justworks/core/parser/SpecParserTest.kt | 323 +++++- .../core/parser/SpecParserTestBase.kt | 4 +- .../core/parser/SpecValidatorTest.kt | 36 +- .../resources/boolean-discriminator-spec.yaml | 25 + .../test/resources/security-schemes-spec.yaml | 5 + .../gradle/JustworksPluginFunctionalTest.kt | 6 +- .../justworks/gradle/JustworksGenerateTask.kt | 11 +- .../gradle/JustworksSharedTypesTask.kt | 44 +- 48 files changed, 3818 insertions(+), 1192 deletions(-) create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/ArrowHelpers.kt create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/Utils.kt delete mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/gen/ClientGenerator.kt delete mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDeduplicator.kt create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaKey.kt create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineTypeResolver.kt create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/gen/NameRegistry.kt create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/gen/Packages.kt delete mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/gen/TypeMapping.kt create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/gen/Utils.kt create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/gen/client/BodyGenerator.kt create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ParametersGenerator.kt rename core/src/main/kotlin/com/avsystem/justworks/core/gen/{ => model}/ModelGenerator.kt (60%) rename core/src/main/kotlin/com/avsystem/justworks/core/gen/{ => shared}/ApiClientBaseGenerator.kt (77%) rename core/src/main/kotlin/com/avsystem/justworks/core/gen/{ => shared}/ApiResponseGenerator.kt (95%) rename core/src/main/kotlin/com/avsystem/justworks/core/gen/{ => shared}/SerializersModuleGenerator.kt (63%) create mode 100644 core/src/test/kotlin/com/avsystem/justworks/core/gen/CodeGeneratorTest.kt create mode 100644 core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineTypeResolverTest.kt create mode 100644 core/src/test/kotlin/com/avsystem/justworks/core/gen/NameRegistryTest.kt create mode 100644 core/src/test/kotlin/com/avsystem/justworks/core/model/ContentTypeTest.kt create mode 100644 core/src/test/resources/boolean-discriminator-spec.yaml diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/ArrowHelpers.kt b/core/src/main/kotlin/com/avsystem/justworks/core/ArrowHelpers.kt new file mode 100644 index 0000000..0ee39b1 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/ArrowHelpers.kt @@ -0,0 +1,34 @@ +package com.avsystem.justworks.core + +import arrow.core.Nel +import arrow.core.nonEmptyListOf +import arrow.core.raise.IorRaise +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind.AT_MOST_ONCE +import kotlin.contracts.contract + +@OptIn(ExperimentalContracts::class) +context(iorRaise: IorRaise>) +inline fun ensureOrAccumulate(condition: Boolean, error: () -> Error) { + contract { callsInPlace(error, AT_MOST_ONCE) } + if (!condition) { + iorRaise.accumulate(nonEmptyListOf(error())) + } +} + +@OptIn(ExperimentalContracts::class) +context(iorRaise: IorRaise>) +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)) + 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 new file mode 100644 index 0000000..88d63d3 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt @@ -0,0 +1,16 @@ +@file:OptIn(ExperimentalRaiseAccumulateApi::class) + +package com.avsystem.justworks.core + +import arrow.core.Nel +import arrow.core.raise.ExperimentalRaiseAccumulateApi +import arrow.core.raise.IorRaise + +object Issue { + data class Error(val message: String) + + @JvmInline + value class Warning(val message: String) +} + +typealias Warnings = IorRaise> diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/Utils.kt b/core/src/main/kotlin/com/avsystem/justworks/core/Utils.kt new file mode 100644 index 0000000..9547a06 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/Utils.kt @@ -0,0 +1,5 @@ +package com.avsystem.justworks.core + +import kotlin.enums.enumEntries + +inline fun > String.toEnumOrNull(): T? = enumEntries().find { it.name.equals(this, true) } 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 deleted file mode 100644 index 6124868..0000000 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ClientGenerator.kt +++ /dev/null @@ -1,250 +0,0 @@ -package com.avsystem.justworks.core.gen - -import com.avsystem.justworks.core.model.ApiSpec -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 -import com.squareup.kotlinpoet.ExperimentalKotlinPoetApi -import com.squareup.kotlinpoet.FileSpec -import com.squareup.kotlinpoet.FunSpec -import com.squareup.kotlinpoet.KModifier -import com.squareup.kotlinpoet.LambdaTypeName -import com.squareup.kotlinpoet.MemberName -import com.squareup.kotlinpoet.ParameterSpec -import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy -import com.squareup.kotlinpoet.PropertySpec -import com.squareup.kotlinpoet.STRING -import com.squareup.kotlinpoet.TypeName -import com.squareup.kotlinpoet.TypeSpec -import com.squareup.kotlinpoet.UNIT - -private const val DEFAULT_TAG = "Default" -private const val API_SUFFIX = "Api" - -/** - * Generates one KotlinPoet [FileSpec] per API tag, each containing a client class - * that extends `ApiClientBase` with suspend functions for every endpoint in that tag group. - */ -@OptIn(ExperimentalKotlinPoetApi::class) -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, spec.securitySchemes) - } - } - - private fun generateClientFile( - tag: String, - endpoints: List, - hasPolymorphicTypes: Boolean = false, - securitySchemes: List = emptyList(), - ): FileSpec { - val className = ClassName(apiPackage, "${tag.toPascalCase()}$API_SUFFIX") - - val clientInitializer = if (hasPolymorphicTypes) { - val generatedSerializersModule = MemberName(modelPackage, GENERATED_SERIALIZERS_MODULE) - CodeBlock.of("$CREATE_HTTP_CLIENT(%M)", generatedSerializersModule) - } else { - CodeBlock.of("$CREATE_HTTP_CLIENT()") - } - - val tokenType = LambdaTypeName.get(returnType = STRING) - val authParams = ApiClientBaseGenerator.buildAuthConstructorParams(securitySchemes) - - val constructorBuilder = FunSpec - .constructorBuilder() - .addParameter(BASE_URL, STRING) - - val classBuilder = TypeSpec - .classBuilder(className) - .superclass(API_CLIENT_BASE) - .addSuperclassConstructorParameter(BASE_URL) - - if (authParams.isEmpty() && securitySchemes.isEmpty()) { - // Backward compat: no security info -> default token param - constructorBuilder.addParameter(TOKEN, tokenType) - classBuilder.addSuperclassConstructorParameter(TOKEN) - } else { - for ((paramName, _) in authParams) { - constructorBuilder.addParameter(paramName, tokenType) - classBuilder.addSuperclassConstructorParameter(paramName) - } - } - - val httpClientProperty = PropertySpec - .builder(CLIENT, HTTP_CLIENT) - .addModifiers(KModifier.OVERRIDE, KModifier.PROTECTED) - .initializer(clientInitializer) - .build() - - classBuilder - .primaryConstructor(constructorBuilder.build()) - .addProperty(httpClientProperty) - - classBuilder.addFunctions(endpoints.map(::generateEndpointFunction)) - - return FileSpec - .builder(className) - .addType(classBuilder.build()) - .build() - } - - private fun generateEndpointFunction(endpoint: Endpoint): FunSpec { - val functionName = endpoint.operationId.toCamelCase() - val returnBodyType = resolveReturnType(endpoint) - val errorType = resolveErrorType(endpoint) - val returnType = HTTP_RESULT.parameterizedBy(errorType, returnBodyType) - - val funBuilder = FunSpec - .builder(functionName) - .addModifiers(KModifier.SUSPEND) - .returns(returnType) - - val params = endpoint.parameters.groupBy { it.location } - - val pathParams = params[ParameterLocation.PATH].orEmpty().map { param -> - ParameterSpec(param.name.toCamelCase(), TypeMapping.toTypeName(param.schema, modelPackage)) - } - - val queryParams = params[ParameterLocation.QUERY].orEmpty().map { param -> - buildNullableParameter(param.schema, param.name, param.required) - } - - val headerParams = params[ParameterLocation.HEADER].orEmpty().map { param -> - buildNullableParameter(param.schema, param.name, param.required) - } - - funBuilder.addParameters(pathParams + queryParams + headerParams) - - if (endpoint.requestBody != null) { - funBuilder.addParameter( - buildNullableParameter(endpoint.requestBody.schema, BODY, endpoint.requestBody.required), - ) - } - - funBuilder.addCode(buildFunctionBody(endpoint, params, returnBodyType)) - - return funBuilder.build() - } - - private fun buildNullableParameter( - typeRef: TypeRef, - name: String, - required: Boolean, - ): ParameterSpec { - val baseType = TypeMapping.toTypeName(typeRef, modelPackage) - - val builder = ParameterSpec.builder(name.toCamelCase(), baseType.copy(nullable = !required)) - if (!required) builder.defaultValue("null") - return builder.build() - } - - private fun buildFunctionBody( - endpoint: Endpoint, - params: Map>, - returnBodyType: TypeName, - ): CodeBlock { - val httpMethodFun = when (endpoint.method) { - HttpMethod.GET -> GET_FUN - HttpMethod.POST -> POST_FUN - HttpMethod.PUT -> PUT_FUN - HttpMethod.DELETE -> DELETE_FUN - HttpMethod.PATCH -> PATCH_FUN - } - - val (format, args) = params[ParameterLocation.PATH] - .orEmpty() - .fold($$"${'$'}{$$BASE_URL}" + endpoint.path to emptyList()) { (format, args), param -> - format.replace("{${param.name}}", $$"${%M(%L)}") to args + ENCODE_PARAM_FUN + param.name.toCamelCase() - } - - val urlString = CodeBlock.of("%P", CodeBlock.of(format, *args.toTypedArray())) - val resultFun = if (returnBodyType == UNIT) TO_EMPTY_RESULT_FUN else TO_RESULT_FUN - - val code = CodeBlock.builder() - - code.beginControlFlow("return $SAFE_CALL") - - code.beginControlFlow("$CLIENT.%M(%L)", httpMethodFun, urlString) - code.addStatement("$APPLY_AUTH()") - - val headerParams = params[ParameterLocation.HEADER] - if (!headerParams.isNullOrEmpty()) { - code.beginControlFlow("%M", HEADERS_FUN) - for (param in headerParams) { - val paramName = param.name.toCamelCase() - code.optionalGuard(param.required, paramName) { - addStatement("append(%S, %M(%L))", param.name, ENCODE_PARAM_FUN, paramName) - } - } - code.endControlFlow() - } - - val queryParams = params[ParameterLocation.QUERY] - if (!queryParams.isNullOrEmpty()) { - code.beginControlFlow("url") - for (param in queryParams) { - val paramName = param.name.toCamelCase() - code.optionalGuard(param.required, paramName) { - addStatement("this.parameters.append(%S, %M(%L))", param.name, ENCODE_PARAM_FUN, paramName) - } - } - code.endControlFlow() - } - - if (endpoint.requestBody != null) { - code.optionalGuard(endpoint.requestBody.required, BODY) { - addStatement("%M(%T.Json)", CONTENT_TYPE_FUN, CONTENT_TYPE_APPLICATION) - addStatement("%M(%L)", SET_BODY_FUN, BODY) - } - } - - // Close client.METHOD block and chain .toResult() / .toEmptyResult() - code.unindent() - code.add("}.%M()\n", resultFun) - code.endControlFlow() // safeCall - - return code.build() - } - - private fun resolveErrorType(endpoint: Endpoint): TypeName { - val errorSchemas = endpoint.responses.entries - .asSequence() - .filter { !it.key.startsWith("2") } - .mapNotNull { it.value.schema } - .map { TypeMapping.toTypeName(it, modelPackage) } - .distinct() - .toList() - - return when { - errorSchemas.size == 1 -> errorSchemas.single() - else -> JSON_ELEMENT - } - } - - private fun resolveReturnType(endpoint: Endpoint): TypeName = endpoint.responses.entries - .asSequence() - .filter { it.key.startsWith("2") } - .firstNotNullOfOrNull { it.value.schema } - ?.let { successResponse -> TypeMapping.toTypeName(successResponse, modelPackage) } - ?: UNIT - - /** - * If [required], emits [block] directly. Otherwise wraps it in `if (name != null) { ... }`. - */ - private inline fun CodeBlock.Builder.optionalGuard( - required: Boolean, - name: String, - block: CodeBlock.Builder.() -> Unit, - ) { - if (!required) beginControlFlow("if (%L != null)", name) - block() - if (!required) endControlFlow() - } -} 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 7255495..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 @@ -1,5 +1,9 @@ package com.avsystem.justworks.core.gen +import com.avsystem.justworks.core.gen.client.ClientGenerator +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 @@ -15,20 +19,25 @@ object CodeGenerator { spec: ApiSpec, modelPackage: String, apiPackage: String, - outputDir: File - ): Result { - val modelFiles = ModelGenerator(modelPackage).generate(spec) + outputDir: File, + ): Result = context(ModelPackage(modelPackage), ApiPackage(apiPackage)) { + val modelRegistry = NameRegistry() + val apiRegistry = NameRegistry() + + val (modelFiles, resolvedSpec) = ModelGenerator.generateWithResolvedSpec(spec, modelRegistry) + modelFiles.forEach { it.writeTo(outputDir) } - val hasPolymorphicTypes = modelFiles.any { it.name == SerializersModuleGenerator.FILE_NAME } + val hasPolymorphicTypes = modelFiles.any { it.name == SERIALIZERS_MODULE.simpleName } + + val clientFiles = ClientGenerator.generate(resolvedSpec, hasPolymorphicTypes, apiRegistry) - val clientFiles = ClientGenerator(apiPackage, modelPackage).generate(spec, hasPolymorphicTypes) clientFiles.forEach { it.writeTo(outputDir) } return Result(modelFiles.size, clientFiles.size) } - fun generateSharedTypes(outputDir: File, securitySchemes: List? = null): Int { + 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/gen/InlineSchemaDeduplicator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDeduplicator.kt deleted file mode 100644 index eef1664..0000000 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDeduplicator.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.avsystem.justworks.core.gen - -import com.avsystem.justworks.core.model.PropertyModel -import com.avsystem.justworks.core.model.TypeRef - -/** - * Key for structural equality of inline schemas. - * Two inline schemas are considered equal if they have the same properties - * (name, type, required status) regardless of property order. - */ -data class InlineSchemaKey(val properties: Set) { - data class PropertyKey( - val name: String, - val type: TypeRef, - val required: Boolean, - ) - - companion object { - fun from(properties: List, required: Set) = InlineSchemaKey( - properties = properties.map { PropertyKey(it.name, it.type, it.name in required) }.toSet(), - ) - } -} - -/** - * Deduplicates inline schemas based on structural equality. - * Ensures that structurally identical inline schemas generate only one class, - * and handles name collisions with component schemas. - */ -class InlineSchemaDeduplicator(private val componentSchemaNames: Set) { - private val namesByKey = mutableMapOf() - - fun getOrGenerateName( - properties: List, - requiredProps: Set, - contextName: String, - ): String = namesByKey.getOrPut(InlineSchemaKey.from(properties, requiredProps)) { - val inlineName = contextName.toInlinedName() - val candidates = sequence { - yield(inlineName) - yield("${inlineName}Inline") - generateSequence(2) { it + 1 }.forEach { - yield("${inlineName}${it}Inline") - } - } - - val existingNames = (componentSchemaNames + namesByKey.values).toSet() - candidates.first { it !in existingNames } - } -} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaKey.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaKey.kt new file mode 100644 index 0000000..dd6047a --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaKey.kt @@ -0,0 +1,52 @@ +package com.avsystem.justworks.core.gen + +import com.avsystem.justworks.core.model.PropertyModel +import com.avsystem.justworks.core.model.TypeRef + +/** + * Key for structural equality of inline schemas. + * Two inline schemas are considered equal if they have the same properties + * (name, type, required status, nullable status) regardless of property order. + * Nested [TypeRef.Inline] types are normalized to ignore [TypeRef.Inline.contextHint], + * ensuring purely structural comparison. + */ +internal data class InlineSchemaKey(val properties: Set) { + data class PropertyKey( + val name: String, + val type: TypeRef, + val required: Boolean, + val nullable: Boolean, + val defaultValue: Any?, + ) + + companion object { + fun from(properties: List, required: Set): InlineSchemaKey { + val keys = properties.map { + PropertyKey( + name = it.name, + type = normalizeType(it.type), + required = it.name in required, + nullable = it.nullable, + defaultValue = it.defaultValue, + ) + } + return InlineSchemaKey(keys.toSet()) + } + + private fun normalizeType(type: TypeRef): TypeRef = when (type) { + is TypeRef.Inline -> TypeRef.Inline( + properties = type.properties + .map { it.copy(type = normalizeType(it.type)) } + .sortedBy { it.name }, + requiredProperties = type.requiredProperties, + contextHint = "", + ) + + is TypeRef.Array -> TypeRef.Array(normalizeType(type.items)) + + is TypeRef.Map -> TypeRef.Map(normalizeType(type.valueType)) + + else -> type + } + } +} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineTypeResolver.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineTypeResolver.kt new file mode 100644 index 0000000..f81d4dc --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineTypeResolver.kt @@ -0,0 +1,75 @@ +package com.avsystem.justworks.core.gen + +import com.avsystem.justworks.core.model.ApiSpec +import com.avsystem.justworks.core.model.Endpoint +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.TypeRef + +/** + * Resolves a single [TypeRef.Inline] to [TypeRef.Reference] using the provided [nameMap]. + * Non-inline types are returned as-is; containers ([TypeRef.Array], [TypeRef.Map]) are resolved recursively. + */ +internal fun ApiSpec.resolveTypeRef(type: TypeRef, nameMap: Map): TypeRef = when (type) { + is TypeRef.Inline -> { + val key = InlineSchemaKey.from(type.properties, type.requiredProperties) + val className = nameMap[key] + ?: error( + "Missing inline schema mapping for key (contextHint=${type.contextHint}). " + + "This indicates a mismatch between inline schema collection and resolution.", + ) + TypeRef.Reference(className) + } + + is TypeRef.Array -> { + TypeRef.Array(resolveTypeRef(type.items, nameMap)) + } + + is TypeRef.Map -> { + TypeRef.Map(resolveTypeRef(type.valueType, nameMap)) + } + + else -> { + type + } +} + +/** + * Rewrites all [TypeRef.Inline] references in an [ApiSpec] to [TypeRef.Reference], + * using the provided [nameMap] that maps structural keys to generated class names. + * + * This is applied once after inline schema collection, so downstream generators + * never encounter [TypeRef.Inline] and need no special handling. + */ +internal fun ApiSpec.resolveInlineTypes(nameMap: Map): ApiSpec { + if (nameMap.isEmpty()) return this + + fun TypeRef.resolve(): TypeRef = resolveTypeRef(this, nameMap) + + fun PropertyModel.resolve() = copy(type = type.resolve()) + + fun SchemaModel.resolve() = copy( + properties = properties.map { it.resolve() }, + allOf = allOf?.map { it.resolve() }, + oneOf = oneOf?.map { it.resolve() }, + anyOf = anyOf?.map { it.resolve() }, + underlyingType = underlyingType?.resolve(), + ) + + fun Response.resolve() = copy(schema = schema?.resolve()) + + fun RequestBody.resolve() = copy(schema = schema.resolve()) + + fun Endpoint.resolve() = copy( + parameters = parameters.map { it.copy(schema = it.schema.resolve()) }, + requestBody = requestBody?.resolve(), + responses = responses.mapValues { (_, v) -> v.resolve() }, + ) + + return copy( + schemas = schemas.map { it.resolve() }, + endpoints = endpoints.map { it.resolve() }, + ) +} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameRegistry.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameRegistry.kt new file mode 100644 index 0000000..480b9d1 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameRegistry.kt @@ -0,0 +1,32 @@ +package com.avsystem.justworks.core.gen + +/** + * Registry that tracks used names and resolves collisions with numeric suffixes. + * + * When a desired name is already taken, appends incrementing numeric suffixes + * (e.g. `Foo`, `Foo2`, `Foo3`). Names can be pre-populated via [reserve] to + * block them from being returned by [register]. + */ +internal class NameRegistry { + private val registered = mutableSetOf() + + /** + * Registers [desired] name, returning it if available or appending a numeric suffix + * to resolve collisions (e.g. `Foo2`, `Foo3`). + */ + fun register(desired: String): String { + require(desired.isNotEmpty()) { "Cannot register an empty name" } + return desired.takeIf { registered.add(it) } + ?: generateSequence(2) { it + 1 } + .map { "$desired$it" } + .first { registered.add(it) } + } + + /** + * Reserves [name] so that subsequent [register] calls for the same name + * will receive a suffixed variant. + */ + fun reserve(name: String) { + registered.add(name) + } +} 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 5599627..af4105b 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 @@ -42,6 +42,13 @@ fun String.toEnumConstantName(): String { */ fun String.toInlinedName(): String = replace(".", "_") +/** + * Sanitizes a string for safe inclusion in KDoc. + * Escapes comment terminators that would break generated Kotlin source. + */ +fun String.sanitizeKdoc(): String = replace("*/", "*/") + .replace("/*", "/*") + /** * Generates a PascalCase operation name from HTTP method and path. * Path parameters like {id} become "ById", {userId} becomes "ByUserId". 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 31f8fef..0ab6e8d 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 @@ -25,11 +25,25 @@ val PUT_FUN = MemberName("io.ktor.client.request", "put") val DELETE_FUN = MemberName("io.ktor.client.request", "delete") val PATCH_FUN = MemberName("io.ktor.client.request", "patch") +// ============================================================================ +// Ktor Forms & Multipart +// ============================================================================ + +val SUBMIT_FORM_FUN = MemberName("io.ktor.client.request.forms", "submitForm") +val SUBMIT_FORM_WITH_BINARY_DATA_FUN = MemberName("io.ktor.client.request.forms", "submitFormWithBinaryData") +val FORM_DATA_FUN = MemberName("io.ktor.client.request.forms", "formData") +val CHANNEL_PROVIDER = ClassName("io.ktor.client.request.forms", "ChannelProvider") +val PARAMETERS_FUN = MemberName("io.ktor.http", "parameters") +val CONTENT_TYPE_CLASS = ClassName("io.ktor.http", "ContentType") +val HEADERS_CLASS = ClassName("io.ktor.http", "Headers") +val HTTP_METHOD_CLASS = ClassName("io.ktor.http", "HttpMethod") + // ============================================================================ // kotlinx.serialization // ============================================================================ val SERIALIZABLE = ClassName("kotlinx.serialization", "Serializable") +val USE_SERIALIZERS = ClassName("kotlinx.serialization", "UseSerializers") val SERIAL_NAME = ClassName("kotlinx.serialization", "SerialName") val EXPERIMENTAL_SERIALIZATION_API = ClassName("kotlinx.serialization", "ExperimentalSerializationApi") val SERIALIZATION_EXCEPTION = ClassName("kotlinx.serialization", "SerializationException") @@ -41,6 +55,13 @@ val SERIALIZERS_MODULE = ClassName("kotlinx.serialization.modules", "Serializers val JSON_OBJECT_EXT = MemberName("kotlinx.serialization.json", "jsonObject") +val K_SERIALIZER = ClassName("kotlinx.serialization", "KSerializer") +val SERIAL_DESCRIPTOR = ClassName("kotlinx.serialization.descriptors", "SerialDescriptor") +val PRIMITIVE_SERIAL_DESCRIPTOR_FUN = MemberName("kotlinx.serialization.descriptors", "PrimitiveSerialDescriptor") +val PRIMITIVE_KIND = ClassName("kotlinx.serialization.descriptors", "PrimitiveKind") +val DECODER = ClassName("kotlinx.serialization.encoding", "Decoder") +val ENCODER = ClassName("kotlinx.serialization.encoding", "Encoder") + val ENCODE_TO_STRING_FUN = MemberName("kotlinx.serialization", "encodeToString") val POLYMORPHIC_FUN = MemberName("kotlinx.serialization.modules", "polymorphic") val SUBCLASS_FUN = MemberName("kotlinx.serialization.modules", "subclass") @@ -52,6 +73,13 @@ val SUBCLASS_FUN = MemberName("kotlinx.serialization.modules", "subclass") val INSTANT = ClassName("kotlin.time", "Instant") val LOCAL_DATE = ClassName("kotlinx.datetime", "LocalDate") +// ============================================================================ +// UUID (kotlin.uuid) +// ============================================================================ + +val UUID_TYPE = ClassName("kotlin.uuid", "Uuid") +val EXPERIMENTAL_UUID_API = ClassName("kotlin.uuid", "ExperimentalUuidApi") + // ============================================================================ // Error Handling (Arrow + Kotlin stdlib) // ============================================================================ @@ -86,6 +114,7 @@ val HTTP_REQUEST_BUILDER = ClassName("io.ktor.client.request", "HttpRequestBuild val TO_RESULT_FUN = MemberName("com.avsystem.justworks", "toResult") val TO_EMPTY_RESULT_FUN = MemberName("com.avsystem.justworks", "toEmptyResult") val ENCODE_PARAM_FUN = MemberName("com.avsystem.justworks", "encodeParam") +val UUID_SERIALIZER = ClassName("com.avsystem.justworks", "UuidSerializer") // ============================================================================ // Shared property / parameter names (used by multiple generators) @@ -99,4 +128,3 @@ const val APPLY_AUTH = "applyAuth" const val SAFE_CALL = "safeCall" const val CREATE_HTTP_CLIENT = "createHttpClient" const val GENERATED_SERIALIZERS_MODULE = "generatedSerializersModule" -const val NETWORK_ERROR = "Network error" diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Packages.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Packages.kt new file mode 100644 index 0000000..0e6c5de --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Packages.kt @@ -0,0 +1,20 @@ +package com.avsystem.justworks.core.gen + +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.MemberName + +internal sealed interface Package { + val name: String +} + +@JvmInline +internal value class ModelPackage(override val name: String) : Package + +@JvmInline +internal value class ApiPackage(override val name: String) : Package + +internal operator fun ClassName.Companion.invoke(pkg: Package, vararg simpleNames: String): ClassName = + ClassName(pkg.name, *simpleNames) + +internal operator fun MemberName.Companion.invoke(pkg: Package, memberName: String): MemberName = + MemberName(pkg.name, memberName) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/TypeMapping.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/TypeMapping.kt deleted file mode 100644 index 674bf95..0000000 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/TypeMapping.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.avsystem.justworks.core.gen - -import com.avsystem.justworks.core.model.PrimitiveType -import com.avsystem.justworks.core.model.TypeRef -import com.squareup.kotlinpoet.ANY -import com.squareup.kotlinpoet.BOOLEAN -import com.squareup.kotlinpoet.BYTE_ARRAY -import com.squareup.kotlinpoet.ClassName -import com.squareup.kotlinpoet.DOUBLE -import com.squareup.kotlinpoet.FLOAT -import com.squareup.kotlinpoet.INT -import com.squareup.kotlinpoet.LIST -import com.squareup.kotlinpoet.LONG -import com.squareup.kotlinpoet.MAP -import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy -import com.squareup.kotlinpoet.STRING -import com.squareup.kotlinpoet.TypeName - -/** - * Maps [TypeRef] sealed variants to KotlinPoet [TypeName] instances. - */ -object TypeMapping { - fun toTypeName(typeRef: TypeRef, modelPackage: String): TypeName = when (typeRef) { - is TypeRef.Primitive -> { - when (typeRef.type) { - PrimitiveType.STRING -> STRING - PrimitiveType.INT -> INT - PrimitiveType.LONG -> LONG - PrimitiveType.DOUBLE -> DOUBLE - PrimitiveType.FLOAT -> FLOAT - PrimitiveType.BOOLEAN -> BOOLEAN - PrimitiveType.BYTE_ARRAY -> BYTE_ARRAY - PrimitiveType.DATE_TIME -> INSTANT - PrimitiveType.DATE -> LOCAL_DATE - } - } - - is TypeRef.Array -> { - LIST.parameterizedBy(toTypeName(typeRef.items, modelPackage)) - } - - is TypeRef.Map -> { - MAP.parameterizedBy(STRING, toTypeName(typeRef.valueType, modelPackage)) - } - - is TypeRef.Reference -> { - ClassName(modelPackage, typeRef.schemaName) - } - - is TypeRef.Inline -> { - ClassName(modelPackage, typeRef.contextHint.toInlinedName()) - } - - is TypeRef.Unknown -> { - ANY - } - } -} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Utils.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Utils.kt new file mode 100644 index 0000000..7391e51 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Utils.kt @@ -0,0 +1,69 @@ +package com.avsystem.justworks.core.gen + +import com.avsystem.justworks.core.model.PrimitiveType +import com.avsystem.justworks.core.model.PropertyModel +import com.avsystem.justworks.core.model.TypeRef +import com.squareup.kotlinpoet.BOOLEAN +import com.squareup.kotlinpoet.BYTE_ARRAY +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.DOUBLE +import com.squareup.kotlinpoet.FLOAT +import com.squareup.kotlinpoet.INT +import com.squareup.kotlinpoet.LIST +import com.squareup.kotlinpoet.LONG +import com.squareup.kotlinpoet.MAP +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.STRING +import com.squareup.kotlinpoet.TypeName + +internal val TypeRef.properties: List + get() = when (this) { + is TypeRef.Inline -> properties + is TypeRef.Array, is TypeRef.Map, is TypeRef.Primitive, is TypeRef.Reference, TypeRef.Unknown -> emptyList() + } + +internal val TypeRef.requiredProperties: Set + get() = when (this) { + is TypeRef.Inline -> requiredProperties + is TypeRef.Array, is TypeRef.Map, is TypeRef.Primitive, is TypeRef.Reference, TypeRef.Unknown -> emptySet() + } + +context(modelPackage: ModelPackage) +internal fun TypeRef.toTypeName(): TypeName = when (this) { + is TypeRef.Primitive -> { + when (type) { + PrimitiveType.STRING -> STRING + PrimitiveType.INT -> INT + PrimitiveType.LONG -> LONG + PrimitiveType.DOUBLE -> DOUBLE + PrimitiveType.FLOAT -> FLOAT + PrimitiveType.BOOLEAN -> BOOLEAN + PrimitiveType.BYTE_ARRAY -> BYTE_ARRAY + PrimitiveType.DATE_TIME -> INSTANT + PrimitiveType.DATE -> LOCAL_DATE + PrimitiveType.UUID -> UUID_TYPE + } + } + + is TypeRef.Array -> { + LIST.parameterizedBy(items.toTypeName()) + } + + is TypeRef.Map -> { + MAP.parameterizedBy(STRING, valueType.toTypeName()) + } + + is TypeRef.Reference -> { + ClassName(modelPackage, schemaName) + } + + is TypeRef.Inline -> { + error("TypeRef.Inline should have been resolved by InlineTypeResolver (contextHint=$contextHint)") + } + + is TypeRef.Unknown -> { + JSON_ELEMENT + } +} + +internal fun TypeRef.isBinaryUpload(): Boolean = this is TypeRef.Primitive && this.type == PrimitiveType.BYTE_ARRAY diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/BodyGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/BodyGenerator.kt new file mode 100644 index 0000000..133ab02 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/BodyGenerator.kt @@ -0,0 +1,244 @@ +package com.avsystem.justworks.core.gen.client + +import com.avsystem.justworks.core.gen.APPLY_AUTH +import com.avsystem.justworks.core.gen.BASE_URL +import com.avsystem.justworks.core.gen.BODY +import com.avsystem.justworks.core.gen.CLIENT +import com.avsystem.justworks.core.gen.CONTENT_TYPE_APPLICATION +import com.avsystem.justworks.core.gen.CONTENT_TYPE_FUN +import com.avsystem.justworks.core.gen.DELETE_FUN +import com.avsystem.justworks.core.gen.ENCODE_PARAM_FUN +import com.avsystem.justworks.core.gen.FORM_DATA_FUN +import com.avsystem.justworks.core.gen.GET_FUN +import com.avsystem.justworks.core.gen.HEADERS_CLASS +import com.avsystem.justworks.core.gen.HEADERS_FUN +import com.avsystem.justworks.core.gen.HTTP_HEADERS +import com.avsystem.justworks.core.gen.HTTP_METHOD_CLASS +import com.avsystem.justworks.core.gen.PARAMETERS_FUN +import com.avsystem.justworks.core.gen.PATCH_FUN +import com.avsystem.justworks.core.gen.POST_FUN +import com.avsystem.justworks.core.gen.PUT_FUN +import com.avsystem.justworks.core.gen.SAFE_CALL +import com.avsystem.justworks.core.gen.SET_BODY_FUN +import com.avsystem.justworks.core.gen.SUBMIT_FORM_FUN +import com.avsystem.justworks.core.gen.SUBMIT_FORM_WITH_BINARY_DATA_FUN +import com.avsystem.justworks.core.gen.TO_EMPTY_RESULT_FUN +import com.avsystem.justworks.core.gen.TO_RESULT_FUN +import com.avsystem.justworks.core.gen.isBinaryUpload +import com.avsystem.justworks.core.gen.properties +import com.avsystem.justworks.core.gen.requiredProperties +import com.avsystem.justworks.core.gen.toCamelCase +import com.avsystem.justworks.core.gen.toPascalCase +import com.avsystem.justworks.core.model.ContentType +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.PrimitiveType +import com.avsystem.justworks.core.model.TypeRef +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.UNIT + +internal object BodyGenerator { + fun buildFunctionBody( + endpoint: Endpoint, + params: Map>, + returnBodyType: TypeName, + ): CodeBlock { + val resultFun = if (returnBodyType == UNIT) TO_EMPTY_RESULT_FUN else TO_RESULT_FUN + val code = CodeBlock.builder() + + code.beginControlFlow("return $SAFE_CALL") + + val urlString = buildUrlString(endpoint, params) + when (endpoint.requestBody?.contentType) { + ContentType.MULTIPART_FORM_DATA -> code.buildMultipartBody(endpoint, params, urlString) + ContentType.FORM_URL_ENCODED -> code.buildFormUrlEncodedBody(endpoint, params, urlString) + ContentType.JSON_CONTENT_TYPE, null -> code.buildJsonBody(endpoint, params, urlString) + } + + // Close the HTTP call block and chain .toResult() / .toEmptyResult() + code.unindent() + code.add("}.%M()\n", resultFun) + code.endControlFlow() // safeCall + + return code.build() + } + + private fun CodeBlock.Builder.buildJsonBody( + endpoint: Endpoint, + params: Map>, + urlString: CodeBlock, + ) { + val httpMethodFun = when (endpoint.method) { + HttpMethod.GET -> GET_FUN + HttpMethod.POST -> POST_FUN + HttpMethod.PUT -> PUT_FUN + HttpMethod.DELETE -> DELETE_FUN + HttpMethod.PATCH -> PATCH_FUN + } + + beginControlFlow("$CLIENT.%M(%L)", httpMethodFun, urlString) + addCommonRequestParts(params) + + optionalGuard(endpoint.requestBody?.required ?: false, BODY) { + addStatement("%M(%T.Json)", CONTENT_TYPE_FUN, CONTENT_TYPE_APPLICATION) + addStatement("%M(%L)", SET_BODY_FUN, BODY) + } + + // Don't endControlFlow here — the outer buildFunctionBody closes with .toResult() + } + + private fun CodeBlock.Builder.buildMultipartBody( + endpoint: Endpoint, + params: Map>, + urlString: CodeBlock, + ) { + val properties = endpoint.requestBody + ?.schema + ?.properties + .orEmpty() + + beginControlFlow( + "$CLIENT.%M(\nurl = %L,\nformData = %M", + SUBMIT_FORM_WITH_BINARY_DATA_FUN, + urlString, + FORM_DATA_FUN, + ) + + for (prop in properties) { + val paramName = prop.name.toCamelCase() + if (prop.type.isBinaryUpload()) { + beginControlFlow( + "append(%S, %L, %T.build", + prop.name, + paramName, + HEADERS_CLASS, + ) + addStatement( + "append(%T.ContentType, %L.toString())", + HTTP_HEADERS, + "${paramName}ContentType", + ) + addStatement( + "append(%T.ContentDisposition, %P)", + HTTP_HEADERS, + CodeBlock.of($$"filename=\"${%L}\"", "${paramName}Name"), + ) + endControlFlow() + add(")\n") + } else { + addStatement("append(%S, %L)", prop.name, paramName) + } + } + + finishFormRequest(endpoint, params) + } + + private fun CodeBlock.Builder.buildFormUrlEncodedBody( + endpoint: Endpoint, + params: Map>, + urlString: CodeBlock, + ) { + val properties = endpoint.requestBody + ?.schema + ?.properties + .orEmpty() + + val requiredProperties = endpoint.requestBody + ?.schema + ?.requiredProperties + .orEmpty() + + beginControlFlow( + "$CLIENT.%M(\nurl = %L,\nformParameters = %M", + SUBMIT_FORM_FUN, + urlString, + PARAMETERS_FUN, + ) + + for (prop in properties) { + val paramName = prop.name.toCamelCase() + val isRequired = endpoint.requestBody?.required == true && prop.name in requiredProperties + val isString = prop.type == TypeRef.Primitive(PrimitiveType.STRING) + val valueExpr = if (isString) paramName else "$paramName.toString()" + + optionalGuard(isRequired, paramName) { + addStatement("append(%S, %L)", prop.name, valueExpr) + } + } + + finishFormRequest(endpoint, params) + } + + private fun CodeBlock.Builder.finishFormRequest( + endpoint: Endpoint, + params: Map>, + ) { + endControlFlow() // formData / parameters + beginControlFlow(")") + addCommonRequestParts(params) + addHttpMethodIfNeeded(endpoint.method) + // Don't endControlFlow here — the outer buildFunctionBody closes with .toResult() + } + + private fun CodeBlock.Builder.addCommonRequestParts(params: Map>) { + addStatement("${APPLY_AUTH}()") + addHeaderParams(params) + addQueryParams(params) + } + + private fun CodeBlock.Builder.addHttpMethodIfNeeded(method: HttpMethod) { + if (method != HttpMethod.POST) { + addStatement("method = %T.%L", HTTP_METHOD_CLASS, method.name.toPascalCase()) + } + } + + private fun buildUrlString(endpoint: Endpoint, params: Map>): CodeBlock { + val (format, args) = params[ParameterLocation.PATH] + .orEmpty() + .fold($$"${'$'}{$$BASE_URL}" + endpoint.path to emptyList()) { (format, args), param -> + format.replace("{${param.name}}", $$"${%M(%L)}") to args + ENCODE_PARAM_FUN + param.name.toCamelCase() + } + return CodeBlock.of("%P", CodeBlock.of(format, *args.toTypedArray())) + } + + private fun CodeBlock.Builder.addHeaderParams(params: Map>) { + val headerParams = params[ParameterLocation.HEADER] + if (!headerParams.isNullOrEmpty()) { + beginControlFlow("%M", HEADERS_FUN) + for (param in headerParams) { + val paramName = param.name.toCamelCase() + optionalGuard(param.required, paramName) { + addStatement("append(%S, %M(%L))", param.name, ENCODE_PARAM_FUN, paramName) + } + } + endControlFlow() + } + } + + private fun CodeBlock.Builder.addQueryParams(params: Map>) { + val queryParams = params[ParameterLocation.QUERY] + if (!queryParams.isNullOrEmpty()) { + beginControlFlow("url") + for (param in queryParams) { + val paramName = param.name.toCamelCase() + optionalGuard(param.required, paramName) { + addStatement("this.parameters.append(%S, %M(%L))", param.name, ENCODE_PARAM_FUN, paramName) + } + } + endControlFlow() + } + } + + internal inline fun CodeBlock.Builder.optionalGuard( + required: Boolean, + name: String, + block: CodeBlock.Builder.() -> Unit, + ) { + if (!required) beginControlFlow("if (%L != null)", name) + block() + if (!required) endControlFlow() + } +} 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 new file mode 100644 index 0000000..77e35d7 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt @@ -0,0 +1,198 @@ +package com.avsystem.justworks.core.gen.client + +import com.avsystem.justworks.core.gen.API_CLIENT_BASE +import com.avsystem.justworks.core.gen.ApiPackage +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.HTTP_CLIENT +import com.avsystem.justworks.core.gen.HTTP_RESULT +import com.avsystem.justworks.core.gen.HTTP_SUCCESS +import com.avsystem.justworks.core.gen.JSON_ELEMENT +import com.avsystem.justworks.core.gen.ModelPackage +import com.avsystem.justworks.core.gen.NameRegistry +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 +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.toCamelCase +import com.avsystem.justworks.core.gen.toPascalCase +import com.avsystem.justworks.core.gen.toTypeName +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.ExperimentalKotlinPoetApi +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.LambdaTypeName +import com.squareup.kotlinpoet.MemberName +import com.squareup.kotlinpoet.ParameterSpec +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.STRING +import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.UNIT + +/** + * Generates one KotlinPoet [FileSpec] per API tag, each containing a client class + * that extends `ApiClientBase` with suspend functions for every endpoint in that tag group. + */ + +@OptIn(ExperimentalKotlinPoetApi::class) +internal object ClientGenerator { + private const val DEFAULT_TAG = "Default" + private const val API_SUFFIX = "Api" + + context(_: ModelPackage, _: ApiPackage) + fun generate( + spec: ApiSpec, + hasPolymorphicTypes: Boolean, + nameRegistry: NameRegistry, + ): List { + val grouped = spec.endpoints.groupBy { it.tags.firstOrNull() ?: DEFAULT_TAG } + return grouped.map { (tag, endpoints) -> + generateClientFile(tag, endpoints, hasPolymorphicTypes, nameRegistry, spec.securitySchemes) + } + } + + context(modelPackage: ModelPackage, apiPackage: ApiPackage) + private fun generateClientFile( + tag: String, + endpoints: List, + hasPolymorphicTypes: Boolean, + nameRegistry: NameRegistry, + securitySchemes: List, + ): FileSpec { + val className = ClassName(apiPackage, nameRegistry.register("${tag.toPascalCase()}$API_SUFFIX")) + + val clientInitializer = if (hasPolymorphicTypes) { + val generatedSerializersModule = MemberName(modelPackage, GENERATED_SERIALIZERS_MODULE) + CodeBlock.of("${CREATE_HTTP_CLIENT}(%M)", generatedSerializersModule) + } else { + CodeBlock.of("${CREATE_HTTP_CLIENT}()") + } + + val tokenType = LambdaTypeName.get(returnType = STRING) + val authParams = ApiClientBaseGenerator.buildAuthConstructorParams(securitySchemes) + + val constructorBuilder = FunSpec + .constructorBuilder() + .addParameter(BASE_URL, STRING) + + 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) + .addModifiers(KModifier.OVERRIDE, KModifier.PROTECTED) + .initializer(clientInitializer) + .build() + + classBuilder + .primaryConstructor(constructorBuilder.build()) + .addProperty(httpClientProperty) + + val methodRegistry = NameRegistry() + classBuilder.addFunctions(endpoints.map { generateEndpointFunction(it, methodRegistry) }) + + return FileSpec + .builder(className) + .addType(classBuilder.build()) + .build() + } + + context(_: ModelPackage) + private fun generateEndpointFunction(endpoint: Endpoint, methodRegistry: NameRegistry): FunSpec { + val functionName = methodRegistry.register(endpoint.operationId.toCamelCase()) + val returnBodyType = resolveReturnType(endpoint) + val errorType = resolveErrorType(endpoint) + val returnType = HTTP_RESULT.parameterizedBy(errorType, returnBodyType) + + val funBuilder = FunSpec + .builder(functionName) + .addModifiers(KModifier.SUSPEND) + .returns(returnType) + + val params = endpoint.parameters.groupBy { it.location } + + val pathParams = params[ParameterLocation.PATH].orEmpty().map { param -> + ParameterSpec(param.name.toCamelCase(), param.schema.toTypeName()) + } + + val queryParams = params[ParameterLocation.QUERY].orEmpty().map { param -> + buildNullableParameter(param.schema, param.name, param.required) + } + + val headerParams = params[ParameterLocation.HEADER].orEmpty().map { param -> + buildNullableParameter(param.schema, param.name, param.required) + } + + funBuilder.addParameters(pathParams + queryParams + headerParams) + + if (endpoint.requestBody != null) { + funBuilder.addParameters(buildBodyParams(endpoint.requestBody)) + } + + val kdocParts = mutableListOf() + endpoint.summary?.let { kdocParts.add(it.sanitizeKdoc()) } + endpoint.description?.let { + if (kdocParts.isNotEmpty()) kdocParts.add("") + kdocParts.add(it.sanitizeKdoc()) + } + val paramDocs = endpoint.parameters.filter { it.description != null } + if (paramDocs.isNotEmpty() && kdocParts.isNotEmpty()) kdocParts.add("") + paramDocs.forEach { param -> + kdocParts.add("@param ${param.name.toCamelCase()} ${param.description?.sanitizeKdoc()}") + } + if (kdocParts.isNotEmpty()) { + funBuilder.addKdoc("%L", kdocParts.joinToString("\n")) + } + if (returnBodyType != UNIT) { + if (kdocParts.isNotEmpty()) funBuilder.addKdoc("\n\n") + funBuilder.addKdoc("@return [%T] containing [%T] on success", HTTP_SUCCESS, returnBodyType) + } + + funBuilder.addCode(buildFunctionBody(endpoint, params, returnBodyType)) + + return funBuilder.build() + } + + context(_: ModelPackage) + private fun resolveErrorType(endpoint: Endpoint): TypeName { + val errorSchemas = endpoint.responses.entries + .asSequence() + .filter { !it.key.startsWith("2") } + .mapNotNull { it.value.schema } + .map { it.toTypeName() } + .distinct() + .toList() + + return when { + errorSchemas.size == 1 -> errorSchemas.single() + else -> JSON_ELEMENT + } + } + + context(_: ModelPackage) + private fun resolveReturnType(endpoint: Endpoint): TypeName = endpoint.responses.entries + .asSequence() + .filter { it.key.startsWith("2") } + .firstNotNullOfOrNull { it.value.schema } + ?.toTypeName() + ?: UNIT +} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ParametersGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ParametersGenerator.kt new file mode 100644 index 0000000..845b13f --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ParametersGenerator.kt @@ -0,0 +1,59 @@ +package com.avsystem.justworks.core.gen.client + +import com.avsystem.justworks.core.gen.BODY +import com.avsystem.justworks.core.gen.CHANNEL_PROVIDER +import com.avsystem.justworks.core.gen.CONTENT_TYPE_CLASS +import com.avsystem.justworks.core.gen.ModelPackage +import com.avsystem.justworks.core.gen.isBinaryUpload +import com.avsystem.justworks.core.gen.properties +import com.avsystem.justworks.core.gen.requiredProperties +import com.avsystem.justworks.core.gen.toCamelCase +import com.avsystem.justworks.core.gen.toTypeName +import com.avsystem.justworks.core.model.ContentType +import com.avsystem.justworks.core.model.RequestBody +import com.avsystem.justworks.core.model.TypeRef +import com.squareup.kotlinpoet.ParameterSpec +import com.squareup.kotlinpoet.STRING + +internal object ParametersGenerator { + context(_: ModelPackage) + fun buildMultipartParameters(requestBody: RequestBody): List = + requestBody.schema.properties.flatMap { prop -> + val name = prop.name.toCamelCase() + if (prop.type.isBinaryUpload()) { + listOf( + ParameterSpec(name, CHANNEL_PROVIDER), + ParameterSpec("${name}Name", STRING), + ParameterSpec("${name}ContentType", CONTENT_TYPE_CLASS), + ) + } else { + listOf( + ParameterSpec(name, prop.type.toTypeName()), + ) + } + } + + context(_: ModelPackage) + fun buildFormParameters(requestBody: RequestBody): List = requestBody.schema.properties.map { prop -> + val isRequired = requestBody.required && prop.name in requestBody.schema.requiredProperties + buildNullableParameter(prop.type, prop.name, isRequired) + } + + context(_: ModelPackage) + fun buildNullableParameter( + typeRef: TypeRef, + name: String, + required: Boolean, + ): ParameterSpec { + val builder = ParameterSpec.builder(name.toCamelCase(), typeRef.toTypeName().copy(nullable = !required)) + if (!required) builder.defaultValue("null") + return builder.build() + } + + context(_: ModelPackage) + fun buildBodyParams(requestBody: RequestBody) = when (requestBody.contentType) { + ContentType.MULTIPART_FORM_DATA -> buildMultipartParameters(requestBody) + ContentType.FORM_URL_ENCODED -> buildFormParameters(requestBody) + ContentType.JSON_CONTENT_TYPE -> listOf(buildNullableParameter(requestBody.schema, BODY, requestBody.required)) + } +} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/model/ModelGenerator.kt similarity index 60% rename from core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt rename to core/src/main/kotlin/com/avsystem/justworks/core/gen/model/ModelGenerator.kt index d59a5f0..4281efe 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/model/ModelGenerator.kt @@ -1,6 +1,40 @@ -package com.avsystem.justworks.core.gen +package com.avsystem.justworks.core.gen.model import arrow.core.raise.catch +import com.avsystem.justworks.core.gen.DECODER +import com.avsystem.justworks.core.gen.ENCODER +import com.avsystem.justworks.core.gen.EXPERIMENTAL_SERIALIZATION_API +import com.avsystem.justworks.core.gen.EXPERIMENTAL_UUID_API +import com.avsystem.justworks.core.gen.INSTANT +import com.avsystem.justworks.core.gen.InlineSchemaKey +import com.avsystem.justworks.core.gen.JSON_CLASS_DISCRIMINATOR +import com.avsystem.justworks.core.gen.JSON_CONTENT_POLYMORPHIC_SERIALIZER +import com.avsystem.justworks.core.gen.JSON_ELEMENT +import com.avsystem.justworks.core.gen.JSON_OBJECT_EXT +import com.avsystem.justworks.core.gen.K_SERIALIZER +import com.avsystem.justworks.core.gen.LOCAL_DATE +import com.avsystem.justworks.core.gen.ModelPackage +import com.avsystem.justworks.core.gen.NameRegistry +import com.avsystem.justworks.core.gen.OPT_IN +import com.avsystem.justworks.core.gen.PRIMITIVE_KIND +import com.avsystem.justworks.core.gen.PRIMITIVE_SERIAL_DESCRIPTOR_FUN +import com.avsystem.justworks.core.gen.SERIALIZABLE +import com.avsystem.justworks.core.gen.SERIALIZATION_EXCEPTION +import com.avsystem.justworks.core.gen.SERIALIZERS_MODULE +import com.avsystem.justworks.core.gen.SERIAL_DESCRIPTOR +import com.avsystem.justworks.core.gen.SERIAL_NAME +import com.avsystem.justworks.core.gen.USE_SERIALIZERS +import com.avsystem.justworks.core.gen.UUID_SERIALIZER +import com.avsystem.justworks.core.gen.UUID_TYPE +import com.avsystem.justworks.core.gen.invoke +import com.avsystem.justworks.core.gen.resolveInlineTypes +import com.avsystem.justworks.core.gen.resolveTypeRef +import com.avsystem.justworks.core.gen.sanitizeKdoc +import com.avsystem.justworks.core.gen.shared.SerializersModuleGenerator +import com.avsystem.justworks.core.gen.toCamelCase +import com.avsystem.justworks.core.gen.toEnumConstantName +import com.avsystem.justworks.core.gen.toInlinedName +import com.avsystem.justworks.core.gen.toTypeName import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.EnumModel import com.avsystem.justworks.core.model.PrimitiveType @@ -25,27 +59,47 @@ import kotlinx.datetime.LocalDate import kotlin.time.Instant /** - * Generates KotlinPoet [FileSpec] instances from an [ApiSpec]. + * Generates KotlinPoet [com.squareup.kotlinpoet.FileSpec] instances from an [com.avsystem.justworks.core.model.ApiSpec]. * - * Produces one file per [SchemaModel] (data class, sealed interface, or allOf composed class) - * and one file per [EnumModel] (enum class), all annotated with kotlinx.serialization annotations. + * Produces one file per [com.avsystem.justworks.core.model.SchemaModel] (data class, sealed interface, or allOf composed class) + * and one file per [com.avsystem.justworks.core.model.EnumModel] (enum class), all annotated with kotlinx.serialization annotations. */ -class ModelGenerator(private val modelPackage: String) { - fun generate(spec: ApiSpec): List = context( - buildHierarchyInfo(spec.schemas), - InlineSchemaDeduplicator(spec.schemas.map { it.name }.toSet()), - ) { - val schemaFiles = spec.schemas.flatMap { generateSchemaFiles(it) } - - val inlineSchemaFiles = collectAllInlineSchemas(spec).map { - if (it.isNested) generateNestedInlineClass(it) else generateDataClass(it) +internal object ModelGenerator { + data class GenerateResult(val files: List, val resolvedSpec: ApiSpec) + + context(_: ModelPackage) + fun generate(spec: ApiSpec, nameRegistry: NameRegistry): List = + generateWithResolvedSpec(spec, nameRegistry).files + + context(modelPackage: ModelPackage) + fun generateWithResolvedSpec(spec: ApiSpec, nameRegistry: NameRegistry): GenerateResult { + ensureReserved(spec, nameRegistry) + val (inlineSchemas, nameMap) = collectAllInlineSchemas(spec, nameRegistry) + val resolvedSpec = spec.resolveInlineTypes(nameMap) + + val resolvedInlineSchemas = inlineSchemas.map { schema -> + schema.copy( + properties = schema.properties.map { prop -> + prop.copy(type = resolvedSpec.resolveTypeRef(prop.type, nameMap)) + }, + ) } - val enumFiles = spec.enums.map(::generateEnumClass) + val files = context(buildHierarchyInfo(resolvedSpec.schemas)) { + val schemaFiles = resolvedSpec.schemas.flatMap { generateSchemaFiles(it) } + + val inlineSchemaFiles = resolvedInlineSchemas.map { generateDataClass(it) } + + val enumFiles = resolvedSpec.enums.map { generateEnumClass(it) } + + val serializersModuleFile = SerializersModuleGenerator.generate() - val serializersModuleFile = SerializersModuleGenerator(modelPackage).generate() + val uuidSerializerFile = if (resolvedSpec.usesUuid()) generateUuidSerializer() else null - schemaFiles + inlineSchemaFiles + enumFiles + listOfNotNull(serializersModuleFile) + schemaFiles + inlineSchemaFiles + enumFiles + listOfNotNull(serializersModuleFile, uuidSerializerFile) + } + + return GenerateResult(files, resolvedSpec) } data class HierarchyInfo( @@ -55,6 +109,7 @@ class ModelGenerator(private val modelPackage: String) { val schemas: List, ) + context(modelPackage: ModelPackage) private fun buildHierarchyInfo(schemas: List): HierarchyInfo { fun SchemaModel.variants() = oneOf ?: anyOf ?: emptyList() @@ -88,8 +143,22 @@ class ModelGenerator(private val modelPackage: String) { return HierarchyInfo(sealedHierarchies, variantParents, anyOfWithoutDiscriminator, schemas) } - context(deduplicator: InlineSchemaDeduplicator) - private fun collectAllInlineSchemas(spec: ApiSpec): List { + /** + * Ensures all top-level schema/enum names are reserved in [nameRegistry], + * preventing inline schemas from colliding with component types even if + * the caller supplied an empty registry. + */ + private fun ensureReserved(spec: ApiSpec, nameRegistry: NameRegistry) { + spec.schemas.forEach { nameRegistry.reserve(it.name) } + spec.enums.forEach { nameRegistry.reserve(it.name) } + nameRegistry.reserve(UUID_SERIALIZER.simpleName) + nameRegistry.reserve(SERIALIZERS_MODULE.simpleName) + } + + private fun collectAllInlineSchemas( + spec: ApiSpec, + nameRegistry: NameRegistry, + ): Pair, Map> { val endpointRefs = spec.endpoints.flatMap { endpoint -> val requestRef = endpoint.requestBody?.schema val responseRefs = endpoint.responses.values.map { it.schema } @@ -98,13 +167,18 @@ class ModelGenerator(private val modelPackage: String) { val schemaPropertyRefs = spec.schemas.flatMap { schema -> schema.properties.map { it.type } } - return collectInlineTypeRefs(endpointRefs + schemaPropertyRefs) + val nameMap = mutableMapOf() + + val schemas = collectInlineTypeRefs(endpointRefs + schemaPropertyRefs) .asSequence() .sortedBy { it.contextHint } .distinctBy { InlineSchemaKey.from(it.properties, it.requiredProperties) } .map { ref -> + val key = InlineSchemaKey.from(ref.properties, ref.requiredProperties) + val generatedName = nameRegistry.register(ref.contextHint.toInlinedName()) + nameMap[key] = generatedName SchemaModel( - name = deduplicator.getOrGenerateName(ref.properties, ref.requiredProperties, ref.contextHint), + name = generatedName, description = null, properties = ref.properties, requiredProperties = ref.requiredProperties, @@ -114,9 +188,11 @@ class ModelGenerator(private val modelPackage: String) { discriminator = null, ) }.toList() + + return schemas to nameMap } - context(hierarchy: HierarchyInfo) + context(hierarchy: HierarchyInfo, _: ModelPackage) private fun generateSchemaFiles(schema: SchemaModel): List = when { !schema.anyOf.isNullOrEmpty() || !schema.oneOf.isNullOrEmpty() -> { if (schema.name in hierarchy.anyOfWithoutDiscriminator) { @@ -127,7 +203,8 @@ class ModelGenerator(private val modelPackage: String) { } schema.isPrimitiveOnly -> { - listOf(generateTypeAlias(schema, STRING)) + val targetType = schema.underlyingType?.toTypeName() ?: STRING + listOf(generateTypeAlias(schema, targetType)) } else -> { @@ -140,7 +217,7 @@ class ModelGenerator(private val modelPackage: String) { * - anyOf without discriminator: @Serializable(with = XxxSerializer::class) * - oneOf or anyOf with discriminator: plain @Serializable + @JsonClassDiscriminator */ - context(hierarchy: HierarchyInfo) + context(hierarchy: HierarchyInfo, modelPackage: ModelPackage) private fun generateSealedInterface(schema: SchemaModel): FileSpec { val className = ClassName(modelPackage, schema.name) @@ -168,7 +245,7 @@ class ModelGenerator(private val modelPackage: String) { } if (schema.description != null) { - typeSpec.addKdoc("%L", schema.description) + typeSpec.addKdoc("%L", schema.description.sanitizeKdoc()) } val fileBuilder = FileSpec.builder(className).addType(typeSpec.build()) @@ -188,7 +265,7 @@ class ModelGenerator(private val modelPackage: String) { /** * Generates a JsonContentPolymorphicSerializer object for an anyOf schema without discriminator. */ - context(hierarchy: HierarchyInfo) + context(hierarchy: HierarchyInfo, modelPackage: ModelPackage) private fun generatePolymorphicSerializer(schema: SchemaModel): FileSpec { val sealedClassName = ClassName(modelPackage, schema.name) val serializerClassName = ClassName(modelPackage, "${schema.name}Serializer") @@ -244,6 +321,7 @@ class ModelGenerator(private val modelPackage: String) { /** * Builds the body code for selectDeserializer using field-presence heuristics. */ + context(modelPackage: ModelPackage) private fun buildSelectDeserializerBody( parentName: String, uniqueFieldsPerVariant: Map, @@ -286,7 +364,7 @@ class ModelGenerator(private val modelPackage: String) { /** * Generates a data class FileSpec, with superinterfaces and @SerialName resolved from hierarchy. */ - context(hierarchy: HierarchyInfo) + context(hierarchy: HierarchyInfo, modelPackage: ModelPackage) private fun generateDataClass(schema: SchemaModel): FileSpec { val className = ClassName(modelPackage, schema.name) @@ -304,7 +382,7 @@ class ModelGenerator(private val modelPackage: String) { val constructorBuilder = FunSpec.constructorBuilder() val propertySpecs = sortedProps.map { prop -> - val type = TypeMapping.toTypeName(prop.type, modelPackage).copy(nullable = prop.nullable) + val type = prop.type.toTypeName().copy(nullable = prop.nullable) val kotlinName = prop.name.toCamelCase() val paramBuilder = ParameterSpec.builder(kotlinName, type) @@ -316,11 +394,17 @@ class ModelGenerator(private val modelPackage: String) { constructorBuilder.addParameter(paramBuilder.build()) - PropertySpec + val propBuilder = PropertySpec .builder(kotlinName, type) .initializer(kotlinName) - .addAnnotation(AnnotationSpec.builder(SERIAL_NAME).addMember("%S", prop.name).build()) - .build() + .addAnnotation( + AnnotationSpec + .builder(SERIAL_NAME) + .addMember("%S", prop.name) + .build(), + ).apply { prop.description?.let { addKdoc("%L", it.sanitizeKdoc()) } } + + propBuilder.build() } val typeSpec = TypeSpec @@ -332,22 +416,44 @@ class ModelGenerator(private val modelPackage: String) { .addSuperinterfaces(superinterfaces) if (serialName != null) { - typeSpec.addAnnotation(AnnotationSpec.builder(SERIAL_NAME).addMember("%S", serialName).build()) + typeSpec.addAnnotation( + AnnotationSpec + .builder(SERIAL_NAME) + .addMember("%S", serialName) + .build(), + ) } if (schema.description != null) { - typeSpec.addKdoc("%L", schema.description) + typeSpec.addKdoc("%L", schema.description.sanitizeKdoc()) } - return FileSpec - .builder(className) - .addType(typeSpec.build()) - .build() + val fileBuilder = FileSpec.builder(className).addType(typeSpec.build()) + + val hasUuid = schema.properties.any { it.type.containsUuid() } + if (hasUuid) { + fileBuilder.addAnnotation( + AnnotationSpec + .builder(OPT_IN) + .addMember("%T::class", EXPERIMENTAL_UUID_API) + .build(), + ) + fileBuilder.addAnnotation( + AnnotationSpec + .builder(USE_SERIALIZERS) + .addMember("%T::class", UUID_SERIALIZER) + .build(), + ) + } + + return fileBuilder.build() } /** * Formats a default value from a PropertyModel for use in KotlinPoet ParameterSpec.defaultValue(). */ + + context(modelPackage: ModelPackage) private fun formatDefaultValue(prop: PropertyModel): CodeBlock = when (prop.type) { is TypeRef.Primitive -> { when (prop.type.type) { @@ -406,21 +512,28 @@ class ModelGenerator(private val modelPackage: String) { } ?: variantSchemaName + context(modelPackage: ModelPackage) private fun generateEnumClass(enum: EnumModel): FileSpec { val className = ClassName(modelPackage, enum.name) val typeSpec = TypeSpec.enumBuilder(className).addAnnotation(SERIALIZABLE) + val enumRegistry = NameRegistry() enum.values.forEach { value -> val anonymousClass = TypeSpec .anonymousClassBuilder() - .addAnnotation(AnnotationSpec.builder(SERIAL_NAME).addMember("%S", value).build()) + .addAnnotation( + AnnotationSpec + .builder(SERIAL_NAME) + .addMember("%S", value.name) + .build(), + ).apply { value.description?.let { addKdoc("%L", it.sanitizeKdoc()) } } .build() - typeSpec.addEnumConstant(value.toEnumConstantName(), anonymousClass) + typeSpec.addEnumConstant(enumRegistry.register(value.name.toEnumConstantName()), anonymousClass) } if (enum.description != null) { - typeSpec.addKdoc("%L", enum.description) + typeSpec.addKdoc("%L", enum.description.sanitizeKdoc()) } return FileSpec @@ -456,20 +569,82 @@ class ModelGenerator(private val modelPackage: String) { return visited.toList() } - context(_: HierarchyInfo) - private fun generateNestedInlineClass(schema: SchemaModel): FileSpec = - generateDataClass(schema.copy(name = schema.name.toInlinedName())) - private val SchemaModel.isPrimitiveOnly: Boolean get() = properties.isEmpty() && allOf == null && oneOf == null && anyOf == null + private fun TypeRef.containsUuid(): Boolean = when (this) { + is TypeRef.Primitive -> type == PrimitiveType.UUID + is TypeRef.Array -> items.containsUuid() + is TypeRef.Map -> valueType.containsUuid() + is TypeRef.Inline -> properties.any { it.type.containsUuid() } + is TypeRef.Reference, TypeRef.Unknown -> false + } + + private fun ApiSpec.usesUuid(): Boolean { + val schemaRefs = schemas.asSequence().flatMap { schema -> schema.properties.map { it.type } } + val endpointRefs = endpoints.asSequence().flatMap { endpoint -> + val responseRefs = endpoint.responses.values + .asSequence() + .mapNotNull { it.schema } + val requestRef = endpoint.requestBody?.schema + val parameterRefs = endpoint.parameters + .asSequence() + .map { it.schema } + responseRefs + listOfNotNull(requestRef) + parameterRefs + } + return schemaRefs.plus(endpointRefs).any { it.containsUuid() } + } + + private fun generateUuidSerializer(): FileSpec { + val descriptorProp = PropertySpec + .builder("descriptor", SERIAL_DESCRIPTOR) + .addModifiers(KModifier.OVERRIDE) + .initializer("%M(%S, %T.STRING)", PRIMITIVE_SERIAL_DESCRIPTOR_FUN, "Uuid", PRIMITIVE_KIND) + .build() + + val serializeFun = FunSpec + .builder("serialize") + .addModifiers(KModifier.OVERRIDE) + .addParameter("encoder", ENCODER) + .addParameter("value", UUID_TYPE) + .addStatement("encoder.encodeString(value.toString())") + .build() + + val deserializeFun = FunSpec + .builder("deserialize") + .addModifiers(KModifier.OVERRIDE) + .addParameter("decoder", DECODER) + .returns(UUID_TYPE) + .addStatement("return %T.parse(decoder.decodeString())", UUID_TYPE) + .build() + + val objectSpec = TypeSpec + .objectBuilder(UUID_SERIALIZER) + .addSuperinterface(K_SERIALIZER.parameterizedBy(UUID_TYPE)) + .addProperty(descriptorProp) + .addFunction(serializeFun) + .addFunction(deserializeFun) + .build() + + return FileSpec + .builder(UUID_SERIALIZER) + .addAnnotation( + AnnotationSpec + .builder(OPT_IN) + .addMember("%T::class", EXPERIMENTAL_UUID_API) + .build(), + ).addType(objectSpec) + .build() + } + + context(modelPackage: ModelPackage) private fun generateTypeAlias(schema: SchemaModel, primitiveType: TypeName): FileSpec { val className = ClassName(modelPackage, schema.name) val typeAlias = TypeAliasSpec.builder(schema.name, primitiveType) if (schema.description != null) { - typeAlias.addKdoc("%L", schema.description) + typeAlias.addKdoc("%L", schema.description.sanitizeKdoc()) } return FileSpec diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt similarity index 77% rename from core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGenerator.kt rename to core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt index b206f42..8786cf1 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiClientBaseGenerator.kt @@ -1,5 +1,34 @@ -package com.avsystem.justworks.core.gen - +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_FUN +import com.avsystem.justworks.core.gen.CLIENT +import com.avsystem.justworks.core.gen.CLOSEABLE +import com.avsystem.justworks.core.gen.CONTENT_NEGOTIATION +import com.avsystem.justworks.core.gen.CREATE_HTTP_CLIENT +import com.avsystem.justworks.core.gen.DESERIALIZE_ERROR_BODY_FUN +import com.avsystem.justworks.core.gen.EITHER +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_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 +import com.avsystem.justworks.core.gen.HTTP_RESULT +import com.avsystem.justworks.core.gen.HTTP_SUCCESS +import com.avsystem.justworks.core.gen.IO_EXCEPTION +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 import com.squareup.kotlinpoet.CodeBlock @@ -26,13 +55,13 @@ import com.squareup.kotlinpoet.UNIT * - `ApiClientBase` abstract class with common client infrastructure */ @OptIn(ExperimentalKotlinPoetApi::class) -object ApiClientBaseGenerator { - private const val BLOCK = "block" - private const val MAP_TO_RESULT = "mapToResult" - private const val SUCCESS_BODY = "successBody" +internal object ApiClientBaseGenerator { private const val SERIALIZERS_MODULE_PARAM = "serializersModule" + private const val SUCCESS_BODY = "successBody" + private const val MAP_TO_RESULT = "mapToResult" + private const val BLOCK = "block" - fun generate(securitySchemes: List? = null): FileSpec { + fun generate(securitySchemes: List): FileSpec { val t = TypeVariableName("T").copy(reified = true) val e = TypeVariableName("E").copy(reified = true) @@ -43,12 +72,12 @@ object ApiClientBaseGenerator { .addFunction(buildMapToResult(e, t)) .addFunction(buildToResult(e, t)) .addFunction(buildToEmptyResult(e)) - .addType(buildApiClientBaseClass(securitySchemes ?: emptyList(), isExplicit = securitySchemes != null)) + .addType(buildApiClientBaseClass(securitySchemes)) .build() } private fun buildEncodeParam(t: TypeVariableName): FunSpec = FunSpec - .builder("encodeParam") + .builder(ENCODE_PARAM_FUN.simpleName) .addModifiers(KModifier.INLINE) .addTypeVariable(t) .addParameter("value", TypeVariableName("T")) @@ -172,7 +201,7 @@ object ApiClientBaseGenerator { .addStatement("return %L { Unit }", MAP_TO_RESULT) .build() - private fun buildApiClientBaseClass(securitySchemes: List, isExplicit: Boolean): TypeSpec { + private fun buildApiClientBaseClass(securitySchemes: List): TypeSpec { val tokenType = LambdaTypeName.get(returnType = STRING) val authParams = buildAuthConstructorParams(securitySchemes) @@ -180,36 +209,28 @@ object ApiClientBaseGenerator { .constructorBuilder() .addParameter(BASE_URL, STRING) - val propertySpecs = mutableListOf() + val classBuilder = TypeSpec + .classBuilder(API_CLIENT_BASE) + .addModifiers(KModifier.ABSTRACT) + .addSuperinterface(CLOSEABLE) val baseUrlProp = PropertySpec .builder(BASE_URL, STRING) .initializer(BASE_URL) .addModifiers(KModifier.PROTECTED) .build() - propertySpecs.add(baseUrlProp) - if (authParams.isEmpty() && !isExplicit) { - // Backward compat: no securitySchemes info -> default token param - constructorBuilder.addParameter(TOKEN, tokenType) - propertySpecs.add( + classBuilder.addProperty(baseUrlProp) + + for (paramName in authParams) { + constructorBuilder.addParameter(paramName, tokenType) + classBuilder.addProperty( PropertySpec - .builder(TOKEN, tokenType) - .initializer(TOKEN) + .builder(paramName, tokenType) + .initializer(paramName) .addModifiers(KModifier.PRIVATE) .build(), ) - } else { - for ((paramName, _) in authParams) { - constructorBuilder.addParameter(paramName, tokenType) - propertySpecs.add( - PropertySpec - .builder(paramName, tokenType) - .initializer(paramName) - .addModifiers(KModifier.PRIVATE) - .build(), - ) - } } val clientProp = PropertySpec @@ -223,20 +244,11 @@ 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 + .primaryConstructor(constructorBuilder.build()) .addProperty(clientProp) .addFunction(closeFun) - .addFunction(buildApplyAuth(securitySchemes, isExplicit)) + .addFunction(buildApplyAuth(securitySchemes)) .addFunction(buildSafeCall()) .addFunction(buildCreateHttpClient()) .build() @@ -244,71 +256,55 @@ 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> { - if (securitySchemes.isEmpty()) return emptyList() - - 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 -> { - val paramName = if (isSingleBearer) TOKEN else "${scheme.name.toCamelCase()}Token" - listOf(paramName to scheme) - } + is SecurityScheme.Bearer if isSingleBearer -> listOf( + TOKEN, + ) - is SecurityScheme.ApiKey -> { - listOf("${scheme.name.toCamelCase()}Key" to scheme) - } + is SecurityScheme.Bearer -> listOf( + "${scheme.name.toCamelCase()}Token", + ) - is SecurityScheme.Basic -> { - listOf( - "${scheme.name.toCamelCase()}Username" to scheme, - "${scheme.name.toCamelCase()}Password" to scheme, - ) - } + is SecurityScheme.ApiKey -> listOf( + "${scheme.name.toCamelCase()}Key", + ) + + is SecurityScheme.Basic -> listOf( + "${scheme.name.toCamelCase()}Username", + "${scheme.name.toCamelCase()}Password", + ) } } } - private fun buildApplyAuth(securitySchemes: List, isExplicit: Boolean): FunSpec { + 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) .addModifiers(KModifier.PROTECTED) .receiver(HTTP_REQUEST_BUILDER) - // Explicitly empty schemes = no security at all -> empty applyAuth - if (isExplicit && securitySchemes.isEmpty()) { - return builder.build() - } - - // Backward compat: no schemes info means hardcoded Bearer with token param - if (securitySchemes.isEmpty()) { - builder.beginControlFlow("%M", HEADERS_FUN) - builder.addStatement( - "append(%T.Authorization, %P)", - HTTP_HEADERS, - CodeBlock.of($$"Bearer ${'$'}{$$TOKEN()}"), - ) - builder.endControlFlow() - return builder.build() - } - - val isSingleBearer = securitySchemes.size == 1 && securitySchemes.first() is SecurityScheme.Bearer + 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 = isSingleBearer(securitySchemes) + builder.beginControlFlow("%M", HEADERS_FUN) for (scheme in headerSchemes) { when (scheme) { @@ -317,7 +313,7 @@ object ApiClientBaseGenerator { builder.addStatement( "append(%T.Authorization, %P)", HTTP_HEADERS, - CodeBlock.of("Bearer \${$paramName()}"), + CodeBlock.of($$"Bearer ${$$paramName()}"), ) } @@ -328,7 +324,7 @@ 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/gen/ApiResponseGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiResponseGenerator.kt similarity index 95% rename from core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiResponseGenerator.kt rename to core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiResponseGenerator.kt index a16cb39..081f685 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiResponseGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/ApiResponseGenerator.kt @@ -1,5 +1,10 @@ -package com.avsystem.justworks.core.gen +package com.avsystem.justworks.core.gen.shared +import com.avsystem.justworks.core.gen.BODY +import com.avsystem.justworks.core.gen.EITHER +import com.avsystem.justworks.core.gen.HTTP_ERROR +import com.avsystem.justworks.core.gen.HTTP_RESULT +import com.avsystem.justworks.core.gen.HTTP_SUCCESS import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.INT @@ -19,7 +24,7 @@ import com.squareup.kotlinpoet.TypeVariableName * - `HttpResult` typealias as `Either, HttpSuccess>` * - `HttpSuccess` data class wrapping successful responses */ -object ApiResponseGenerator { +internal object ApiResponseGenerator { private const val CODE = "code" private val HTTP_ERROR_SUBTYPES = listOf( diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/SerializersModuleGenerator.kt similarity index 63% rename from core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt rename to core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/SerializersModuleGenerator.kt index 255807c..c4db460 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/shared/SerializersModuleGenerator.kt @@ -1,6 +1,12 @@ -package com.avsystem.justworks.core.gen - -import com.avsystem.justworks.core.gen.ModelGenerator.HierarchyInfo +package com.avsystem.justworks.core.gen.shared + +import com.avsystem.justworks.core.gen.GENERATED_SERIALIZERS_MODULE +import com.avsystem.justworks.core.gen.ModelPackage +import com.avsystem.justworks.core.gen.POLYMORPHIC_FUN +import com.avsystem.justworks.core.gen.SERIALIZERS_MODULE +import com.avsystem.justworks.core.gen.SUBCLASS_FUN +import com.avsystem.justworks.core.gen.invoke +import com.avsystem.justworks.core.gen.model.ModelGenerator import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.FileSpec @@ -12,17 +18,13 @@ import com.squareup.kotlinpoet.PropertySpec * Produces a top-level `val generatedSerializersModule: SerializersModule` property * that registers each sealed interface with its subclass variants. */ -class SerializersModuleGenerator(private val modelPackage: String) { - companion object { - const val FILE_NAME = "SerializersModule" - } - +internal object SerializersModuleGenerator { /** - * Generates a [FileSpec] containing the SerializersModule registration. + * Generates a [com.squareup.kotlinpoet.FileSpec] containing the SerializersModule registration. * Returns null if the hierarchy has no sealed types to register. */ - context(hierarchy: HierarchyInfo) + context(hierarchy: ModelGenerator.HierarchyInfo, modelPackage: ModelPackage) fun generate(): FileSpec? { // anyOf hierarchies without a discriminator use JsonContentPolymorphicSerializer // with custom deserialization logic, so they don't need SerializersModule registration. @@ -45,14 +47,13 @@ class SerializersModuleGenerator(private val modelPackage: String) { code.endControlFlow() - val prop = - PropertySpec - .builder(GENERATED_SERIALIZERS_MODULE, SERIALIZERS_MODULE) - .initializer(code.build()) - .build() + val prop = PropertySpec + .builder(GENERATED_SERIALIZERS_MODULE, SERIALIZERS_MODULE) + .initializer(code.build()) + .build() return FileSpec - .builder(modelPackage, FILE_NAME) + .builder(modelPackage.name, SERIALIZERS_MODULE.simpleName) .addProperty(prop) .build() } 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 dd0150f..af4a13d 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 @@ -37,6 +37,7 @@ data class Endpoint( val method: HttpMethod, val operationId: String, val summary: String?, + val description: String?, val tags: List, val parameters: List, val requestBody: RequestBody?, @@ -48,11 +49,7 @@ enum class HttpMethod { POST, PUT, DELETE, - PATCH; - - companion object { - fun parse(name: String): HttpMethod? = entries.find { it.name.equals(name, true) } - } + PATCH } data class Parameter( @@ -67,19 +64,22 @@ data class Parameter( enum class ParameterLocation { PATH, QUERY, - HEADER; - - companion object { - fun parse(name: String): ParameterLocation? = entries.find { it.name.equals(name, true) } - } + HEADER } data class RequestBody( val required: Boolean, - val contentType: String, + val contentType: ContentType, val schema: TypeRef, ) +// the order is important!!! +enum class ContentType(val value: String) { + MULTIPART_FORM_DATA("multipart/form-data"), + FORM_URL_ENCODED("application/x-www-form-urlencoded"), + JSON_CONTENT_TYPE("application/json"), +} + data class Response( val statusCode: String, val description: String?, @@ -95,6 +95,7 @@ data class SchemaModel( val oneOf: List?, val anyOf: List?, val discriminator: Discriminator?, + val underlyingType: TypeRef? = null, ) { val isNested get() = name.contains(".") } @@ -111,16 +112,14 @@ data class EnumModel( val name: String, val description: String?, val type: EnumBackingType, - val values: List, -) + val values: List, +) { + data class Value(val name: String, val description: String? = null) +} enum class EnumBackingType { STRING, - INTEGER; - - companion object { - fun parse(name: String): EnumBackingType? = entries.find { it.name.equals(name, true) } - } + INTEGER } data class Discriminator(val propertyName: String, val mapping: Map) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt b/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt index 8238725..17d5461 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt @@ -18,4 +18,4 @@ sealed interface TypeRef { data object Unknown : TypeRef } -enum class PrimitiveType { STRING, INT, LONG, DOUBLE, FLOAT, BOOLEAN, BYTE_ARRAY, DATE_TIME, DATE } +enum class PrimitiveType { STRING, INT, LONG, DOUBLE, FLOAT, BOOLEAN, BYTE_ARRAY, DATE_TIME, DATE, UUID } 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 f63ff99..db0f96e 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 @@ -1,14 +1,22 @@ package com.avsystem.justworks.core.parser import arrow.core.fold -import arrow.core.merge -import arrow.core.raise.context.Raise +import arrow.core.getOrElse +import arrow.core.left +import arrow.core.raise.ExperimentalRaiseAccumulateApi +import arrow.core.raise.Raise +import arrow.core.raise.context.either import arrow.core.raise.context.ensure import arrow.core.raise.context.ensureNotNull -import arrow.core.raise.either +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.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 import com.avsystem.justworks.core.model.Discriminator import com.avsystem.justworks.core.model.Endpoint import com.avsystem.justworks.core.model.EnumBackingType @@ -23,14 +31,20 @@ 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 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. @@ -38,18 +52,21 @@ 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.Failure -> handleErrors(result.errors) + * is ParseResult.Success -> result.value + * is ParseResult.Failure -> handleErrors(result.error) * } * ``` * * Both [Success] and [Failure] may carry [warnings] about non-fatal issues * encountered during parsing or validation. */ -sealed interface ParseResult { - data class Success(val apiSpec: ApiSpec, val warnings: List = emptyList()) : ParseResult - data class Failure(val errors: List, val warnings: List = emptyList()) : ParseResult +sealed interface ParseResult { + val warnings: List + + data class Success(val value: T, override val warnings: List) : ParseResult + + data class Failure(val error: Issue.Error, override val warnings: List) : ParseResult } object SpecParser { @@ -67,36 +84,75 @@ object SpecParser { * @return [ParseResult.Success] with the parsed model and any warnings, or * [ParseResult.Failure] with a non-empty list of error messages */ - fun parse(specFile: File): ParseResult = either { - 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(), + ) } - val swaggerResult = OpenAPIParser().readLocation(specFile.absolutePath, null, parseOptions) - val openApi = swaggerResult.openAPI - val swaggerMessages = swaggerResult.messages.orEmpty() + @OptIn(ExperimentalRaiseAccumulateApi::class) + private inline fun parseSpec( + specFile: File, + resolveFully: Boolean, + extract: context(Raise, Warnings) (OpenAPI) -> T, + ): ParseResult { + val result = iorNel { + either { + val openApi = loadOpenApi(specFile, resolveFully) + + ensureNotNull(openApi) { + Issue.Error("Failed to parse spec: ${specFile.name}") + } - ensureNotNull(openApi) { - ParseResult.Failure(swaggerMessages.ifEmpty { listOf("Failed to parse spec: ${specFile.name}") }) + extract(openApi) + } } + val warnings = result.leftOrNull().orEmpty() + val either = result.getOrElse { Issue.Error("Failed to parse spec: ${specFile.name}").left() } - val validationIssues = SpecValidator.validate(openApi) - val (errors, warnings) = validationIssues.partition { it is SpecValidator.ValidationIssue.Error } - val allWarnings = warnings.map { it.message } + swaggerMessages + return either.fold( + { ParseResult.Failure(it, warnings) }, + { ParseResult.Success(it, warnings) }, + ) + } - ensure(errors.isEmpty()) { - ParseResult.Failure(errors.map { it.message }, allWarnings) + /** + * 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 } - ParseResult.Success(openApi.toApiSpec(), warnings = allWarnings) - }.merge() + 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) + context(_: Raise, _: Warnings) private fun OpenAPI.toApiSpec(): ApiSpec { val allSchemas = components?.schemas.orEmpty() @@ -124,47 +180,69 @@ object SpecParser { } } + // Pick up synthetic schemas added by detectAndUnwrapOneOfWrappers. + // Iterate until stable, since processing a synthetic schema could register more. + tailrec fun collectModels(processed: Set, acc: List): List { + val currentKeys = componentSchemas.keys - allSchemas.keys - processed + return if (currentKeys.isEmpty()) { + acc + } else { + val newModels = currentKeys + .asSequence() + .mapNotNull { name -> componentSchemas[name]?.let { name to it } } + .filterNot { (_, schema) -> schema.isEnumSchema } + .map { (name, schema) -> extractSchemaModel(name, schema) } + + collectModels(processed + currentKeys, acc + newModels) + } + } + + val syntheticModels = collectModels(emptySet(), emptyList()) return ApiSpec( title = info?.title ?: "Untitled", version = info?.version ?: "0.0.0", endpoints = endpoints, - schemas = schemaModels, + schemas = schemaModels + syntheticModels, enums = enumModels, securitySchemes = securitySchemes, ) } } + context(_: Warnings) private fun extractSecuritySchemes( - definitions: Map, - requirements: List, + definitions: Map, + requirements: List, ): List { val referencedNames = requirements.flatMap { it.keys }.toSet() return referencedNames.mapNotNull { name -> - val scheme = definitions[name] ?: return@mapNotNull null - when (scheme.type) { - io.swagger.v3.oas.models.security.SecurityScheme.Type.HTTP -> { - when (scheme.scheme?.lowercase()) { - "bearer" -> SecurityScheme.Bearer(name) - "basic" -> SecurityScheme.Basic(name) - else -> null - } - } + ensureNotNullOrAccumulate(definitions[name]) { + Issue.Warning("Security requirement references undefined scheme '$name'") + }?.toSecurityScheme(name) + } + } - io.swagger.v3.oas.models.security.SecurityScheme.Type.APIKEY -> { - val location = when (scheme.`in`) { - io.swagger.v3.oas.models.security.SecurityScheme.In.HEADER -> ApiKeyLocation.HEADER - io.swagger.v3.oas.models.security.SecurityScheme.In.QUERY -> ApiKeyLocation.QUERY - else -> null - } ?: return@mapNotNull null - SecurityScheme.ApiKey(name, scheme.name, location) - } + 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 -> accumulate(Issue.Warning("Unsupported HTTP auth scheme '$scheme' for '$name'")) + } + } - else -> { - null - } + 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 -> accumulate(Issue.Warning("Unsupported API key location '${`in`}' for '$name'")) } } + + else -> { + accumulate(Issue.Warning("Unsupported security scheme type '$type' for '$name'")) + } } context(_: ComponentSchemaIdentity, _: ComponentSchemas) @@ -174,7 +252,7 @@ object SpecParser { pathItem .readOperationsMap() .asSequence() - .mapNotNull { (method, value) -> HttpMethod.parse(method.name)?.let { it to value } } + .mapNotNull { (method, value) -> method.name.toEnumOrNull()?.let { it to value } } .map { (method, operation) -> val operationId = operation.operationId ?: generateOperationId(method, path) @@ -185,11 +263,19 @@ object SpecParser { val requestBody = nullable { val body = operation.requestBody.bind() val content = body.content.bind() - val schema = content[JSON_CONTENT_TYPE]?.schema.bind() + + val contentType = ContentType.entries.find { it in content }.bind() + + val mediaType = content[contentType].bind() + + val schema = mediaType.schema + ?.toTypeRef("${operationId.replaceFirstChar { it.uppercase() }}Request") + .bind() + RequestBody( required = body.required ?: false, - contentType = JSON_CONTENT_TYPE, - schema = schema.toTypeRef("${operationId.replaceFirstChar { it.uppercase() }}Request"), + contentType = contentType, + schema = schema, ) } @@ -200,7 +286,7 @@ object SpecParser { statusCode = code, description = resp.description, schema = resp.content - ?.get(JSON_CONTENT_TYPE) + ?.get(ContentType.JSON_CONTENT_TYPE.value) ?.schema ?.toTypeRef("${operationId.replaceFirstChar { it.uppercase() }}Response"), ) @@ -211,6 +297,7 @@ object SpecParser { method = method, operationId = operationId, summary = operation.summary, + description = operation.description, tags = operation.tags.orEmpty(), parameters = mergedParams, requestBody = requestBody, @@ -222,15 +309,15 @@ object SpecParser { context(_: ComponentSchemaIdentity, _: ComponentSchemas) private fun SwaggerParameter.toParameter(): Parameter = Parameter( name = name ?: "", - location = ParameterLocation.parse(`in`) ?: ParameterLocation.QUERY, + location = `in`.toEnumOrNull() ?: ParameterLocation.QUERY, required = required ?: false, schema = schema?.toTypeRef() ?: TypeRef.Primitive(PrimitiveType.STRING), description = description, ) - // --- Schema extraction --- +// --- 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() } @@ -240,7 +327,7 @@ object SpecParser { val anyOf = schema.anyOf?.mapNotNull { it.resolveName() } ensure(oneOf.isNullOrEmpty() || anyOf.isNullOrEmpty()) { - ParseResult.Failure(listOf("Schema '$name' has both oneOf and anyOf. Use one combinator only.")) + Issue.Error("Schema '$name' has both oneOf and anyOf. Use one combinator only.") } val (properties, requiredProps) = @@ -261,6 +348,14 @@ object SpecParser { Discriminator(propertyName = propertyName, mapping = disc.mapping.orEmpty()) } + // Resolve underlying type for primitive-only / $ref-wrapper schemas. + // Uses $ref for wrapper schemas, otherwise resolves structurally + // from type/format to bypass componentSchemaIdentity (which would self-reference). + val underlyingType = schema + .takeIf { properties.isEmpty() && allOf.isNullOrEmpty() && oneOf.isNullOrEmpty() && anyOf.isNullOrEmpty() } + ?.let { s -> s.`$ref`?.removePrefix(SCHEMA_PREFIX)?.let(TypeRef::Reference) ?: s.resolveByType() } + ?.takeUnless { it is TypeRef.Unknown } + return SchemaModel( name = name, description = schema.description, @@ -270,19 +365,29 @@ object SpecParser { oneOf = oneOf?.let { it.map(TypeRef::Reference).ifEmpty { null } }, anyOf = anyOf?.let { it.map(TypeRef::Reference).ifEmpty { null } }, discriminator = discriminator, + underlyingType = underlyingType, ) } - private fun extractEnumModel(name: String, schema: Schema<*>): EnumModel = EnumModel( - name = name, - description = schema.description, - type = EnumBackingType.parse(schema.type) ?: EnumBackingType.STRING, - values = schema.enum.map { it.toString() }, - ) + private fun extractEnumModel(name: String, schema: Schema<*>): EnumModel { + val enumValues = schema.enum.map { it.toString() } + val valueDescriptions = when (val ext = schema.extensions?.get("x-enum-descriptions")) { + is List<*> if ext.size == enumValues.size -> enumValues.zip(ext).toMap() + is Map<*, *> -> ext + else -> emptyMap() + }.mapNotNull { (k, v) -> if (k is String && v is String) k to v else null }.toMap() - // --- allOf property merging --- + return EnumModel( + name = name, + description = schema.description, + type = schema.type.toEnumOrNull() ?: EnumBackingType.STRING, + values = enumValues.map { EnumModel.Value(it, valueDescriptions[it]) }, + ) + } - context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) +// --- allOf property merging --- + + 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()}" } @@ -332,12 +437,14 @@ object SpecParser { ) val schemaName = ensureNotNull( - propertySchema.resolveName() ?: propertyName - .takeIf { propertySchema.isInlineObject } - ?.also { name -> - componentSchemas[name] = propertySchema - componentSchemaIdentity[propertySchema] = name - }, + propertySchema.resolveName() + ?: propertyName + .takeIf { propertySchema.isInlineObject } + ?.let { rawName -> + componentSchemas[rawName] = propertySchema + componentSchemaIdentity[propertySchema] = rawName + rawName + }, ) propertyName to schemaName @@ -353,26 +460,30 @@ object SpecParser { 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 } - ?: when (type) { - "string" -> STRING_FORMAT_MAP[format] ?: TypeRef.Primitive(PrimitiveType.STRING) + ?: resolveByType(contextName) - "integer" -> INTEGER_FORMAT_MAP[format] ?: TypeRef.Primitive(PrimitiveType.INT) + /** Resolves a [TypeRef] based on the schema's structural type/format, ignoring component identity. */ + context(_: ComponentSchemaIdentity, _: ComponentSchemas) + private fun Schema<*>.resolveByType(contextName: String? = null): TypeRef = when (type) { + "string" -> STRING_FORMAT_MAP[format] ?: TypeRef.Primitive(PrimitiveType.STRING) - "number" -> NUMBER_FORMAT_MAP[format] ?: TypeRef.Primitive(PrimitiveType.DOUBLE) + "integer" -> INTEGER_FORMAT_MAP[format] ?: TypeRef.Primitive(PrimitiveType.INT) - "boolean" -> TypeRef.Primitive(PrimitiveType.BOOLEAN) + "number" -> NUMBER_FORMAT_MAP[format] ?: TypeRef.Primitive(PrimitiveType.DOUBLE) - "array" -> TypeRef.Array(items?.toTypeRef(contextName?.let { "${it}Item" }) ?: TypeRef.Unknown) + "boolean" -> TypeRef.Primitive(PrimitiveType.BOOLEAN) - "object" -> when (val ap = additionalProperties) { - is Schema<*> -> TypeRef.Map(ap.toTypeRef()) - is Boolean -> if (ap) TypeRef.Map(TypeRef.Unknown) else TypeRef.Unknown - else -> title?.let(TypeRef::Reference) ?: TypeRef.Unknown - } + "array" -> TypeRef.Array(items?.toTypeRef(contextName?.let { "${it}Item" }) ?: TypeRef.Unknown) - else -> TypeRef.Unknown + "object" -> when (val ap = additionalProperties) { + is Schema<*> -> TypeRef.Map(ap.toTypeRef()) + is Boolean -> if (ap) TypeRef.Map(TypeRef.Unknown) else TypeRef.Unknown + else -> title?.let(TypeRef::Reference) ?: TypeRef.Unknown } + else -> TypeRef.Unknown + } + context(_: ComponentSchemaIdentity, _: ComponentSchemas) private fun Schema<*>.toInlineTypeRef(contextName: String): TypeRef? = takeIf { isInlineObject }?.let { val required = required.orEmpty().toSet() @@ -421,23 +532,37 @@ object SpecParser { return method.name.lowercase() + segments } + operator fun Content.get(contentType: ContentType) = this[contentType.value] + + operator fun Content.contains(contentType: ContentType) = contentType.value in this + private fun String.toPascalCase(): String = split("-", "_", ".").joinToString("") { part -> part.replaceFirstChar { it.uppercase() } } - private const val JSON_CONTENT_TYPE = "application/json" private const val SCHEMA_PREFIX = "#/components/schemas/" private val STRING_FORMAT_MAP = mapOf( "byte" to TypeRef.Primitive(PrimitiveType.BYTE_ARRAY), + "binary" to TypeRef.Primitive(PrimitiveType.BYTE_ARRAY), "date-time" to TypeRef.Primitive(PrimitiveType.DATE_TIME), "date" to TypeRef.Primitive(PrimitiveType.DATE), + "uuid" to TypeRef.Primitive(PrimitiveType.UUID), + "uri" to TypeRef.Primitive(PrimitiveType.STRING), + "url" to TypeRef.Primitive(PrimitiveType.STRING), + "email" to TypeRef.Primitive(PrimitiveType.STRING), + "hostname" to TypeRef.Primitive(PrimitiveType.STRING), + "ipv4" to TypeRef.Primitive(PrimitiveType.STRING), + "ipv6" to TypeRef.Primitive(PrimitiveType.STRING), + "password" to TypeRef.Primitive(PrimitiveType.STRING), ) private val INTEGER_FORMAT_MAP = mapOf( + "int32" to TypeRef.Primitive(PrimitiveType.INT), "int64" to TypeRef.Primitive(PrimitiveType.LONG), ) private val NUMBER_FORMAT_MAP = mapOf( "float" to TypeRef.Primitive(PrimitiveType.FLOAT), + "double" to TypeRef.Primitive(PrimitiveType.DOUBLE), ) } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt index 8ccc15d..ac24363 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt @@ -1,61 +1,42 @@ package com.avsystem.justworks.core.parser -import arrow.core.raise.ExperimentalRaiseAccumulateApi -import arrow.core.raise.context.accumulate -import arrow.core.raise.context.ensureNotNullOrAccumulate -import arrow.core.raise.context.ensureOrAccumulate -import arrow.core.raise.fold +import com.avsystem.justworks.core.Issue +import com.avsystem.justworks.core.Warnings +import com.avsystem.justworks.core.ensureNotNullOrAccumulate +import com.avsystem.justworks.core.ensureOrAccumulate import io.swagger.v3.oas.models.OpenAPI object SpecValidator { - sealed class ValidationIssue { - abstract val message: String - - class Error(override val message: String) : ValidationIssue() - - class Warning(override val message: String) : ValidationIssue() - } - /** * Validates a parsed OpenAPI model for required fields and unsupported constructs. * - * Collects all issues without short-circuiting (using Arrow [accumulate]) so that - * callers receive the full list of problems in a single call. - * - * Returned issues are either [ValidationIssue.Error] (spec is unusable) or - * [ValidationIssue.Warning] (spec can be processed but some features will be ignored). + * Accumulates all warnings without short-circuiting so that callers receive the + * full list of problems in a single call. * * @param openApi the parsed OpenAPI model from Swagger Parser - * @return list of [ValidationIssue]; empty when the spec is fully valid */ - @OptIn(ExperimentalRaiseAccumulateApi::class) - fun validate(openApi: OpenAPI): List = fold( - { - accumulate { - ensureNotNullOrAccumulate(openApi.info) { - ValidationIssue.Error("Spec is missing required 'info' section") - } + context(_: Warnings) + fun validate(openApi: OpenAPI) { + ensureNotNullOrAccumulate(openApi.info) { + Issue.Warning("Spec is missing required 'info' section") + } - ensureOrAccumulate(!openApi.paths.isNullOrEmpty()) { - ValidationIssue.Warning("Spec has no paths defined") - } - // Detect unsupported constructs for v1 - openApi.paths?.values?.forEach { pathItem -> - pathItem.readOperationsMap()?.values?.forEach { operation -> - ensureOrAccumulate(operation.callbacks.isNullOrEmpty()) { - ValidationIssue.Warning("Callbacks are not supported in v1 and will be ignored") - } - } - } + ensureOrAccumulate(!openApi.paths.isNullOrEmpty()) { + Issue.Warning("Spec has no paths defined") + } - openApi.components?.links?.let { links -> - ensureOrAccumulate(links.isEmpty()) { - ValidationIssue.Warning("Links are not supported in v1 and will be ignored") - } + openApi.paths?.values?.forEach { pathItem -> + pathItem.readOperationsMap()?.values?.forEach { operation -> + ensureOrAccumulate(operation.callbacks.isNullOrEmpty()) { + Issue.Warning("Callbacks are not supported in v1 and will be ignored") } } - }, - { it }, - { emptyList() }, - ) + } + + openApi.components?.links?.let { links -> + ensureOrAccumulate(links.isEmpty()) { + Issue.Warning("Links are not supported in v1 and will be ignored") + } + } + } } 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 b2920b1..ae72113 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,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 @@ -14,7 +15,7 @@ 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" } @@ -53,13 +54,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 @@ -78,14 +76,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 @@ -201,7 +197,7 @@ class ApiClientBaseGeneratorTest { 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 (backward compat)") + assertTrue("token" in params, "Expected token param (single-bearer shorthand)") } @Test 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 501dd90..c6ff87d 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 @@ -1,5 +1,6 @@ package com.avsystem.justworks.core.gen +import com.avsystem.justworks.core.gen.shared.ApiResponseGenerator import com.squareup.kotlinpoet.KModifier import com.squareup.kotlinpoet.TypeAliasSpec import com.squareup.kotlinpoet.TypeSpec 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 9d44ec4..6d3a134 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,34 +1,45 @@ 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 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.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 import com.squareup.kotlinpoet.KModifier import com.squareup.kotlinpoet.ParameterizedTypeName import com.squareup.kotlinpoet.TypeSpec import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue class ClientGeneratorTest { private val apiPackage = "com.example.api" private val modelPackage = "com.example.model" - private val generator = ClientGenerator(apiPackage, modelPackage) - private fun spec(endpoints: List, securitySchemes: List = emptyList(),) = ApiSpec( + private fun generate(spec: ApiSpec, hasPolymorphicTypes: Boolean = false): List = + context(ModelPackage(modelPackage), ApiPackage(apiPackage)) { + ClientGenerator.generate(spec, hasPolymorphicTypes, NameRegistry()) + } + + 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, + endpoints = endpoints.toList(), schemas = emptyList(), enums = emptyList(), securitySchemes = securitySchemes, @@ -38,6 +49,8 @@ class ClientGeneratorTest { path: String = "/pets", method: HttpMethod = HttpMethod.GET, operationId: String = "listPets", + summary: String? = null, + description: String? = null, tags: List = listOf("Pets"), parameters: List = emptyList(), requestBody: RequestBody? = null, @@ -49,15 +62,18 @@ class ClientGeneratorTest { path = path, method = method, operationId = operationId, - summary = null, + summary = summary, + description = description, tags = tags, parameters = parameters, requestBody = requestBody, responses = responses, ) - private fun clientClass(endpoints: List, securitySchemes: List = emptyList(),): TypeSpec { - val files = generator.generate(spec(endpoints, securitySchemes)) + 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 @@ -69,12 +85,11 @@ class ClientGeneratorTest { @Test fun `generates one client class per tag`() { - val endpoints = - listOf( - endpoint(operationId = "listPets", tags = listOf("Pets")), - endpoint(path = "/store", operationId = "getInventory", tags = listOf("Store")), - ) - val files = generator.generate(spec(endpoints)) + val endpoints = arrayOf( + endpoint(operationId = "listPets", tags = listOf("Pets")), + endpoint(path = "/store", operationId = "getInventory", tags = listOf("Store")), + ) + val files = generate(spec(*endpoints)) assertEquals(2, files.size) val classNames = files @@ -91,7 +106,7 @@ class ClientGeneratorTest { @Test fun `endpoint functions are suspend`() { - val cls = clientClass(listOf(endpoint())) + val cls = clientClass(endpoint()) val funSpec = cls.funSpecs.first { it.name == "listPets" } assertTrue(KModifier.SUSPEND in funSpec.modifiers, "Expected SUSPEND modifier") } @@ -100,23 +115,16 @@ class ClientGeneratorTest { @Test fun `supports all HTTP methods`() { - val methods = - listOf( - HttpMethod.GET to "getPet", - HttpMethod.POST to "createPet", - HttpMethod.PUT to "updatePet", - HttpMethod.DELETE to "deletePet", - HttpMethod.PATCH to "patchPet", - ) - val endpoints = - methods.map { (method, opId) -> - endpoint(method = method, operationId = opId) - } - val cls = clientClass(endpoints) - val funBodies = - cls.funSpecs.associate { - it.name to it.body.toString() - } + val methods = listOf( + HttpMethod.GET to "getPet", + HttpMethod.POST to "createPet", + HttpMethod.PUT to "updatePet", + HttpMethod.DELETE to "deletePet", + HttpMethod.PATCH to "patchPet", + ) + val endpoints = methods.map { (method, opId) -> endpoint(method = method, operationId = opId) }.toTypedArray() + val cls = clientClass(*endpoints) + val funBodies = cls.funSpecs.associate { it.name to it.body.toString() } assertTrue( funBodies["getPet"]!!.contains("request.get(") || funBodies["getPet"]!!.contains("request.`get`("), "GET method expected", @@ -148,12 +156,11 @@ class ClientGeneratorTest { endpoint( path = "/pets/{petId}", operationId = "getPet", - parameters = - listOf( - Parameter("petId", ParameterLocation.PATH, true, TypeRef.Primitive(PrimitiveType.LONG), null), - ), + parameters = listOf( + Parameter("petId", ParameterLocation.PATH, true, TypeRef.Primitive(PrimitiveType.LONG), null), + ), ) - val cls = clientClass(listOf(ep)) + val cls = clientClass(ep) val funSpec = cls.funSpecs.first { it.name == "getPet" } val param = funSpec.parameters.first { it.name == "petId" } assertEquals("kotlin.Long", param.type.toString()) @@ -171,7 +178,7 @@ class ClientGeneratorTest { Parameter("limit", ParameterLocation.QUERY, true, TypeRef.Primitive(PrimitiveType.INT), null), ), ) - val cls = clientClass(listOf(ep)) + val cls = clientClass(ep) val funSpec = cls.funSpecs.first { it.name == "listPets" } val param = funSpec.parameters.first { it.name == "limit" } assertEquals("kotlin.Int", param.type.toString()) @@ -181,15 +188,14 @@ class ClientGeneratorTest { @Test fun `optional query parameters default to null`() { - val ep = - endpoint( - operationId = "listPets", - parameters = - listOf( - Parameter("limit", ParameterLocation.QUERY, false, TypeRef.Primitive(PrimitiveType.INT), null), - ), - ) - val cls = clientClass(listOf(ep)) + val ep = endpoint( + operationId = "listPets", + parameters = + listOf( + Parameter("limit", ParameterLocation.QUERY, false, TypeRef.Primitive(PrimitiveType.INT), null), + ), + ) + val cls = clientClass(ep) val funSpec = cls.funSpecs.first { it.name == "listPets" } val param = funSpec.parameters.first { it.name == "limit" } assertTrue(param.type.isNullable, "Optional query param should be nullable") @@ -200,13 +206,12 @@ class ClientGeneratorTest { @Test fun `request body becomes function parameter`() { - val ep = - endpoint( - method = HttpMethod.POST, - operationId = "createPet", - requestBody = RequestBody(true, "application/json", TypeRef.Reference("Pet")), - ) - val cls = clientClass(listOf(ep)) + val ep = endpoint( + method = HttpMethod.POST, + operationId = "createPet", + requestBody = RequestBody(true, ContentType.JSON_CONTENT_TYPE, TypeRef.Reference("Pet")), + ) + val cls = clientClass(ep) val funSpec = cls.funSpecs.first { it.name == "createPet" } val bodyParam = funSpec.parameters.first { it.name == "body" } assertEquals("com.example.model.Pet", bodyParam.type.toString()) @@ -216,7 +221,7 @@ class ClientGeneratorTest { @Test fun `return type is HttpResult parameterized`() { - val cls = clientClass(listOf(endpoint())) + val cls = clientClass(endpoint()) val funSpec = cls.funSpecs.first { it.name == "listPets" } val returnType = funSpec.returnType assertNotNull(returnType) @@ -239,7 +244,7 @@ class ClientGeneratorTest { @OptIn(ExperimentalKotlinPoetApi::class) @Test fun `endpoint functions have no context parameters`() { - val cls = clientClass(listOf(endpoint())) + val cls = clientClass(endpoint()) val funSpec = cls.funSpecs.first { it.name == "listPets" } assertTrue(funSpec.contextParameters.isEmpty(), "Expected no context parameters") } @@ -248,21 +253,20 @@ class ClientGeneratorTest { @Test fun `header parameters become function parameters`() { - val ep = - endpoint( - operationId = "listPets", - parameters = - listOf( - Parameter( - "X-Request-Id", - ParameterLocation.HEADER, - true, - TypeRef.Primitive(PrimitiveType.STRING), - null, - ), + val ep = endpoint( + operationId = "listPets", + parameters = + listOf( + Parameter( + "X-Request-Id", + ParameterLocation.HEADER, + true, + TypeRef.Primitive(PrimitiveType.STRING), + null, ), - ) - val cls = clientClass(listOf(ep)) + ), + ) + val cls = clientClass(ep) val funSpec = cls.funSpecs.first { it.name == "listPets" } val param = funSpec.parameters.first { it.name == "xRequestId" } assertEquals("kotlin.String", param.type.toString()) @@ -270,21 +274,20 @@ class ClientGeneratorTest { @Test fun `header parameters are emitted inside headers block`() { - val ep = - endpoint( - operationId = "listPets", - parameters = - listOf( - Parameter( - "X-Request-Id", - ParameterLocation.HEADER, - true, - TypeRef.Primitive(PrimitiveType.STRING), - null, - ), + val ep = endpoint( + operationId = "listPets", + parameters = + listOf( + Parameter( + "X-Request-Id", + ParameterLocation.HEADER, + true, + TypeRef.Primitive(PrimitiveType.STRING), + null, ), - ) - val cls = clientClass(listOf(ep)) + ), + ) + val cls = clientClass(ep) val funSpec = cls.funSpecs.first { it.name == "listPets" } val body = funSpec.body.toString() assertTrue(body.contains("headers"), "Expected headers block in generated body") @@ -295,20 +298,20 @@ class ClientGeneratorTest { @Test fun `client constructor has baseUrl parameter`() { - val cls = clientClass(listOf(endpoint())) + val cls = clientClass(endpoint()) val constructor = assertNotNull(cls.primaryConstructor) val baseUrl = constructor.parameters.first { it.name == "baseUrl" } 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`() { - val cls = clientClass(listOf(endpoint())) + 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 -- @@ -316,7 +319,7 @@ class ClientGeneratorTest { @Test fun `untagged endpoints go to DefaultApi`() { val ep = endpoint(operationId = "healthCheck", tags = emptyList()) - val files = generator.generate(spec(listOf(ep))) + val files = generate(spec(ep)) val className = files .first() @@ -331,13 +334,12 @@ class ClientGeneratorTest { @Test fun `void response uses HttpResult with Unit type parameter`() { - val ep = - endpoint( - method = HttpMethod.DELETE, - operationId = "deletePet", - responses = mapOf("204" to Response("204", "No content", null)), - ) - val cls = clientClass(listOf(ep)) + val ep = endpoint( + method = HttpMethod.DELETE, + operationId = "deletePet", + responses = mapOf("204" to Response("204", "No content", null)), + ) + val cls = clientClass(ep) val funSpec = cls.funSpecs.first { it.name == "deletePet" } val returnType = funSpec.returnType as ParameterizedTypeName assertEquals("com.avsystem.justworks.HttpResult", returnType.rawType.toString()) @@ -353,11 +355,45 @@ class ClientGeneratorTest { ) } + // -- CONT-03: Response code handling -- + + @Test + fun `201 Created with schema returns typed response`() { + val ep = endpoint( + method = HttpMethod.POST, + operationId = "createPet", + responses = mapOf( + "201" to Response("201", "Created", TypeRef.Reference("Pet")), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "createPet" } + val returnType = funSpec.returnType as ParameterizedTypeName + assertEquals("com.avsystem.justworks.HttpResult", returnType.rawType.toString()) + assertEquals("com.example.model.Pet", returnType.typeArguments[1].toString()) + } + + @Test + fun `mixed 200 and 204 responses uses 200 schema type`() { + val ep = endpoint( + method = HttpMethod.DELETE, + operationId = "removePet", + responses = mapOf( + "200" to Response("200", "OK", TypeRef.Reference("Pet")), + "204" to Response("204", "No content", null), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "removePet" } + val returnType = funSpec.returnType as ParameterizedTypeName + assertEquals("com.example.model.Pet", returnType.typeArguments[1].toString()) + } + // -- Client class extends ApiClientBase -- @Test fun `client class extends ApiClientBase`() { - val cls = clientClass(listOf(endpoint())) + val cls = clientClass(endpoint()) assertEquals("com.avsystem.justworks.ApiClientBase", cls.superclass.toString()) } @@ -365,10 +401,7 @@ class ClientGeneratorTest { @Test fun `polymorphic spec wires serializersModule in createHttpClient call`() { - val files = ClientGenerator( - apiPackage, - modelPackage, - ).generate(spec(listOf(endpoint())), hasPolymorphicTypes = true) + val files = generate(spec(endpoint()), hasPolymorphicTypes = true) val clientProperty = files .first() .members @@ -384,12 +417,207 @@ class ClientGeneratorTest { assertTrue(clientInitializer.contains("createHttpClient"), "Expected createHttpClient call") } + // -- CONT-01: Multipart form-data code generation -- + + @Test + fun `multipart endpoint generates submitFormWithBinaryData call`() { + val ep = endpoint( + method = HttpMethod.POST, + operationId = "uploadFile", + requestBody = RequestBody( + required = true, + contentType = ContentType.MULTIPART_FORM_DATA, + schema = TypeRef.Inline( + properties = listOf( + PropertyModel("file", TypeRef.Primitive(PrimitiveType.BYTE_ARRAY), null, false), + PropertyModel("description", TypeRef.Primitive(PrimitiveType.STRING), null, false), + ), + requiredProperties = setOf("file", "description"), + contextHint = "request", + ), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "uploadFile" } + val body = funSpec.body.toString() + assertTrue(body.contains("submitFormWithBinaryData"), "Expected submitFormWithBinaryData call") + assertTrue(body.contains("formData"), "Expected formData builder") + } + + @Test + fun `multipart endpoint has ChannelProvider param for binary field`() { + val ep = endpoint( + method = HttpMethod.POST, + operationId = "uploadFile", + requestBody = RequestBody( + required = true, + contentType = ContentType.MULTIPART_FORM_DATA, + schema = TypeRef.Inline( + properties = listOf( + PropertyModel("file", TypeRef.Primitive(PrimitiveType.BYTE_ARRAY), null, false), + ), + requiredProperties = setOf("file"), + contextHint = "request", + ), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "uploadFile" } + val paramTypes = funSpec.parameters.associate { it.name to it.type.toString() } + assertEquals("io.ktor.client.request.forms.ChannelProvider", paramTypes["file"]) + assertEquals("kotlin.String", paramTypes["fileName"]) + assertEquals("io.ktor.http.ContentType", paramTypes["fileContentType"]) + } + + @Test + fun `multipart text fields use simple append`() { + val ep = endpoint( + method = HttpMethod.POST, + operationId = "uploadFile", + requestBody = RequestBody( + required = true, + contentType = ContentType.MULTIPART_FORM_DATA, + schema = TypeRef.Inline( + properties = listOf( + PropertyModel("file", TypeRef.Primitive(PrimitiveType.BYTE_ARRAY), null, false), + PropertyModel("description", TypeRef.Primitive(PrimitiveType.STRING), null, false), + ), + requiredProperties = setOf("file", "description"), + contextHint = "request", + ), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "uploadFile" } + val body = funSpec.body.toString() + assertTrue(body.contains("append(\"description\", description)"), "Expected simple append for text field") + } + + @Test + fun `multipart binary fields include ContentDisposition header`() { + val ep = endpoint( + method = HttpMethod.POST, + operationId = "uploadFile", + requestBody = RequestBody( + required = true, + contentType = ContentType.MULTIPART_FORM_DATA, + schema = TypeRef.Inline( + properties = listOf( + PropertyModel("file", TypeRef.Primitive(PrimitiveType.BYTE_ARRAY), null, false), + ), + requiredProperties = setOf("file"), + contextHint = "request", + ), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "uploadFile" } + val body = funSpec.body.toString() + assertTrue(body.contains("ContentDisposition"), "Expected ContentDisposition in headers") + assertTrue(body.contains("filename"), "Expected filename in ContentDisposition") + } + + @Test + fun `existing JSON requestBody still generates setBody pattern`() { + val ep = endpoint( + method = HttpMethod.POST, + operationId = "createPet", + requestBody = RequestBody(true, ContentType.JSON_CONTENT_TYPE, TypeRef.Reference("Pet")), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "createPet" } + val body = funSpec.body.toString() + assertTrue(body.contains("setBody"), "Expected setBody for JSON content type") + assertFalse(body.contains("submitForm"), "Should NOT contain submitForm for JSON") + } + + // -- CONT-02: Form-urlencoded code generation -- + + @Test + fun `form-urlencoded endpoint generates submitForm call`() { + val ep = endpoint( + method = HttpMethod.POST, + operationId = "createUser", + requestBody = RequestBody( + required = true, + contentType = ContentType.FORM_URL_ENCODED, + schema = TypeRef.Inline( + properties = listOf( + PropertyModel("username", TypeRef.Primitive(PrimitiveType.STRING), null, false), + PropertyModel("age", TypeRef.Primitive(PrimitiveType.INT), null, false), + ), + requiredProperties = setOf("username", "age"), + contextHint = "request", + ), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "createUser" } + val body = funSpec.body.toString() + assertTrue(body.contains("submitForm"), "Expected submitForm call") + assertTrue(body.contains("parameters"), "Expected parameters builder") + + val paramTypes = funSpec.parameters.associate { it.name to it.type.toString() } + assertEquals("kotlin.String", paramTypes["username"]) + assertEquals("kotlin.Int", paramTypes["age"]) + } + + @Test + fun `form-urlencoded non-string params use toString`() { + val ep = endpoint( + method = HttpMethod.POST, + operationId = "createUser", + requestBody = RequestBody( + required = true, + contentType = ContentType.FORM_URL_ENCODED, + schema = TypeRef.Inline( + properties = listOf( + PropertyModel("username", TypeRef.Primitive(PrimitiveType.STRING), null, false), + PropertyModel("age", TypeRef.Primitive(PrimitiveType.INT), null, false), + ), + requiredProperties = setOf("username", "age"), + contextHint = "request", + ), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "createUser" } + val body = funSpec.body.toString() + assertTrue(body.contains("age.toString()"), "Expected toString() for non-string param") + assertFalse(body.contains("username.toString()"), "String param should NOT use toString()") + } + + @Test + fun `form-urlencoded optional field generates nullable param with guard`() { + val ep = endpoint( + method = HttpMethod.POST, + operationId = "createUser", + requestBody = RequestBody( + required = true, + contentType = ContentType.FORM_URL_ENCODED, + schema = TypeRef.Inline( + properties = listOf( + PropertyModel("username", TypeRef.Primitive(PrimitiveType.STRING), null, false), + PropertyModel("nickname", TypeRef.Primitive(PrimitiveType.STRING), null, false), + ), + requiredProperties = setOf("username"), + contextHint = "request", + ), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "createUser" } + val nicknameParam = funSpec.parameters.first { it.name == "nickname" } + assertTrue(nicknameParam.type.isNullable, "Optional form field should be nullable") + assertEquals("null", nicknameParam.defaultValue.toString()) + + val body = funSpec.body.toString() + assertTrue(body.contains("if (nickname != null)"), "Expected null guard for optional field") + } + @Test fun `non-polymorphic spec has createHttpClient without serializersModule`() { - val files = ClientGenerator( - apiPackage, - modelPackage, - ).generate(spec(listOf(endpoint())), hasPolymorphicTypes = false) + val files = generate(spec(endpoint()), hasPolymorphicTypes = false) val clientProperty = files .first() .members @@ -406,7 +634,7 @@ class ClientGeneratorTest { @Test fun `generated code calls applyAuth`() { - val cls = clientClass(listOf(endpoint())) + val cls = clientClass(endpoint()) val funSpec = cls.funSpecs.first { it.name == "listPets" } val body = funSpec.body.toString() assertTrue(body.contains("applyAuth()"), "Expected applyAuth() call") @@ -414,7 +642,7 @@ class ClientGeneratorTest { @Test fun `generated code calls safeCall`() { - val cls = clientClass(listOf(endpoint())) + val cls = clientClass(endpoint()) val funSpec = cls.funSpecs.first { it.name == "listPets" } val body = funSpec.body.toString() assertTrue(body.contains("safeCall"), "Expected safeCall call") @@ -422,7 +650,7 @@ class ClientGeneratorTest { @Test fun `generated code calls toResult for typed response`() { - val cls = clientClass(listOf(endpoint())) + val cls = clientClass(endpoint()) val funSpec = cls.funSpecs.first { it.name == "listPets" } val body = funSpec.body.toString() assertTrue(body.contains("toResult"), "Expected toResult call") @@ -436,7 +664,7 @@ class ClientGeneratorTest { operationId = "deletePet", responses = mapOf("204" to Response("204", "No content", null)), ) - val cls = clientClass(listOf(ep)) + val cls = clientClass(ep) val funSpec = cls.funSpecs.first { it.name == "deletePet" } val body = funSpec.body.toString() assertTrue(body.contains("toEmptyResult"), "Expected toEmptyResult call") @@ -444,14 +672,6 @@ class ClientGeneratorTest { // -- SECU: Security-aware constructor generation -- - @Test - fun `no securitySchemes generates backward compat constructor with baseUrl and token`() { - val cls = clientClass(listOf(endpoint())) - val constructor = assertNotNull(cls.primaryConstructor) - val paramNames = constructor.parameters.map { it.name } - assertEquals(listOf("baseUrl", "token"), paramNames) - } - @Test fun `ApiKey HEADER scheme generates constructor with baseUrl and apiKey param`() { val cls = clientClass( @@ -500,9 +720,8 @@ class ClientGeneratorTest { } @Test - fun `empty securitySchemes generates backward compat constructor with token`() { - // Empty securitySchemes = backward compat (spec doesn't define security info) - // ClientGenerator always gets spec.securitySchemes which defaults to emptyList() + 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", @@ -511,7 +730,7 @@ class ClientGeneratorTest { enums = emptyList(), securitySchemes = emptyList(), ) - val files = generator.generate(spec) + val files = generate(spec) val cls = files .first() .members @@ -520,9 +739,9 @@ class ClientGeneratorTest { val constructor = assertNotNull(cls.primaryConstructor) val paramNames = constructor.parameters.map { it.name } assertEquals( - listOf("baseUrl", "token"), + listOf("baseUrl"), paramNames, - "Expected backward compat params for empty security schemes", + "Expected only baseUrl param when security is explicitly empty", ) } @@ -536,7 +755,7 @@ class ClientGeneratorTest { "400" to Response("400", "Bad request", TypeRef.Reference("ValidationError")), ), ) - val cls = clientClass(listOf(ep)) + val cls = clientClass(ep) val funSpec = cls.funSpecs.first { it.name == "listPets" } val returnType = funSpec.returnType as ParameterizedTypeName assertEquals( @@ -555,7 +774,7 @@ class ClientGeneratorTest { "422" to Response("422", "Unprocessable", TypeRef.Reference("ValidationError")), ), ) - val cls = clientClass(listOf(ep)) + val cls = clientClass(ep) val funSpec = cls.funSpecs.first { it.name == "listPets" } val returnType = funSpec.returnType as ParameterizedTypeName assertEquals( @@ -574,7 +793,7 @@ class ClientGeneratorTest { "404" to Response("404", "Not found", TypeRef.Reference("NotFoundError")), ), ) - val cls = clientClass(listOf(ep)) + val cls = clientClass(ep) val funSpec = cls.funSpecs.first { it.name == "listPets" } val returnType = funSpec.returnType as ParameterizedTypeName assertEquals( @@ -592,7 +811,7 @@ class ClientGeneratorTest { "401" to Response("401", "Unauthorized", null), ), ) - val cls = clientClass(listOf(ep)) + val cls = clientClass(ep) val funSpec = cls.funSpecs.first { it.name == "listPets" } val returnType = funSpec.returnType as ParameterizedTypeName assertEquals( @@ -603,13 +822,83 @@ class ClientGeneratorTest { } @Test - fun `single Bearer scheme uses token param name for backward compat`() { + 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 (backward compat)") + assertTrue("token" in paramNames, "Expected token param (single-bearer shorthand)") + } + + // -- DOCS-03: Endpoint KDoc generation -- + + @Test + fun `endpoint with summary generates KDoc`() { + val ep = endpoint(summary = "List all pets") + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "listPets" } + assertTrue( + funSpec.kdoc.toString().contains("List all pets"), + "Expected KDoc with summary, got: ${funSpec.kdoc}", + ) + } + + @Test + fun `endpoint with summary and description generates KDoc with both`() { + val ep = endpoint(summary = "List pets", description = "Returns a paginated list of pets") + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "listPets" } + val kdoc = funSpec.kdoc.toString() + assertTrue(kdoc.contains("List pets"), "Expected summary in KDoc, got: $kdoc") + assertTrue(kdoc.contains("Returns a paginated list of pets"), "Expected description in KDoc, got: $kdoc") + } + + @Test + fun `endpoint with parameter descriptions generates param KDoc`() { + val ep = endpoint( + parameters = listOf( + Parameter( + "limit", + ParameterLocation.QUERY, + false, + TypeRef.Primitive(PrimitiveType.INT), + "Max items to return", + ), + ), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "listPets" } + val kdoc = funSpec.kdoc.toString() + assertTrue(kdoc.contains("@param"), "Expected @param in KDoc, got: $kdoc") + assertTrue(kdoc.contains("Max items to return"), "Expected param description in KDoc, got: $kdoc") + } + + @Test + fun `endpoint with non-Unit return generates return KDoc`() { + val ep = endpoint( + responses = mapOf("200" to Response("200", "OK", TypeRef.Reference("Pet"))), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "listPets" } + val kdoc = funSpec.kdoc.toString() + assertTrue(kdoc.contains("@return"), "Expected @return in KDoc, got: $kdoc") + } + + @Test + fun `endpoint without descriptions generates no KDoc`() { + val ep = endpoint( + summary = null, + description = null, + parameters = emptyList(), + responses = mapOf("204" to Response("204", "No content", null)), + ) + val cls = clientClass(ep) + val funSpec = cls.funSpecs.first { it.name == "listPets" } + assertTrue( + funSpec.kdoc.toString().isEmpty(), + "Expected no KDoc when no descriptions, got: ${funSpec.kdoc}", + ) } } 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 new file mode 100644 index 0000000..abd834a --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/CodeGeneratorTest.kt @@ -0,0 +1,55 @@ +package com.avsystem.justworks.core.gen + +import com.avsystem.justworks.core.parser.ParseResult +import com.avsystem.justworks.core.parser.SpecParser +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.test.fail + +class CodeGeneratorTest { + companion object { + private val SPEC_FIXTURES = listOf( + "/fixtures/platform-api.json", + "/fixtures/analytics-api.json", + ) + } + + @Test + fun `generate produces model and client files for real-world specs`() { + for (fixture in SPEC_FIXTURES) { + val specUrl = javaClass.getResource(fixture) + ?: fail("Spec fixture not found: $fixture") + val specFile = File(specUrl.toURI()) + val spec = when (val result = SpecParser.parse(specFile)) { + is ParseResult.Success -> result.value + is ParseResult.Failure -> fail("Failed to parse $fixture: ${result.error}") + } + + val outputDir = Files.createTempDirectory("codegen-test").toFile() + try { + val result = CodeGenerator.generate( + spec = spec, + modelPackage = "com.example.model", + apiPackage = "com.example.api", + outputDir = outputDir, + ) + + assertTrue(result.modelFiles > 0, "$fixture: should produce model files") + if (spec.endpoints.isNotEmpty()) { + assertTrue(result.clientFiles > 0, "$fixture: should produce client files") + } + + val generatedFiles = outputDir.walkTopDown().filter { it.isFile }.toList() + assertTrue(generatedFiles.isNotEmpty(), "$fixture: output directory should contain files") + + val fileNames = generatedFiles.map { it.nameWithoutExtension } + val duplicates = fileNames.groupingBy { it }.eachCount().filter { it.value > 1 } + assertTrue(duplicates.isEmpty(), "$fixture: duplicate file names found: ${duplicates.keys}") + } finally { + outputDir.deleteRecursively() + } + } + } +} diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDedupTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDedupTest.kt index 385ad99..65c22e0 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDedupTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDedupTest.kt @@ -9,9 +9,7 @@ import kotlin.test.assertNotEquals class InlineSchemaDedupTest { @Test - fun `identical schemas return same name`() { - val deduplicator = InlineSchemaDeduplicator(emptySet()) - + fun `identical schemas return same name via InlineSchemaKey`() { val props1 = listOf( PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, false), PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), @@ -23,17 +21,14 @@ class InlineSchemaDedupTest { PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), ) - val name1 = deduplicator.getOrGenerateName(props1, required, "FirstContext") - val name2 = deduplicator.getOrGenerateName(props2, required, "SecondContext") + val key1 = InlineSchemaKey.from(props1, required) + val key2 = InlineSchemaKey.from(props2, required) - assertEquals("FirstContext", name1) - assertEquals("FirstContext", name2) // Same structure returns same name + assertEquals(key1, key2) } @Test - fun `different schemas return different names`() { - val deduplicator = InlineSchemaDeduplicator(emptySet()) - + fun `different schemas produce different keys`() { val props1 = listOf( PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, false), ) @@ -42,31 +37,25 @@ class InlineSchemaDedupTest { PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), ) - val name1 = deduplicator.getOrGenerateName(props1, setOf("id"), "FirstContext") - val name2 = deduplicator.getOrGenerateName(props2, setOf("name"), "SecondContext") + val key1 = InlineSchemaKey.from(props1, setOf("id")) + val key2 = InlineSchemaKey.from(props2, setOf("name")) - assertEquals("FirstContext", name1) - assertEquals("SecondContext", name2) - assertNotEquals(name1, name2) + assertNotEquals(key1, key2) } @Test - fun `name collision with component schema appends Inline suffix`() { - val deduplicator = InlineSchemaDeduplicator(componentSchemaNames = setOf("Pet")) + fun `name collision with component schema uses numeric suffix`() { + val registry = NameRegistry().apply { + reserve("Pet") + } - val props = listOf( - PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, false), - ) + val name = registry.register("Pet") - val name = deduplicator.getOrGenerateName(props, setOf("id"), "Pet") - - assertEquals("PetInline", name) // Collision with component schema + assertEquals("Pet2", name) } @Test fun `property order does not affect equality`() { - val deduplicator = InlineSchemaDeduplicator(emptySet()) - val props1 = listOf( PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, false), @@ -79,49 +68,91 @@ class InlineSchemaDedupTest { val required = setOf("id", "name") - val name1 = deduplicator.getOrGenerateName(props1, required, "FirstContext") - val name2 = deduplicator.getOrGenerateName(props2, required, "SecondContext") + val key1 = InlineSchemaKey.from(props1, required) + val key2 = InlineSchemaKey.from(props2, required) - // Same structure despite different order - assertEquals("FirstContext", name1) - assertEquals("FirstContext", name2) + assertEquals(key1, key2) } @Test fun `different required sets produce different keys`() { - val deduplicator = InlineSchemaDeduplicator(emptySet()) - val props = listOf( PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, false), PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, true), ) - val name1 = deduplicator.getOrGenerateName(props, setOf("id", "name"), "FirstContext") - val name2 = deduplicator.getOrGenerateName(props, setOf("id"), "SecondContext") + val key1 = InlineSchemaKey.from(props, setOf("id", "name")) + val key2 = InlineSchemaKey.from(props, setOf("id")) - // Different required sets mean different structures - assertEquals("FirstContext", name1) - assertEquals("SecondContext", name2) - assertNotEquals(name1, name2) + assertNotEquals(key1, key2) } @Test - fun `collision with existing inline schema name appends Inline suffix`() { - val deduplicator = InlineSchemaDeduplicator(emptySet()) + fun `nested inline schemas with different contextHints produce same key`() { + val nestedProps = listOf( + PropertyModel("street", TypeRef.Primitive(PrimitiveType.STRING), null, nullable = false), + ) + val props1 = listOf( + PropertyModel( + "address", + TypeRef.Inline(nestedProps, setOf("street"), "RequestAddress"), + null, + nullable = false, + ), + ) + val props2 = listOf( + PropertyModel( + "address", + TypeRef.Inline(nestedProps, setOf("street"), "ResponseAddress"), + null, + nullable = false, + ), + ) + val key1 = InlineSchemaKey.from(props1, setOf("address")) + val key2 = InlineSchemaKey.from(props2, setOf("address")) + + assertEquals(key1, key2) + } + + @Test + fun `different nullable flags produce different keys`() { val props1 = listOf( - PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, false), + PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, nullable = false), ) val props2 = listOf( - PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), + PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, nullable = true), + ) + + val key1 = InlineSchemaKey.from(props1, setOf("id")) + val key2 = InlineSchemaKey.from(props2, setOf("id")) + + assertNotEquals(key1, key2) + } + + @Test + fun `different defaultValues produce different keys`() { + val props1 = listOf( + PropertyModel("count", TypeRef.Primitive(PrimitiveType.INT), null, nullable = false, defaultValue = 0), + ) + val props2 = listOf( + PropertyModel("count", TypeRef.Primitive(PrimitiveType.INT), null, nullable = false, defaultValue = 10), ) - // First schema gets the base name - val name1 = deduplicator.getOrGenerateName(props1, setOf("id"), "Context") + val key1 = InlineSchemaKey.from(props1, setOf("count")) + val key2 = InlineSchemaKey.from(props2, setOf("count")) + + assertNotEquals(key1, key2) + } + + @Test + fun `collision with existing inline schema name uses numeric suffix`() { + val registry = NameRegistry() + + val name1 = registry.register("Context") assertEquals("Context", name1) - // Second schema (different structure) wants same name, gets Inline suffix - val name2 = deduplicator.getOrGenerateName(props2, setOf("name"), "Context") - assertEquals("ContextInline", name2) + val name2 = registry.register("Context") + assertEquals("Context2", name2) } } 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 new file mode 100644 index 0000000..7c72fd1 --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineTypeResolverTest.kt @@ -0,0 +1,174 @@ +package com.avsystem.justworks.core.gen + +import com.avsystem.justworks.core.model.ApiSpec +import com.avsystem.justworks.core.model.ContentType +import com.avsystem.justworks.core.model.Endpoint +import com.avsystem.justworks.core.model.HttpMethod +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.TypeRef +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class InlineTypeResolverTest { + private fun emptySpec() = ApiSpec( + title = "test", + version = "1.0", + schemas = emptyList(), + enums = emptyList(), + endpoints = emptyList(), + ) + + private fun inlineType(vararg propNames: String, contextHint: String = "Test") = TypeRef.Inline( + properties = propNames.map { PropertyModel(it, TypeRef.Primitive(PrimitiveType.STRING), null, false) }, + requiredProperties = propNames.toSet(), + contextHint = contextHint, + ) + + private fun nameMapFor(vararg types: TypeRef.Inline): Map = + types.associate { InlineSchemaKey.from(it.properties, it.requiredProperties) to "${it.contextHint}Resolved" } + + @Test + fun `resolveTypeRef replaces Inline with Reference`() { + val spec = emptySpec() + val inline = inlineType("id") + val nameMap = nameMapFor(inline) + + val resolved = spec.resolveTypeRef(inline, nameMap) + + assertEquals(TypeRef.Reference("TestResolved"), resolved) + } + + @Test + fun `resolveTypeRef passes through Primitive unchanged`() { + val spec = emptySpec() + val primitive = TypeRef.Primitive(PrimitiveType.INT) + + val resolved = spec.resolveTypeRef(primitive, emptyMap()) + + assertEquals(primitive, resolved) + } + + @Test + fun `resolveTypeRef passes through Reference unchanged`() { + val spec = emptySpec() + val ref = TypeRef.Reference("Foo") + + val resolved = spec.resolveTypeRef(ref, emptyMap()) + + assertEquals(ref, resolved) + } + + @Test + fun `resolveTypeRef resolves Inline inside Array`() { + val spec = emptySpec() + val inline = inlineType("name") + val nameMap = nameMapFor(inline) + + val resolved = spec.resolveTypeRef(TypeRef.Array(inline), nameMap) + + assertEquals(TypeRef.Array(TypeRef.Reference("TestResolved")), resolved) + } + + @Test + fun `resolveTypeRef resolves Inline inside Map`() { + val spec = emptySpec() + val inline = inlineType("name") + val nameMap = nameMapFor(inline) + + val resolved = spec.resolveTypeRef(TypeRef.Map(inline), nameMap) + + assertEquals(TypeRef.Map(TypeRef.Reference("TestResolved")), resolved) + } + + @Test + fun `resolveTypeRef fails fast on missing mapping`() { + val spec = emptySpec() + val inline = inlineType("unknown") + + val error = assertFailsWith { + spec.resolveTypeRef(inline, emptyMap()) + } + assertEquals(true, error.message?.contains("Missing inline schema mapping")) + } + + @Test + fun `resolveInlineTypes returns spec unchanged when nameMap is empty`() { + val spec = emptySpec() + + val resolved = spec.resolveInlineTypes(emptyMap()) + + assertEquals(spec, resolved) + } + + @Test + fun `resolveInlineTypes resolves inline types in endpoint responses`() { + val inline = inlineType("status") + val nameMap = nameMapFor(inline) + + val spec = ApiSpec( + title = "test", + version = "1.0", + schemas = emptyList(), + enums = emptyList(), + endpoints = listOf( + Endpoint( + path = "/test", + method = HttpMethod.GET, + operationId = "getTest", + summary = null, + tags = emptyList(), + parameters = emptyList(), + requestBody = null, + responses = mapOf("200" to Response("200", null, inline)), + description = null, + ), + ), + ) + + val resolved = spec.resolveInlineTypes(nameMap) + + val resolvedResponseType = resolved.endpoints + .first() + .responses["200"] + ?.schema + assertEquals(TypeRef.Reference("TestResolved"), resolvedResponseType) + } + + @Test + fun `resolveInlineTypes resolves inline types in request body`() { + val inline = inlineType("payload") + val nameMap = nameMapFor(inline) + + val spec = ApiSpec( + title = "test", + version = "1.0", + schemas = emptyList(), + enums = emptyList(), + endpoints = listOf( + Endpoint( + path = "/test", + method = HttpMethod.POST, + operationId = "postTest", + summary = null, + tags = emptyList(), + parameters = emptyList(), + requestBody = RequestBody(true, ContentType.JSON_CONTENT_TYPE, inline), + responses = emptyMap(), + description = null, + ), + ), + ) + + val resolved = spec.resolveInlineTypes(nameMap) + + val resolvedRequestType = resolved.endpoints + .first() + .requestBody + ?.schema + assertEquals(TypeRef.Reference("TestResolved"), resolvedRequestType) + } +} 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 7d7c7f5..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 @@ -1,7 +1,12 @@ package com.avsystem.justworks.core.gen +import com.avsystem.justworks.core.gen.client.ClientGenerator +import com.avsystem.justworks.core.gen.model.ModelGenerator +import com.avsystem.justworks.core.gen.shared.ApiClientBaseGenerator +import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.parser.ParseResult import com.avsystem.justworks.core.parser.SpecParser +import com.squareup.kotlinpoet.FileSpec import java.io.File import kotlin.test.Test import kotlin.test.assertFalse @@ -25,24 +30,34 @@ 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()) return when (val result = SpecParser.parse(specFile)) { is ParseResult.Success -> result - is ParseResult.Failure -> fail("Failed to parse $resourcePath: ${result.errors}") + is ParseResult.Failure -> fail("Failed to parse $resourcePath: ${result.error}") } } + private fun generateModel(spec: ApiSpec): List = + context(ModelPackage(modelPackage)) { ModelGenerator.generate(spec, NameRegistry()) } + + private fun generateModelWithResolvedSpec(spec: ApiSpec): ModelGenerator.GenerateResult = + context(ModelPackage(modelPackage)) { ModelGenerator.generateWithResolvedSpec(spec, NameRegistry()) } + + private fun generateClient(spec: ApiSpec, hasPolymorphicTypes: Boolean = false): List = + context(ModelPackage(modelPackage), ApiPackage(apiPackage)) { + ClientGenerator.generate(spec, hasPolymorphicTypes, NameRegistry()) + } + @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 generator = ModelGenerator(modelPackage) - val files = generator.generate(spec) + val files = generateModel(spec) assertTrue(files.isNotEmpty(), "$fixture: ModelGenerator should produce output files") val enumSources = files @@ -75,10 +90,10 @@ 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() + val apiClientBaseFile = ApiClientBaseGenerator.generate(spec.securitySchemes) assertNotNull(apiClientBaseFile, "$fixture: ApiClientBaseGenerator should produce output") val source = apiClientBaseFile.toString() @@ -96,23 +111,73 @@ 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 modelGenerator = ModelGenerator(modelPackage) - val modelFiles = modelGenerator.generate(spec) + val (modelFiles, resolvedSpec) = generateModelWithResolvedSpec(spec) assertTrue(modelFiles.isNotEmpty(), "$fixture: ModelGenerator should produce files") if (spec.endpoints.isNotEmpty()) { - val clientGenerator = ClientGenerator(apiPackage, modelPackage) - val clientFiles = clientGenerator.generate(spec) + val clientFiles = generateClient(resolvedSpec) assertTrue( clientFiles.isNotEmpty(), "$fixture: ClientGenerator should produce files for a spec with endpoints", ) } - val apiClientBaseFile = ApiClientBaseGenerator.generate() + val apiClientBaseFile = ApiClientBaseGenerator.generate(spec.securitySchemes) assertNotNull(apiClientBaseFile, "$fixture: ApiClientBaseGenerator should produce output") } } + + // -- Phase 2: Format type mapping validation -- + + @Test + fun `format mappings produce correct types in generated output`() { + for (fixture in SPEC_FIXTURES) { + val spec = parseSpec(fixture).value + + val files = generateModel(spec) + assertTrue(files.isNotEmpty(), "$fixture: ModelGenerator should produce output files") + + val allSources = files.map { it.toString() } + + val hasUuid = spec.schemas.any { schema -> + schema.properties.any { + it.type == com.avsystem.justworks.core.model.TypeRef.Primitive( + com.avsystem.justworks.core.model.PrimitiveType.UUID, + ) + } + } + if (hasUuid) { + assertTrue( + allSources.any { it.contains("kotlin.uuid.Uuid") }, + "$fixture: Expected kotlin.uuid.Uuid import when spec contains UUID properties", + ) + assertTrue( + allSources.any { it.contains("UuidSerializer") }, + "$fixture: Expected UuidSerializer when spec contains UUID properties", + ) + } + } + } + + @Test + fun `all generated files are syntactically valid Kotlin`() { + for (fixture in SPEC_FIXTURES) { + val spec = parseSpec(fixture).value + + val files = generateModel(spec) + assertTrue(files.isNotEmpty(), "$fixture: ModelGenerator should produce output files") + + for (file in files) { + val source = file.toString() + assertTrue(source.isNotBlank(), "$fixture: Generated file ${file.name} should not be blank") + + assertFalse( + source.contains("FIXME"), + "$fixture: Generated file ${file.name} should not contain FIXME markers", + ) + } + } + } } 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 30486e2..01632cb 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 @@ -1,5 +1,6 @@ package com.avsystem.justworks.core.gen +import com.avsystem.justworks.core.gen.model.ModelGenerator import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.Discriminator import com.avsystem.justworks.core.model.EnumModel @@ -16,14 +17,18 @@ import kotlin.test.assertTrue class ModelGeneratorPolymorphicTest { private val modelPackage = "com.example.model" - private val generator = ModelGenerator(modelPackage) - private fun spec(schemas: List = emptyList(), enums: List = emptyList(),) = ApiSpec( + private fun generate(spec: ApiSpec) = context(ModelPackage(modelPackage)) { + ModelGenerator.generate(spec, NameRegistry()) + } + + private fun spec(schemas: List = emptyList(), enums: List = emptyList()) = ApiSpec( title = "Test", version = "1.0", endpoints = emptyList(), schemas = schemas, enums = enums, + securitySchemes = emptyList(), ) private fun schema( @@ -45,7 +50,7 @@ class ModelGeneratorPolymorphicTest { discriminator = discriminator, ) - private fun findType(files: List, name: String,): TypeSpec { + private fun findType(files: List, name: String): TypeSpec { for (file in files) { val found = file.members.filterIsInstance().find { it.name == name } if (found != null) return found @@ -85,7 +90,7 @@ class ModelGeneratorPolymorphicTest { requiredProperties = setOf("sideLength"), ) - val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema, squareSchema))) + val files = generate(spec(schemas = listOf(shapeSchema, circleSchema, squareSchema))) val shapeType = findType(files, "Shape") assertTrue(KModifier.SEALED in shapeType.modifiers, "Expected SEALED modifier on Shape") @@ -102,7 +107,7 @@ class ModelGeneratorPolymorphicTest { val circleSchema = schema(name = "Circle") val squareSchema = schema(name = "Square") - val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema, squareSchema))) + val files = generate(spec(schemas = listOf(shapeSchema, circleSchema, squareSchema))) val shapeType = findType(files, "Shape") val annotations = shapeType.annotations.map { it.typeName.toString() } @@ -125,7 +130,7 @@ class ModelGeneratorPolymorphicTest { requiredProperties = setOf("radius"), ) - val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema))) + val files = generate(spec(schemas = listOf(shapeSchema, circleSchema))) val circleType = findType(files, "Circle") val superinterfaces = circleType.superinterfaces.keys.map { it.toString() } @@ -149,7 +154,7 @@ class ModelGeneratorPolymorphicTest { requiredProperties = setOf("radius"), ) - val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema))) + val files = generate(spec(schemas = listOf(shapeSchema, circleSchema))) val circleType = findType(files, "Circle") val serialNameAnnotation = @@ -183,7 +188,7 @@ class ModelGeneratorPolymorphicTest { val circleSchema = schema(name = "Circle") val squareSchema = schema(name = "Square") - val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema, squareSchema))) + val files = generate(spec(schemas = listOf(shapeSchema, circleSchema, squareSchema))) val shapeType = findType(files, "Shape") val discriminatorAnnotation = @@ -216,7 +221,7 @@ class ModelGeneratorPolymorphicTest { requiredProperties = setOf("radius"), ) - val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema))) + val files = generate(spec(schemas = listOf(shapeSchema, circleSchema))) val circleType = findType(files, "Circle") val serialNameAnnotation = @@ -244,7 +249,7 @@ class ModelGeneratorPolymorphicTest { ) val circleSchema = schema(name = "Circle") - val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema))) + val files = generate(spec(schemas = listOf(shapeSchema, circleSchema))) val shapeFile = findFile(files, "Shape") val optInAnnotation = @@ -287,7 +292,7 @@ class ModelGeneratorPolymorphicTest { allOf = listOf(TypeRef.Reference("Dog")), ) - val files = generator.generate(spec(schemas = listOf(dogSchema, extendedDogSchema))) + val files = generate(spec(schemas = listOf(dogSchema, extendedDogSchema))) val extendedDogType = findType(files, "ExtendedDog") val constructor = assertNotNull(extendedDogType.primaryConstructor, "Expected primary constructor") @@ -323,7 +328,7 @@ class ModelGeneratorPolymorphicTest { allOf = listOf(TypeRef.Reference("Dog")), ) - val files = generator.generate(spec(schemas = listOf(dogSchema, extendedDogSchema))) + val files = generate(spec(schemas = listOf(dogSchema, extendedDogSchema))) val extendedDogType = findType(files, "ExtendedDog") val constructor = assertNotNull(extendedDogType.primaryConstructor) @@ -374,7 +379,7 @@ class ModelGeneratorPolymorphicTest { ), ) - val files = generator.generate( + val files = generate( spec(schemas = listOf(networkMeshSchema, extenderPropsSchema, ethernetPropsSchema)), ) val networkMeshType = findType(files, "NetworkMeshDevice") @@ -410,7 +415,7 @@ class ModelGeneratorPolymorphicTest { ), ) - val files = generator.generate(spec(schemas = listOf(networkMeshSchema, extenderPropsSchema))) + val files = generate(spec(schemas = listOf(networkMeshSchema, extenderPropsSchema))) val extenderType = findType(files, "ExtenderDeviceProperties") // Verify @SerialName uses wrapper property name @@ -444,7 +449,7 @@ class ModelGeneratorPolymorphicTest { requiredProperties = setOf("accountNumber"), ) - val files = generator.generate(spec(schemas = listOf(unionSchema, creditCardSchema, bankTransferSchema))) + val files = generate(spec(schemas = listOf(unionSchema, creditCardSchema, bankTransferSchema))) val paymentType = findType(files, "Payment") val serializableAnnotation = paymentType.annotations.find { @@ -474,7 +479,7 @@ class ModelGeneratorPolymorphicTest { requiredProperties = setOf("accountNumber"), ) - val files = generator.generate(spec(schemas = listOf(unionSchema, creditCardSchema, bankTransferSchema))) + val files = generate(spec(schemas = listOf(unionSchema, creditCardSchema, bankTransferSchema))) val serializerType = findType(files, "PaymentSerializer") assertEquals(TypeSpec.Kind.OBJECT, serializerType.kind, "PaymentSerializer should be an object") @@ -502,7 +507,7 @@ class ModelGeneratorPolymorphicTest { requiredProperties = setOf("accountNumber"), ) - val files = generator.generate(spec(schemas = listOf(unionSchema, creditCardSchema, bankTransferSchema))) + val files = generate(spec(schemas = listOf(unionSchema, creditCardSchema, bankTransferSchema))) val serializerType = findType(files, "PaymentSerializer") val selectDeserializer = serializerType.funSpecs.find { it.name == "selectDeserializer" } assertNotNull(selectDeserializer, "PaymentSerializer should have selectDeserializer function") @@ -536,7 +541,7 @@ class ModelGeneratorPolymorphicTest { requiredProperties = setOf("amount"), ) - val files = generator.generate(spec(schemas = listOf(unionSchema, typeASchema, typeBSchema))) + val files = generate(spec(schemas = listOf(unionSchema, typeASchema, typeBSchema))) val serializerType = findType(files, "PaymentSerializer") val selectDeserializer = serializerType.funSpecs.find { it.name == "selectDeserializer" } assertNotNull(selectDeserializer, "PaymentSerializer should have selectDeserializer function") @@ -565,7 +570,7 @@ class ModelGeneratorPolymorphicTest { requiredProperties = setOf("accountNumber"), ) - val files = generator.generate(spec(schemas = listOf(unionSchema, creditCardSchema, bankTransferSchema))) + val files = generate(spec(schemas = listOf(unionSchema, creditCardSchema, bankTransferSchema))) val creditCardType = findType(files, "CreditCard") val annotations = creditCardType.annotations.map { it.typeName.toString() } @@ -589,7 +594,7 @@ class ModelGeneratorPolymorphicTest { val circleSchema = schema(name = "Circle") val squareSchema = schema(name = "Square") - val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema, squareSchema))) + val files = generate(spec(schemas = listOf(shapeSchema, circleSchema, squareSchema))) val shapeType = findType(files, "Shape") // Should have plain @Serializable, NOT @Serializable(with = ...) @@ -612,6 +617,170 @@ class ModelGeneratorPolymorphicTest { ) } + // -- CEM-01: boolean discriminator names (KotlinPoet handles escaping) -- + + @Test + fun `boolean discriminator names produce valid data classes`() { + val deviceStatusSchema = schema( + name = "DeviceStatus", + oneOf = listOf( + TypeRef.Reference("true"), + TypeRef.Reference("false"), + ), + discriminator = Discriminator( + propertyName = "online", + mapping = mapOf( + "true" to "#/components/schemas/true", + "false" to "#/components/schemas/false", + ), + ), + ) + val trueSchema = schema( + name = "true", + properties = listOf( + PropertyModel("connectedSince", TypeRef.Primitive(PrimitiveType.STRING), null, false), + ), + requiredProperties = setOf("connectedSince"), + ) + val falseSchema = schema( + name = "false", + properties = listOf( + PropertyModel("lastSeen", TypeRef.Primitive(PrimitiveType.STRING), null, false), + ), + requiredProperties = setOf("lastSeen"), + ) + + val files = generate( + spec(schemas = listOf(deviceStatusSchema, trueSchema, falseSchema)), + ) + + val trueType = findType(files, "true") + assertTrue(KModifier.DATA in trueType.modifiers, "'true' should be data class") + + val falseType = findType(files, "false") + assertTrue(KModifier.DATA in falseType.modifiers, "'false' should be data class") + + // Both implement DeviceStatus sealed interface + val trueSuperinterfaces = trueType.superinterfaces.keys.map { it.toString() } + assertTrue( + "$modelPackage.DeviceStatus" in trueSuperinterfaces, + "'true' should implement DeviceStatus. Superinterfaces: $trueSuperinterfaces", + ) + val falseSuperinterfaces = falseType.superinterfaces.keys.map { it.toString() } + assertTrue( + "$modelPackage.DeviceStatus" in falseSuperinterfaces, + "'false' should implement DeviceStatus. Superinterfaces: $falseSuperinterfaces", + ) + } + + @Test + fun `all oneOf variant schemas generate data classes even with many subtypes`() { + val variantNames = listOf( + "ExtenderDevice", + "EthernetDevice", + "WanDevice", + "USBDevice", + "WiFiDevice", + "OtherDevice", + ) + + val networkMeshSchema = schema( + name = "NetworkMeshDevice", + oneOf = variantNames.map { TypeRef.Reference(it) }, + discriminator = Discriminator( + propertyName = "deviceType", + mapping = variantNames.associateWith { "#/components/schemas/$it" }, + ), + ) + + val variantSchemas = variantNames.map { name -> + schema( + name = name, + properties = listOf( + PropertyModel("deviceId", TypeRef.Primitive(PrimitiveType.STRING), null, false), + ), + requiredProperties = setOf("deviceId"), + ) + } + + val files = generate( + spec(schemas = listOf(networkMeshSchema) + variantSchemas), + ) + + // All 6 variants generated + for (name in variantNames) { + val variantType = findType(files, name) + assertTrue( + KModifier.DATA in variantType.modifiers, + "$name should be a data class", + ) + val superinterfaces = variantType.superinterfaces.keys.map { it.toString() } + assertTrue( + "$modelPackage.NetworkMeshDevice" in superinterfaces, + "$name should implement NetworkMeshDevice. Superinterfaces: $superinterfaces", + ) + } + + // SerializersModule contains all variants + val serializersModuleFile = files.find { it.name == "SerializersModule" } + assertNotNull(serializersModuleFile, "SerializersModule file should be generated") + val moduleCode = serializersModuleFile.toString() + for (name in variantNames) { + assertTrue( + name in moduleCode, + "SerializersModule should reference $name. Code: $moduleCode", + ) + } + } + + @Test + fun `SerializersModule includes boolean variant names`() { + val deviceStatusSchema = schema( + name = "DeviceStatus", + oneOf = listOf( + TypeRef.Reference("true"), + TypeRef.Reference("false"), + ), + discriminator = Discriminator( + propertyName = "online", + mapping = mapOf( + "true" to "#/components/schemas/true", + "false" to "#/components/schemas/false", + ), + ), + ) + val trueSchema = schema( + name = "true", + properties = listOf( + PropertyModel("connectedSince", TypeRef.Primitive(PrimitiveType.STRING), null, false), + ), + requiredProperties = setOf("connectedSince"), + ) + val falseSchema = schema( + name = "false", + properties = listOf( + PropertyModel("lastSeen", TypeRef.Primitive(PrimitiveType.STRING), null, false), + ), + requiredProperties = setOf("lastSeen"), + ) + + val files = generate( + spec(schemas = listOf(deviceStatusSchema, trueSchema, falseSchema)), + ) + + val serializersModuleFile = files.find { it.name == "SerializersModule" } + assertNotNull(serializersModuleFile, "SerializersModule file should be generated") + val moduleCode = serializersModuleFile.toString() + assertTrue( + "`true`" in moduleCode, + "SerializersModule should reference `true`. Code: $moduleCode", + ) + assertTrue( + "`false`" in moduleCode, + "SerializersModule should reference `false`. Code: $moduleCode", + ) + } + // -- POLY-06: allOf with sealed parent -- @Test @@ -632,7 +801,7 @@ class ModelGeneratorPolymorphicTest { allOf = listOf(TypeRef.Reference("Pet")), ) - val files = generator.generate(spec(schemas = listOf(petSchema, dogSchema))) + val files = generate(spec(schemas = listOf(petSchema, dogSchema))) val dogType = findType(files, "Dog") val superinterfaces = dogType.superinterfaces.keys.map { it.toString() } 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 061e371..fa77d04 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 @@ -1,13 +1,19 @@ package com.avsystem.justworks.core.gen +import com.avsystem.justworks.core.gen.model.ModelGenerator import com.avsystem.justworks.core.model.ApiSpec +import com.avsystem.justworks.core.model.Endpoint import com.avsystem.justworks.core.model.EnumBackingType import com.avsystem.justworks.core.model.EnumModel +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.PrimitiveType import com.avsystem.justworks.core.model.PropertyModel import com.avsystem.justworks.core.model.SchemaModel import com.avsystem.justworks.core.model.TypeRef import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.TypeSpec import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -16,7 +22,10 @@ import kotlin.test.assertTrue class ModelGeneratorTest { private val modelPackage = "com.example.model" - private val generator = ModelGenerator(modelPackage) + + private fun generate(spec: ApiSpec) = context(ModelPackage(modelPackage)) { + ModelGenerator.generate(spec, NameRegistry()) + } private fun spec(schemas: List = emptyList(), enums: List = emptyList()) = ApiSpec( title = "Test", @@ -24,6 +33,7 @@ class ModelGeneratorTest { endpoints = emptyList(), schemas = schemas, enums = enums, + securitySchemes = emptyList(), ) private val petSchema = @@ -47,39 +57,24 @@ class ModelGeneratorTest { @Test fun `generates data class with DATA modifier`() { - val files = generator.generate(spec(schemas = listOf(petSchema))) + val files = generate(spec(schemas = listOf(petSchema))) assertEquals(1, files.size) - val typeSpec = - files - .first() - .members - .filterIsInstance() - .first() + val typeSpec = files[0].members.filterIsInstance()[0] assertTrue(KModifier.DATA in typeSpec.modifiers, "Expected DATA modifier") } @Test fun `generates class with Serializable annotation`() { - val files = generator.generate(spec(schemas = listOf(petSchema))) - val typeSpec = - files - .first() - .members - .filterIsInstance() - .first() + val files = generate(spec(schemas = listOf(petSchema))) + val typeSpec = files[0].members.filterIsInstance()[0] val annotations = typeSpec.annotations.map { it.typeName.toString() } assertTrue("kotlinx.serialization.Serializable" in annotations, "Expected @Serializable") } @Test fun `required property is non-nullable in constructor`() { - val files = generator.generate(spec(schemas = listOf(petSchema))) - val typeSpec = - files - .first() - .members - .filterIsInstance() - .first() + val files = generate(spec(schemas = listOf(petSchema))) + val typeSpec = files[0].members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor, "Expected primary constructor") val idParam = constructor.parameters.first { it.name == "id" } assertTrue(!idParam.type.isNullable, "Required property 'id' should be non-nullable") @@ -87,13 +82,8 @@ class ModelGeneratorTest { @Test fun `optional property is nullable with default null`() { - val files = generator.generate(spec(schemas = listOf(petSchema))) - val typeSpec = - files - .first() - .members - .filterIsInstance() - .first() + val files = generate(spec(schemas = listOf(petSchema))) + val typeSpec = files[0].members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) val tagParam = constructor.parameters.first { it.name == "tag" } assertTrue(tagParam.type.isNullable, "Optional property 'tag' should be nullable") @@ -103,13 +93,8 @@ class ModelGeneratorTest { @Test fun `every property has SerialName annotation with wire name`() { - val files = generator.generate(spec(schemas = listOf(petSchema))) - val typeSpec = - files - .first() - .members - .filterIsInstance() - .first() + val files = generate(spec(schemas = listOf(petSchema))) + val typeSpec = files[0].members.filterIsInstance()[0] for (prop in typeSpec.propertySpecs) { val serialName = prop.annotations.firstOrNull { @@ -135,14 +120,9 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) - val typeSpec = - files - .first() - .members - .filterIsInstance() - .first() - val prop = typeSpec.propertySpecs.first() + val files = generate(spec(schemas = listOf(schema))) + val typeSpec = files[0].members.filterIsInstance()[0] + val prop = typeSpec.propertySpecs[0] assertEquals("createdAt", prop.name) val serialNameAnnotation = prop.annotations.first { @@ -156,13 +136,8 @@ class ModelGeneratorTest { @Test fun `schema with description produces KDoc`() { - val files = generator.generate(spec(schemas = listOf(petSchema))) - val typeSpec = - files - .first() - .members - .filterIsInstance() - .first() + val files = generate(spec(schemas = listOf(petSchema))) + val typeSpec = files[0].members.filterIsInstance()[0] assertTrue(typeSpec.kdoc.toString().contains("A pet in the store"), "Expected KDoc from description") } @@ -182,13 +157,8 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) - val typeSpec = - files - .first() - .members - .filterIsInstance() - .first() + val files = generate(spec(schemas = listOf(schema))) + val typeSpec = files[0].members.filterIsInstance()[0] val petProp = typeSpec.propertySpecs.first { it.name == "pet" } assertEquals("com.example.model.Pet", petProp.type.toString()) } @@ -196,19 +166,14 @@ class ModelGeneratorTest { @Test fun `generate produces one FileSpec per schema`() { val schema2 = petSchema.copy(name = "Category", description = null) - val files = generator.generate(spec(schemas = listOf(petSchema, schema2))) + val files = generate(spec(schemas = listOf(petSchema, schema2))) assertEquals(2, files.size) } @Test fun `required properties ordered before optional in constructor`() { - val files = generator.generate(spec(schemas = listOf(petSchema))) - val typeSpec = - files - .first() - .members - .filterIsInstance() - .first() + val files = generate(spec(schemas = listOf(petSchema))) + val typeSpec = files[0].members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) val paramNames = constructor.parameters.map { it.name } // id and name are required, tag is optional -> tag should be last @@ -222,31 +187,21 @@ class ModelGeneratorTest { name = "PetStatus", description = null, type = EnumBackingType.STRING, - values = listOf("available", "pending", "sold"), + values = listOf(EnumModel.Value("available"), EnumModel.Value("pending"), EnumModel.Value("sold")), ) @Test fun `string enum has Serializable annotation`() { - val files = generator.generate(spec(enums = listOf(statusEnum))) - val typeSpec = - files - .first() - .members - .filterIsInstance() - .first() + val files = generate(spec(enums = listOf(statusEnum))) + val typeSpec = files[0].members.filterIsInstance()[0] val annotations = typeSpec.annotations.map { it.typeName.toString() } assertTrue("kotlinx.serialization.Serializable" in annotations, "Expected @Serializable on enum") } @Test fun `string enum constants have SerialName with wire value`() { - val files = generator.generate(spec(enums = listOf(statusEnum))) - val typeSpec = - files - .first() - .members - .filterIsInstance() - .first() + val files = generate(spec(enums = listOf(statusEnum))) + val typeSpec = files[0].members.filterIsInstance()[0] assertEquals(3, typeSpec.enumConstants.size) for ((name, spec) in typeSpec.enumConstants) { val serialName = @@ -259,21 +214,16 @@ class ModelGeneratorTest { @Test fun `enum constant names are UPPER_SNAKE_CASE`() { - val files = generator.generate(spec(enums = listOf(statusEnum))) - val typeSpec = - files - .first() - .members - .filterIsInstance() - .first() + val files = generate(spec(enums = listOf(statusEnum))) + val typeSpec = files[0].members.filterIsInstance()[0] val names = typeSpec.enumConstants.keys.toList() assertEquals(listOf("AVAILABLE", "PENDING", "SOLD"), names) } @Test fun `enum constants do not produce anonymous class body`() { - val files = generator.generate(spec(enums = listOf(statusEnum))) - val source = files.first().toString() + val files = generate(spec(enums = listOf(statusEnum))) + val source = files[0].toString() // Assert no class body braces on enum constants assertFalse( source.contains(Regex("""[A-Z_]+\(\) \{""")), @@ -298,15 +248,10 @@ class ModelGeneratorTest { name = "Priority", description = null, type = EnumBackingType.INTEGER, - values = listOf("1", "2", "3"), + values = listOf(EnumModel.Value("1"), EnumModel.Value("2"), EnumModel.Value("3")), ) - val files = generator.generate(spec(enums = listOf(intEnum))) - val typeSpec = - files - .first() - .members - .filterIsInstance() - .first() + val files = generate(spec(enums = listOf(intEnum))) + val typeSpec = files[0].members.filterIsInstance()[0] val constants = typeSpec.enumConstants.entries.toList() assertEquals("1", constants[0].key) assertEquals("2", constants[1].key) @@ -329,13 +274,13 @@ class ModelGeneratorTest { @Test fun `generate returns FileSpecs for schemas and enums combined`() { val schema2 = petSchema.copy(name = "Category", description = null) - val files = generator.generate(spec(schemas = listOf(petSchema, schema2), enums = listOf(statusEnum))) + val files = generate(spec(schemas = listOf(petSchema, schema2), enums = listOf(statusEnum))) assertEquals(3, files.size) } @Test fun `all FileSpecs have correct package name`() { - val files = generator.generate(spec(schemas = listOf(petSchema), enums = listOf(statusEnum))) + val files = generate(spec(schemas = listOf(petSchema), enums = listOf(statusEnum))) for (file in files) { assertEquals(modelPackage, file.packageName) } @@ -368,16 +313,11 @@ class ModelGeneratorTest { discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(paymentSchema, creditCardSchema))) - val paymentType = - files - .first { it.name == "Payment" } - .members - .filterIsInstance() - .first() + val files = generate(spec(schemas = listOf(paymentSchema, creditCardSchema))) + val paymentType = files.first { it.name == "Payment" }.members.filterIsInstance()[0] assertTrue(KModifier.SEALED in paymentType.modifiers, "Expected SEALED modifier on Payment") - assertEquals(com.squareup.kotlinpoet.TypeSpec.Kind.INTERFACE, paymentType.kind, "Expected INTERFACE kind") + assertEquals(TypeSpec.Kind.INTERFACE, paymentType.kind, "Expected INTERFACE kind") } @Test @@ -405,13 +345,8 @@ class ModelGeneratorTest { discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(paymentSchema, creditCardSchema))) - val creditCardType = - files - .first { it.name == "CreditCard" } - .members - .filterIsInstance() - .first() + val files = generate(spec(schemas = listOf(paymentSchema, creditCardSchema))) + val creditCardType = files.first { it.name == "CreditCard" }.members.filterIsInstance()[0] val serialNameAnnotation = creditCardType.annotations.find { @@ -449,13 +384,8 @@ class ModelGeneratorTest { discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(paymentSchema, creditCardSchema))) - val paymentType = - files - .first { it.name == "Payment" } - .members - .filterIsInstance() - .first() + val files = generate(spec(schemas = listOf(paymentSchema, creditCardSchema))) + val paymentType = files.first { it.name == "Payment" }.members.filterIsInstance()[0] val discriminatorAnnotation = paymentType.annotations.find { @@ -486,13 +416,8 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) - val typeSpec = - files - .first() - .members - .filterIsInstance() - .first() + val files = generate(spec(schemas = listOf(schema))) + val typeSpec = files[0].members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) val param = constructor.parameters.first { it.name == "name" } assertEquals("\"default-name\"", param.defaultValue.toString()) @@ -515,13 +440,8 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) - val typeSpec = - files - .first() - .members - .filterIsInstance() - .first() + val files = generate(spec(schemas = listOf(schema))) + val typeSpec = files[0].members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) val ageParam = constructor.parameters.first { it.name == "age" } assertEquals("42", ageParam.defaultValue.toString()) @@ -537,7 +457,13 @@ class ModelGeneratorTest { description = null, properties = listOf( - PropertyModel("active", TypeRef.Primitive(PrimitiveType.BOOLEAN), null, false, true), + PropertyModel( + "active", + TypeRef.Primitive(PrimitiveType.BOOLEAN), + null, + nullable = false, + defaultValue = true, + ), ), requiredProperties = setOf("active"), allOf = null, @@ -545,13 +471,8 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) - val typeSpec = - files - .first() - .members - .filterIsInstance() - .first() + val files = generate(spec(schemas = listOf(schema))) + val typeSpec = files[0].members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) val param = constructor.parameters.first { it.name == "active" } assertEquals("true", param.defaultValue.toString()) @@ -579,13 +500,8 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) - val typeSpec = - files - .first() - .members - .filterIsInstance() - .first() + val files = generate(spec(schemas = listOf(schema))) + val typeSpec = files[0].members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) val param = constructor.parameters.first { it.name == "createdAt" } assertTrue( @@ -614,13 +530,8 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) - val typeSpec = - files - .first() - .members - .filterIsInstance() - .first() + val files = generate(spec(schemas = listOf(schema))) + val typeSpec = files[0].members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) val param = constructor.parameters.first { it.name == "eventDate" } assertTrue( @@ -648,13 +559,8 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) - val typeSpec = - files - .first() - .members - .filterIsInstance() - .first() + val files = generate(spec(schemas = listOf(schema))) + val typeSpec = files[0].members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) val paramNames = constructor.parameters.map { it.name } // Expected order: required (no default), withDefault (has default), optional (nullable) @@ -677,13 +583,8 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) - val typeSpec = - files - .first() - .members - .filterIsInstance() - .first() + val files = generate(spec(schemas = listOf(schema))) + val typeSpec = files[0].members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) val param = constructor.parameters.first { it.name == "name" } assertTrue(param.type.isNullable, "Property should be nullable") @@ -698,7 +599,7 @@ class ModelGeneratorTest { name = "Status", description = null, type = EnumBackingType.STRING, - values = listOf("active", "pending", "closed"), + values = listOf(EnumModel.Value("active"), EnumModel.Value("pending"), EnumModel.Value("closed")), ) val schema = SchemaModel( @@ -714,13 +615,8 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema), enums = listOf(statusEnum))) - val typeSpec = - files - .first { it.name == "Task" } - .members - .filterIsInstance() - .first() + val files = generate(spec(schemas = listOf(schema), enums = listOf(statusEnum))) + val typeSpec = files.first { it.name == "Task" }.members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) val param = constructor.parameters.first { it.name == "status" } assertTrue( @@ -732,7 +628,7 @@ class ModelGeneratorTest { // -- Primitive-only type alias tests -- @Test - fun `primitive only schema generates type alias`() { + fun `primitive only schema generates type alias to String by default`() { val groupIdSchema = SchemaModel( name = "GroupId", description = null, @@ -742,39 +638,169 @@ class ModelGeneratorTest { oneOf = null, anyOf = null, discriminator = null, + underlyingType = null, ) - val files = generator.generate(spec(schemas = listOf(groupIdSchema))) + val files = generate(spec(schemas = listOf(groupIdSchema))) assertEquals(1, files.size) - val file = files.first() + val file = files[0] assertEquals("GroupId", file.name) // Verify it contains a TypeAliasSpec, not a TypeSpec val typeAliases = file.members.filterIsInstance() assertEquals(1, typeAliases.size, "Expected one type alias") - val typeAlias = typeAliases.first() + val typeAlias = typeAliases[0] assertEquals("GroupId", typeAlias.name) + assertEquals("kotlin.String", typeAlias.type.toString(), "Default typealias should be String") } @Test - fun `primitive only schema with description generates type alias with kdoc`() { - val userIdSchema = SchemaModel( - name = "UserId", - description = "Unique identifier for a user", + fun `primitive only schema with integer underlyingType generates typealias to Int`() { + val schema = SchemaModel( + name = "IntId", + description = null, properties = emptyList(), requiredProperties = emptySet(), allOf = null, oneOf = null, anyOf = null, discriminator = null, + underlyingType = TypeRef.Primitive(PrimitiveType.INT), ) - val files = generator.generate(spec(schemas = listOf(userIdSchema))) + val files = generate(spec(schemas = listOf(schema))) val typeAlias = files .first() .members .filterIsInstance() .first() + assertEquals("kotlin.Int", typeAlias.type.toString()) + } + + @Test + fun `primitive only schema with boolean underlyingType generates typealias to Boolean`() { + val schema = SchemaModel( + name = "Flag", + description = null, + properties = emptyList(), + requiredProperties = emptySet(), + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + underlyingType = TypeRef.Primitive(PrimitiveType.BOOLEAN), + ) + val files = generate(spec(schemas = listOf(schema))) + val typeAlias = files + .first() + .members + .filterIsInstance() + .first() + assertEquals("kotlin.Boolean", typeAlias.type.toString()) + } + + @Test + fun `primitive only schema with long underlyingType generates typealias to Long`() { + val schema = SchemaModel( + name = "BigId", + description = null, + properties = emptyList(), + requiredProperties = emptySet(), + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + underlyingType = TypeRef.Primitive(PrimitiveType.LONG), + ) + val files = generate(spec(schemas = listOf(schema))) + val typeAlias = files + .first() + .members + .filterIsInstance() + .first() + assertEquals("kotlin.Long", typeAlias.type.toString()) + } + + @Test + fun `primitive only schema with double underlyingType generates typealias to Double`() { + val schema = SchemaModel( + name = "Score", + description = null, + properties = emptyList(), + requiredProperties = emptySet(), + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + underlyingType = TypeRef.Primitive(PrimitiveType.DOUBLE), + ) + val files = generate(spec(schemas = listOf(schema))) + val typeAlias = files + .first() + .members + .filterIsInstance() + .first() + assertEquals("kotlin.Double", typeAlias.type.toString()) + } + + @Test + fun `primitive only schema with array underlyingType generates typealias to List`() { + val schema = SchemaModel( + name = "Tags", + description = null, + properties = emptyList(), + requiredProperties = emptySet(), + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + underlyingType = TypeRef.Array(TypeRef.Primitive(PrimitiveType.STRING)), + ) + val files = generate(spec(schemas = listOf(schema))) + val typeAlias = files + .first() + .members + .filterIsInstance() + .first() + assertEquals("kotlin.collections.List", typeAlias.type.toString()) + } + + @Test + fun `primitive only schema with reference underlyingType generates typealias to referenced type`() { + val schema = SchemaModel( + name = "Wrapper", + description = null, + properties = emptyList(), + requiredProperties = emptySet(), + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + underlyingType = TypeRef.Reference("OtherSchema"), + ) + val files = generate(spec(schemas = listOf(schema))) + val typeAlias = files + .first() + .members + .filterIsInstance() + .first() + assertEquals("com.example.model.OtherSchema", typeAlias.type.toString()) + } + + @Test + fun `primitive only schema with description generates type alias with kdoc`() { + val userIdSchema = SchemaModel( + name = "UserId", + description = "Unique identifier for a user", + properties = emptyList(), + requiredProperties = emptySet(), + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generate(spec(schemas = listOf(userIdSchema))) + val typeAlias = files[0].members.filterIsInstance()[0] assertTrue( typeAlias.kdoc.toString().contains("Unique identifier for a user"), @@ -785,19 +811,52 @@ class ModelGeneratorTest { @Test fun `schema with properties generates data class not type alias`() { // Verify existing behavior unchanged - schemas with properties still get data classes - val files = generator.generate(spec(schemas = listOf(petSchema))) + val files = generate(spec(schemas = listOf(petSchema))) - val typeSpecs = files.first().members.filterIsInstance() - val typeAliases = files.first().members.filterIsInstance() + val typeSpecs = files[0].members.filterIsInstance() + val typeAliases = files[0].members.filterIsInstance() assertEquals(1, typeSpecs.size, "Schema with properties should generate TypeSpec") assertEquals(0, typeAliases.size, "Schema with properties should not generate TypeAliasSpec") } - // -- SER-03: Kotlin keyword escaping -- + // -- SCHM-06: Reserved word and name conflict escaping -- + // + // These tests verify that: + // 1. Hard Kotlin keywords (class, object, val) are auto-escaped by KotlinPoet with backticks + // 2. Non-keyword names that could conflict (values, size, entries, keys) are safe on data classes + // 3. @SerialName always preserves the original wire name regardless of escaping + // + + @Test + fun `property named 'class' generates backtick-escaped name with SerialName`() { + // KotlinPoet auto-escapes hard keywords — 'class' should become `class` in a generated source + val schema = SchemaModel( + name = "Reserved", + description = null, + properties = listOf( + PropertyModel("class", TypeRef.Primitive(PrimitiveType.STRING), null, false), + ), + requiredProperties = setOf("class"), + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generate(spec(schemas = listOf(schema))) + val source = files[0].toString() + + // Hard keyword: KotlinPoet should backtick-escape + assertTrue(source.contains("`class`"), "Expected backtick-escaped 'class' property in:\n${source.take(500)}") + assertTrue( + source.contains("@SerialName(\"class\")"), + "Expected @SerialName with original wire name 'class'", + ) + } @Test - fun `property named with Kotlin keyword generates backtick-escaped name with correct SerialName`() { + fun `property named 'object' generates backtick-escaped name with SerialName`() { + // KotlinPoet auto-escapes hard keywords — 'object' should become `object` in a generated source val schema = SchemaModel( name = "Item", description = null, @@ -810,21 +869,137 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) - val typeSpec = files - .first() - .members - .filterIsInstance() - .first() + val files = generate(spec(schemas = listOf(schema))) + val source = files[0].toString() + + // Hard keyword: KotlinPoet should backtick-escape + assertTrue(source.contains("`object`"), "Expected backtick-escaped 'object' property in:\n${source.take(500)}") + assertTrue( + source.contains("@SerialName(\"object\")"), + "Expected @SerialName with original wire name 'object'", + ) + } - val prop = typeSpec.propertySpecs.first() + @Test + fun `property named 'val' generates backtick-escaped name with SerialName`() { + // KotlinPoet auto-escapes hard keywords — 'val' should become `val` in generated source + val schema = SchemaModel( + name = "Reserved", + description = null, + properties = listOf( + PropertyModel("val", TypeRef.Primitive(PrimitiveType.STRING), null, false), + ), + requiredProperties = setOf("val"), + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generate(spec(schemas = listOf(schema))) + val source = files[0].toString() - // @SerialName should still use the original wire name - val serialName = prop.annotations.first { it.typeName.toString() == "kotlinx.serialization.SerialName" } + // Hard keyword: KotlinPoet should backtick-escape + assertTrue(source.contains("`val`"), "Expected backtick-escaped 'val' property in:\n${source.take(500)}") assertTrue( - serialName.members.any { it.toString().contains("\"object\"") }, - "Expected @SerialName(\"object\") for wire name", + source.contains("@SerialName(\"val\")"), + "Expected @SerialName with original wire name 'val'", + ) + } + + @Test + fun `property named 'values' on data class does not conflict`() { + // Data classes do not inherit Map/Collection members, so 'values' is safe without escaping + val schema = SchemaModel( + name = "Container", + description = null, + properties = listOf( + PropertyModel("values", TypeRef.Array(TypeRef.Primitive(PrimitiveType.STRING)), null, false), + ), + requiredProperties = setOf("values"), + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generate(spec(schemas = listOf(schema))) + val typeSpec = files[0].members.filterIsInstance()[0] + val prop = typeSpec.propertySpecs[0] + + assertEquals("values", prop.name) // No escaping needed for non-keyword + val serialName = prop.annotations.first { it.typeName.toString().contains("SerialName") } + assertTrue(serialName.members.any { it.toString().contains("\"values\"") }) + } + + @Test + fun `property named 'size' on data class does not conflict`() { + // Data classes do not inherit Map/Collection members, so 'size' is safe without escaping + val schema = SchemaModel( + name = "Container", + description = null, + properties = listOf( + PropertyModel("size", TypeRef.Primitive(PrimitiveType.INT), null, false), + ), + requiredProperties = setOf("size"), + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generate(spec(schemas = listOf(schema))) + val typeSpec = files[0].members.filterIsInstance()[0] + val prop = typeSpec.propertySpecs[0] + + assertEquals("size", prop.name) // No escaping needed for non-keyword + val serialName = prop.annotations.first { it.typeName.toString().contains("SerialName") } + assertTrue(serialName.members.any { it.toString().contains("\"size\"") }) + } + + @Test + fun `property named 'entries' on data class does not conflict`() { + // Data classes do not inherit Map/Collection members, so 'entries' is safe without escaping + val schema = SchemaModel( + name = "Container", + description = null, + properties = listOf( + PropertyModel("entries", TypeRef.Array(TypeRef.Primitive(PrimitiveType.STRING)), null, false), + ), + requiredProperties = setOf("entries"), + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generate(spec(schemas = listOf(schema))) + val typeSpec = files[0].members.filterIsInstance()[0] + val prop = typeSpec.propertySpecs[0] + + assertEquals("entries", prop.name) // No escaping needed for non-keyword + val serialName = prop.annotations.first { it.typeName.toString().contains("SerialName") } + assertTrue(serialName.members.any { it.toString().contains("\"entries\"") }) + } + + @Test + fun `property named 'keys' on data class does not conflict`() { + // Data classes do not inherit Map/Collection members, so 'keys' is safe without escaping + val schema = SchemaModel( + name = "Container", + description = null, + properties = listOf( + PropertyModel("keys", TypeRef.Array(TypeRef.Primitive(PrimitiveType.STRING)), null, false), + ), + requiredProperties = setOf("keys"), + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, ) + val files = generate(spec(schemas = listOf(schema))) + val typeSpec = files[0].members.filterIsInstance()[0] + val prop = typeSpec.propertySpecs[0] + + assertEquals("keys", prop.name) // No escaping needed for non-keyword + val serialName = prop.annotations.first { it.typeName.toString().contains("SerialName") } + assertTrue(serialName.members.any { it.toString().contains("\"keys\"") }) } // -- ROB-01: Circular schema visited-set guard -- @@ -864,7 +1039,7 @@ class ModelGeneratorTest { ) // Should complete without StackOverflowError - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) assertNotNull(files, "generate should return results without StackOverflowError") } @@ -885,12 +1060,8 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) - val typeSpec = files - .first() - .members - .filterIsInstance() - .first() + val files = generate(spec(schemas = listOf(schema))) + val typeSpec = files[0].members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) val nicknameParam = constructor.parameters.first { it.name == "nickname" } @@ -930,9 +1101,9 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(baseSchema, composedSchema))) + val files = generate(spec(schemas = listOf(baseSchema, composedSchema))) val childFile = files.first { it.name == "Child" } - val typeSpec = childFile.members.filterIsInstance().first() + val typeSpec = childFile.members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) val optionalParam = constructor.parameters.first { it.name == "optionalField" } @@ -945,6 +1116,277 @@ class ModelGeneratorTest { ) } + // -- CEM-02: Freeform/Any type handling -- + + @Test + fun `freeform object property generates JsonElement type`() { + val schema = SchemaModel( + name = "WorkflowResultDTO", + description = null, + properties = listOf( + PropertyModel("response", TypeRef.Unknown, null, false), + ), + requiredProperties = setOf("response"), + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generate(spec(schemas = listOf(schema))) + val typeSpec = files + .first() + .members + .filterIsInstance() + .first() + val constructor = assertNotNull(typeSpec.primaryConstructor) + val param = constructor.parameters.first { it.name == "response" } + assertEquals("kotlinx.serialization.json.JsonElement", param.type.toString()) + } + + @Test + fun `nullable freeform property generates nullable JsonElement with null default`() { + val schema = SchemaModel( + name = "TaskReportDTO", + description = null, + properties = listOf( + PropertyModel("response", TypeRef.Unknown, null, true), + ), + requiredProperties = emptySet(), + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generate(spec(schemas = listOf(schema))) + val typeSpec = files + .first() + .members + .filterIsInstance() + .first() + val constructor = assertNotNull(typeSpec.primaryConstructor) + val param = constructor.parameters.first { it.name == "response" } + assertTrue(param.type.isNullable, "Nullable freeform property should be nullable") + assertEquals("kotlinx.serialization.json.JsonElement?", param.type.toString()) + assertEquals("null", param.defaultValue.toString()) + } + + @Test + fun `array of freeform items generates List of JsonElement`() { + val schema = SchemaModel( + name = "DeviceInfo", + description = null, + properties = listOf( + PropertyModel("healthChecks", TypeRef.Array(TypeRef.Unknown), null, true), + ), + requiredProperties = emptySet(), + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generate(spec(schemas = listOf(schema))) + val typeSpec = files + .first() + .members + .filterIsInstance() + .first() + val constructor = assertNotNull(typeSpec.primaryConstructor) + val param = constructor.parameters.first { it.name == "healthChecks" } + assertTrue( + param.type.toString().contains("List"), + "Expected List, got: ${param.type}", + ) + } + + // -- UUID support (SCHM-03/04/05) -- + + @Test + fun `data class with UUID property generates UuidSerializer file`() { + val schema = SchemaModel( + name = "Device", + description = null, + properties = listOf( + PropertyModel("id", TypeRef.Primitive(PrimitiveType.UUID), null, false), + ), + requiredProperties = setOf("id"), + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generate(spec(schemas = listOf(schema))) + val uuidSerializerFile = files.find { it.name == "UuidSerializer" } + assertNotNull(uuidSerializerFile, "Expected UuidSerializer file to be generated") + val content = uuidSerializerFile.toString() + assertTrue(content.contains("object UuidSerializer"), "Expected object UuidSerializer") + assertTrue(content.contains("KSerializer"), "Expected KSerializer") + } + + @Test + fun `data class with UUID property has file-level UseSerializers annotation`() { + val schema = SchemaModel( + name = "Device", + description = null, + properties = listOf( + PropertyModel("id", TypeRef.Primitive(PrimitiveType.UUID), null, false), + ), + requiredProperties = setOf("id"), + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generate(spec(schemas = listOf(schema))) + val deviceFile = files.find { it.name == "Device" } + assertNotNull(deviceFile) + val content = deviceFile.toString() + assertTrue( + content.contains("@file:UseSerializers(UuidSerializer::class)"), + "Expected @file:UseSerializers(UuidSerializer::class) file annotation, got:\n$content", + ) + } + + @Test + fun `data class with UUID property has file-level OptIn annotation`() { + val schema = SchemaModel( + name = "Device", + description = null, + properties = listOf( + PropertyModel("id", TypeRef.Primitive(PrimitiveType.UUID), null, false), + ), + requiredProperties = setOf("id"), + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generate(spec(schemas = listOf(schema))) + val deviceFile = files.find { it.name == "Device" } + assertNotNull(deviceFile) + val content = deviceFile.toString() + assertTrue( + content.contains("ExperimentalUuidApi"), + "Expected @OptIn(ExperimentalUuidApi::class) file annotation, got:\n$content", + ) + } + + @Test + fun `data class with UUID array property generates UuidSerializer and file-level annotations`() { + val schema = SchemaModel( + name = "DeviceWithUuidList", + description = null, + properties = listOf( + PropertyModel( + "ids", + TypeRef.Array(TypeRef.Primitive(PrimitiveType.UUID)), + null, + false, + ), + ), + requiredProperties = setOf("ids"), + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generate(spec(schemas = listOf(schema))) + val deviceFile = files.find { it.name == "DeviceWithUuidList" } + assertNotNull(deviceFile) + val content = deviceFile.toString() + + assertTrue( + content.contains("@file:UseSerializers(UuidSerializer::class)"), + "Expected @file:UseSerializers(UuidSerializer::class) for UUID inside array, got:\n$content", + ) + + assertTrue( + content.contains("ExperimentalUuidApi"), + "Expected @OptIn(ExperimentalUuidApi::class) for UUID inside array, got:\n$content", + ) + + val uuidSerializerFile = files.find { it.name == "UuidSerializer" } + assertNotNull(uuidSerializerFile, "Expected UuidSerializer file to be generated for UUID inside array") + } + + @Test + fun `data class with UUID map property generates UuidSerializer and file-level annotations`() { + val schema = SchemaModel( + name = "DeviceWithUuidMap", + description = null, + properties = listOf( + PropertyModel( + "idByKey", + TypeRef.Map(TypeRef.Primitive(PrimitiveType.UUID)), + null, + false, + ), + ), + requiredProperties = setOf("idByKey"), + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generate(spec(schemas = listOf(schema))) + val deviceFile = files.find { it.name == "DeviceWithUuidMap" } + assertNotNull(deviceFile) + val content = deviceFile.toString() + + assertTrue( + content.contains("@file:UseSerializers(UuidSerializer::class)"), + "Expected @file:UseSerializers(UuidSerializer::class) for UUID inside map, got:\n$content", + ) + + assertTrue( + content.contains("ExperimentalUuidApi"), + "Expected @OptIn(ExperimentalUuidApi::class) for UUID inside map, got:\n$content", + ) + + val uuidSerializerFile = files.find { it.name == "UuidSerializer" } + assertNotNull(uuidSerializerFile, "Expected UuidSerializer file to be generated for UUID inside map") + } + + @Test + fun `data class without UUID property does not generate UuidSerializer`() { + val files = generate(spec(schemas = listOf(petSchema))) + val uuidSerializerFile = files.find { it.name == "UuidSerializer" } + assertEquals(null, uuidSerializerFile, "Expected no UuidSerializer file without UUID properties") + } + + @Test + fun `UUID in endpoint parameter triggers UuidSerializer generation`() { + val endpoint = Endpoint( + path = "/devices/{deviceId}", + method = HttpMethod.GET, + operationId = "getDevice", + summary = null, + tags = emptyList(), + parameters = listOf( + Parameter( + name = "deviceId", + location = ParameterLocation.PATH, + required = true, + schema = TypeRef.Primitive(PrimitiveType.UUID), + description = null, + ), + ), + requestBody = null, + responses = emptyMap(), + description = null, + ) + val apiSpec = ApiSpec( + title = "Test", + version = "1.0", + endpoints = listOf(endpoint), + schemas = emptyList(), + enums = emptyList(), + securitySchemes = emptyList(), + ) + val files = generate(apiSpec) + val uuidSerializerFile = files.find { it.name == "UuidSerializer" } + assertNotNull(uuidSerializerFile, "Expected UuidSerializer when UUID is used in endpoint parameter") + } + @Test fun `required property in allOf schema generates as non-nullable without default`() { val composedSchema = SchemaModel( @@ -960,13 +1402,119 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(composedSchema))) + val files = generate(spec(schemas = listOf(composedSchema))) val childFile = files.first { it.name == "Child" } - val typeSpec = childFile.members.filterIsInstance().first() + val typeSpec = childFile.members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) val idParam = constructor.parameters.first { it.name == "id" } assertTrue(!idParam.type.isNullable, "Required property should be non-nullable") assertEquals(null, idParam.defaultValue, "Required property should have no default") } + + // -- Property KDoc tests (DOCS-03) -- + + @Test + fun `property with description generates KDoc`() { + val schema = SchemaModel( + name = "Pet", + description = null, + properties = listOf( + PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), "The pet's name", false), + ), + requiredProperties = setOf("name"), + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generate(spec(schemas = listOf(schema))) + val typeSpec = files + .first() + .members + .filterIsInstance() + .first() + val prop = typeSpec.propertySpecs.first { it.name == "name" } + assertTrue( + prop.kdoc.toString().contains("The pet's name"), + "Expected KDoc with description, got: ${prop.kdoc}", + ) + } + + @Test + fun `property without description generates no KDoc`() { + val schema = SchemaModel( + name = "Pet", + description = null, + properties = listOf( + PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), + ), + requiredProperties = setOf("name"), + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generate(spec(schemas = listOf(schema))) + val typeSpec = files + .first() + .members + .filterIsInstance() + .first() + val prop = typeSpec.propertySpecs.first { it.name == "name" } + assertTrue( + prop.kdoc.toString().isEmpty(), + "Expected no KDoc when description is null, got: ${prop.kdoc}", + ) + } + + @Test + fun `enum with valueDescriptions generates constant KDoc`() { + val enum = EnumModel( + name = "Status", + description = null, + type = EnumBackingType.STRING, + values = listOf(EnumModel.Value("active", "Currently active"), EnumModel.Value("inactive", "Not active")), + ) + val files = generate(spec(enums = listOf(enum))) + val typeSpec = files + .first() + .members + .filterIsInstance() + .first() + val activeConstant = typeSpec.enumConstants["ACTIVE"] + assertNotNull(activeConstant, "Expected ACTIVE enum constant") + assertTrue( + activeConstant.kdoc.toString().contains("Currently active"), + "Expected KDoc 'Currently active' on ACTIVE, got: ${activeConstant.kdoc}", + ) + val inactiveConstant = typeSpec.enumConstants["INACTIVE"] + assertNotNull(inactiveConstant, "Expected INACTIVE enum constant") + assertTrue( + inactiveConstant.kdoc.toString().contains("Not active"), + "Expected KDoc 'Not active' on INACTIVE, got: ${inactiveConstant.kdoc}", + ) + } + + @Test + fun `enum without valueDescriptions generates no constant KDoc`() { + val enum = EnumModel( + name = "Status", + description = null, + type = EnumBackingType.STRING, + values = listOf(EnumModel.Value("active"), EnumModel.Value("inactive")), + ) + val files = generate(spec(enums = listOf(enum))) + val typeSpec = files + .first() + .members + .filterIsInstance() + .first() + val activeConstant = typeSpec.enumConstants["ACTIVE"] + assertNotNull(activeConstant, "Expected ACTIVE enum constant") + assertTrue( + activeConstant.kdoc.toString().isEmpty(), + "Expected no KDoc on ACTIVE when no valueDescriptions, got: ${activeConstant.kdoc}", + ) + } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/NameRegistryTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/NameRegistryTest.kt new file mode 100644 index 0000000..28f6ca7 --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/NameRegistryTest.kt @@ -0,0 +1,73 @@ +package com.avsystem.justworks.core.gen + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class NameRegistryTest { + @Test + fun `register on empty registry returns desired name`() { + val registry = NameRegistry() + assertEquals("Foo", registry.register("Foo")) + } + + @Test + fun `register same name twice returns numeric suffix`() { + val registry = NameRegistry() + assertEquals("Foo", registry.register("Foo")) + assertEquals("Foo2", registry.register("Foo")) + } + + @Test + fun `register same name three times returns incrementing suffixes`() { + val registry = NameRegistry() + assertEquals("Foo", registry.register("Foo")) + assertEquals("Foo2", registry.register("Foo")) + assertEquals("Foo3", registry.register("Foo")) + } + + @Test + fun `reserve then register returns Foo2`() { + val registry = NameRegistry() + registry.reserve("Foo") + assertEquals("Foo2", registry.register("Foo")) + } + + @Test + fun `reserve Foo and Foo2 then register Foo returns Foo3`() { + val registry = NameRegistry() + registry.reserve("Foo") + registry.reserve("Foo2") + assertEquals("Foo3", registry.register("Foo")) + } + + @Test + fun `register after reserve for component schema collision`() { + val registry = NameRegistry() + registry.reserve("Pet") + assertEquals("Pet2", registry.register("Pet")) + } + + @Test + fun `different names do not interfere`() { + val registry = NameRegistry() + assertEquals("Foo", registry.register("Foo")) + assertEquals("Bar", registry.register("Bar")) + } + + @Test + fun `register empty name throws`() { + val registry = NameRegistry() + assertFailsWith { + registry.register("") + } + } + + @Test + fun `names differing only by case are treated as distinct`() { + val registry = NameRegistry() + assertEquals("Foo", registry.register("Foo")) + assertEquals("foo", registry.register("foo")) + assertEquals("FOO", registry.register("FOO")) + } +} diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGeneratorTest.kt index fbabf73..e2e7fdb 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGeneratorTest.kt @@ -1,5 +1,8 @@ package com.avsystem.justworks.core.gen +import com.avsystem.justworks.core.gen.model.ModelGenerator +import com.avsystem.justworks.core.gen.shared.SerializersModuleGenerator +import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.PropertySpec import kotlin.test.Test import kotlin.test.assertNotNull @@ -7,8 +10,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class SerializersModuleGeneratorTest { - private val modelPackage = "com.example.model" - private val generator = SerializersModuleGenerator(modelPackage) + private val modelPackage = ModelPackage("com.example.model") private fun hierarchyInfo( sealedHierarchies: Map>, @@ -20,10 +22,13 @@ class SerializersModuleGeneratorTest { schemas = emptyList(), ) + private fun generate(info: ModelGenerator.HierarchyInfo): FileSpec? = + context(info, modelPackage) { SerializersModuleGenerator.generate() } + @Test fun `generates SerializersModule with polymorphic registration`() { val hierarchies = mapOf("Shape" to listOf("Circle", "Square")) - val fileSpec = context(hierarchyInfo(hierarchies)) { generator.generate() } + val fileSpec = generate(hierarchyInfo(hierarchies)) assertNotNull(fileSpec, "Should generate a FileSpec for non-empty hierarchies") @@ -42,7 +47,7 @@ class SerializersModuleGeneratorTest { "Shape" to listOf("Circle", "Square"), "Animal" to listOf("Cat", "Dog"), ) - val fileSpec = context(hierarchyInfo(hierarchies)) { generator.generate() } + val fileSpec = generate(hierarchyInfo(hierarchies)) assertNotNull(fileSpec) val initializer = @@ -61,7 +66,7 @@ class SerializersModuleGeneratorTest { @Test fun `returns null for empty hierarchies`() { - val result = context(hierarchyInfo(emptyMap())) { generator.generate() } + val result = generate(hierarchyInfo(emptyMap())) assertNull(result, "Should return null for empty hierarchies") } @@ -72,7 +77,7 @@ class SerializersModuleGeneratorTest { "Pet" to listOf("Cat", "Dog"), ) val info = hierarchyInfo(hierarchies, anyOfWithoutDiscriminator = setOf("Pet")) - val fileSpec = context(info) { generator.generate() } + val fileSpec = generate(info) assertNotNull(fileSpec) val initializer = fileSpec.members @@ -89,7 +94,7 @@ class SerializersModuleGeneratorTest { fun `returns null when all hierarchies are anyOf without discriminator`() { val hierarchies = mapOf("Pet" to listOf("Cat", "Dog")) val info = hierarchyInfo(hierarchies, anyOfWithoutDiscriminator = setOf("Pet")) - val result = context(info) { generator.generate() } + val result = generate(info) assertNull(result, "Should return null when only non-discriminator anyOf hierarchies exist") } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/TypeMappingTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/TypeMappingTest.kt index fa74b18..a56be4f 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/TypeMappingTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/TypeMappingTest.kt @@ -3,74 +3,86 @@ package com.avsystem.justworks.core.gen import com.avsystem.justworks.core.model.PrimitiveType import com.avsystem.justworks.core.model.PropertyModel import com.avsystem.justworks.core.model.TypeRef +import com.squareup.kotlinpoet.TypeName import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class TypeMappingTest { - private val pkg = "com.example.model" + private val pkg = ModelPackage("com.example.model") + + private fun map(typeRef: TypeRef): TypeName = context(pkg) { + typeRef.toTypeName() + } // -- Primitive types -- @Test fun `maps STRING to kotlin String`() { - val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.STRING), pkg) + val result = map(TypeRef.Primitive(PrimitiveType.STRING)) assertEquals("kotlin.String", result.toString()) } @Test fun `maps INT to kotlin Int`() { - val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.INT), pkg) + val result = map(TypeRef.Primitive(PrimitiveType.INT)) assertEquals("kotlin.Int", result.toString()) } @Test fun `maps LONG to kotlin Long`() { - val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.LONG), pkg) + val result = map(TypeRef.Primitive(PrimitiveType.LONG)) assertEquals("kotlin.Long", result.toString()) } @Test fun `maps DOUBLE to kotlin Double`() { - val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.DOUBLE), pkg) + val result = map(TypeRef.Primitive(PrimitiveType.DOUBLE)) assertEquals("kotlin.Double", result.toString()) } @Test fun `maps FLOAT to kotlin Float`() { - val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.FLOAT), pkg) + val result = map(TypeRef.Primitive(PrimitiveType.FLOAT)) assertEquals("kotlin.Float", result.toString()) } @Test fun `maps BOOLEAN to kotlin Boolean`() { - val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.BOOLEAN), pkg) + val result = map(TypeRef.Primitive(PrimitiveType.BOOLEAN)) assertEquals("kotlin.Boolean", result.toString()) } @Test fun `maps BYTE_ARRAY to kotlin ByteArray`() { - val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.BYTE_ARRAY), pkg) + val result = map(TypeRef.Primitive(PrimitiveType.BYTE_ARRAY)) assertEquals("kotlin.ByteArray", result.toString()) } @Test fun `maps DATE_TIME to kotlin time Instant`() { - val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.DATE_TIME), pkg) + val result = map(TypeRef.Primitive(PrimitiveType.DATE_TIME)) assertEquals("kotlin.time.Instant", result.toString()) } @Test fun `maps DATE to kotlinx datetime LocalDate`() { - val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.DATE), pkg) + val result = map(TypeRef.Primitive(PrimitiveType.DATE)) assertEquals("kotlinx.datetime.LocalDate", result.toString()) } + @Test + fun `maps UUID to kotlin uuid Uuid`() { + val result = map(TypeRef.Primitive(PrimitiveType.UUID)) + assertEquals("kotlin.uuid.Uuid", result.toString()) + } + // -- Array -- @Test fun `maps Array of String to List of String`() { val ref = TypeRef.Array(TypeRef.Primitive(PrimitiveType.STRING)) - val result = TypeMapping.toTypeName(ref, pkg) + val result = map(ref) assertEquals("kotlin.collections.List", result.toString()) } @@ -79,7 +91,7 @@ class TypeMappingTest { @Test fun `maps Map of String to Map with String key and String value`() { val ref = TypeRef.Map(TypeRef.Primitive(PrimitiveType.STRING)) - val result = TypeMapping.toTypeName(ref, pkg) + val result = map(ref) assertEquals("kotlin.collections.Map", result.toString()) } @@ -88,7 +100,7 @@ class TypeMappingTest { @Test fun `maps Reference to ClassName in model package`() { val ref = TypeRef.Reference("Pet") - val result = TypeMapping.toTypeName(ref, pkg) + val result = map(ref) assertEquals("com.example.model.Pet", result.toString()) } @@ -97,21 +109,20 @@ class TypeMappingTest { @Test fun `maps Array of Reference to List of model class`() { val ref = TypeRef.Array(TypeRef.Reference("Pet")) - val result = TypeMapping.toTypeName(ref, pkg) + val result = map(ref) assertEquals("kotlin.collections.List", result.toString()) } // -- Inline -- @Test - fun `maps Inline to ClassName using contextHint`() { + fun `Inline throws error because it should be resolved before type mapping`() { val ref = TypeRef.Inline( properties = listOf(PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false)), requiredProperties = setOf("name"), contextHint = "Pet.Address", ) - val result = TypeMapping.toTypeName(ref, pkg) - assertEquals("com.example.model.Pet_Address", result.toString()) + assertFailsWith { map(ref) } } // -- SCHM-07: Instant mapping verification -- @@ -125,8 +136,20 @@ class TypeMappingTest { // -- Unknown -- @Test - fun `maps Unknown to kotlin Any`() { - val result = TypeMapping.toTypeName(TypeRef.Unknown, pkg) - assertEquals("kotlin.Any", result.toString()) + fun `maps Unknown to kotlinx serialization json JsonElement`() { + val result = map(TypeRef.Unknown) + assertEquals("kotlinx.serialization.json.JsonElement", result.toString()) + } + + @Test + fun `maps Array of Unknown to List of JsonElement`() { + val result = map(TypeRef.Array(TypeRef.Unknown)) + assertEquals("kotlin.collections.List", result.toString()) + } + + @Test + fun `maps Map of Unknown to Map with JsonElement value`() { + val result = map(TypeRef.Map(TypeRef.Unknown)) + assertEquals("kotlin.collections.Map", result.toString()) } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/model/ContentTypeTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/model/ContentTypeTest.kt new file mode 100644 index 0000000..eac8cf9 --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/model/ContentTypeTest.kt @@ -0,0 +1,20 @@ +package com.avsystem.justworks.core.model + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ContentTypeTest { + @Test + fun `ContentType entries are ordered by priority for content negotiation`() { + assertEquals( + listOf( + ContentType.MULTIPART_FORM_DATA, + ContentType.FORM_URL_ENCODED, + ContentType.JSON_CONTENT_TYPE, + ), + ContentType.entries, + "ContentType declaration order matters: SpecParser.find picks the first matching entry, " + + "so more specific content types must come before JSON", + ) + } +} diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt index 908a836..9cebff4 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt @@ -56,4 +56,82 @@ class SpecParserPolymorphicTest : SpecParserTestBase() { assertEquals("#/components/schemas/Circle", discriminator.mapping["circle"]) assertEquals("#/components/schemas/Square", discriminator.mapping["square"]) } + + // -- Synthetic schemas from wrapper unwrapping -- + + @Test + fun `boolean discriminator spec preserves original schema names`() { + val spec = parseSpec(loadResource("boolean-discriminator-spec.yaml")) + val schemaNames = spec.schemas.map { it.name }.toSet() + + assertTrue( + "true" in schemaNames, + "Expected schema 'true' in output — KotlinPoet handles escaping. Schemas: $schemaNames", + ) + assertTrue( + "false" in schemaNames, + "Expected schema 'false' in output — KotlinPoet handles escaping. Schemas: $schemaNames", + ) + } + + @Test + fun `boolean discriminator mapping preserves original values as keys`() { + val spec = parseSpec(loadResource("boolean-discriminator-spec.yaml")) + + val deviceStatus = + spec.schemas.find { it.name == "DeviceStatus" } + ?: fail("DeviceStatus schema not found. Schemas: ${spec.schemas.map { it.name }}") + + val discriminator = assertNotNull(deviceStatus.discriminator, "DeviceStatus should have discriminator") + val mappingKeys = discriminator.mapping.keys + + assertTrue( + "true" in mappingKeys, + "Discriminator mapping should have 'true' as key. Keys: $mappingKeys", + ) + assertTrue( + "false" in mappingKeys, + "Discriminator mapping should have 'false' as key. Keys: $mappingKeys", + ) + + // Values reference original schema names + assertTrue( + discriminator.mapping["true"]!!.endsWith("true"), + "Mapping for 'true' should reference 'true'. Value: ${discriminator.mapping["true"]}", + ) + assertTrue( + discriminator.mapping["false"]!!.endsWith("false"), + "Mapping for 'false' should reference 'false'. Value: ${discriminator.mapping["false"]}", + ) + } + + @Test + fun `wrapper-unwrapped synthetic schemas appear in parsed output`() { + val spec = parseSpec(loadResource("boolean-discriminator-spec.yaml")) + val schemaNames = spec.schemas.map { it.name }.toSet() + + // DeviceStatus parent + 2 synthetic variants + assertTrue( + "DeviceStatus" in schemaNames, + "Parent schema 'DeviceStatus' should be in output. Schemas: $schemaNames", + ) + assertTrue( + spec.schemas.size >= 3, + "Should have at least 3 schemas (parent + 2 variants). Got: ${spec.schemas.size}. Schemas: $schemaNames", + ) + } + + @Test + fun `polymorphic-spec regression test - all schemas present`() { + val spec = parseSpec(loadResource("polymorphic-spec.yaml")) + val schemaNames = spec.schemas.map { it.name }.toSet() + + val expectedSchemas = setOf("Shape", "Circle", "Square", "Pet", "Cat", "Dog", "ExtendedDog") + for (expected in expectedSchemas) { + assertTrue( + expected in schemaNames, + "Expected schema '$expected' in output. Schemas: $schemaNames", + ) + } + } } 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/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt index fc9871a..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 @@ -1,7 +1,9 @@ package com.avsystem.justworks.core.parser import com.avsystem.justworks.core.model.ApiSpec +import com.avsystem.justworks.core.model.ContentType import com.avsystem.justworks.core.model.EnumBackingType +import com.avsystem.justworks.core.model.EnumModel import com.avsystem.justworks.core.model.HttpMethod import com.avsystem.justworks.core.model.ParameterLocation import com.avsystem.justworks.core.model.PrimitiveType @@ -27,10 +29,10 @@ class SpecParserTest : SpecParserTestBase() { } } - private fun parseSpecErrors(file: File): List { + private fun parseSpecIssues(file: File): List { val result = SpecParser.parse(file) check(result is ParseResult.Failure) { "Expected failure" } - return result.errors + return result.warnings.map { it.message } + result.error.message } // -- SPEC-01: OpenAPI 3.0 parsing -- @@ -53,7 +55,10 @@ class SpecParserTest : SpecParserTestBase() { val petStatus = petstore.enums.find { it.name == "PetStatus" } assertNotNull(petStatus, "PetStatus enum missing") assertEquals(EnumBackingType.STRING, petStatus.type) - assertEquals(listOf("available", "pending", "sold"), petStatus.values) + assertEquals( + listOf(EnumModel.Value("available"), EnumModel.Value("pending"), EnumModel.Value("sold")), + petStatus.values, + ) } @Test @@ -129,7 +134,7 @@ class SpecParserTest : SpecParserTestBase() { val body = assertNotNull(createPet.requestBody, "createPet should have a request body") assertTrue(body.required, "Request body should be required") - assertEquals("application/json", body.contentType) + assertEquals(ContentType.JSON_CONTENT_TYPE, body.contentType) val bodyType = assertIs(body.schema) assertEquals("NewPet", bodyType.schemaName) @@ -195,10 +200,9 @@ class SpecParserTest : SpecParserTestBase() { val order = spec.schemas.find { it.name == "Order" } ?: fail("Order schema not found") - val itemProp = - order.properties.find { it.name == "item" } - ?: fail("item property not found on Order") - + assert(order.properties.any { it.name == "item" }) { + "item property not found on Order" + } // After resolveFully, the item property may be inlined or a reference // Either way, ItemDetails should exist as a named schema val itemDetails = spec.schemas.find { it.name == "ItemDetails" } @@ -216,26 +220,26 @@ class SpecParserTest : SpecParserTestBase() { // The $ref parameter (LimitParam) should be resolved to an actual parameter val limitParam = listOrders.parameters.find { it.name == "limit" } - ?: fail("limit parameter not found -- \$ref parameter not resolved") + ?: fail($$"limit parameter not found -- $ref parameter not resolved") assertEquals(ParameterLocation.QUERY, limitParam.location) } - // -- SPEC-03: Error reporting -- + // -- SPEC-03: Warning reporting -- @Test - fun `parse invalid spec returns Failure`() { + 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 invalid spec has descriptive error messages`() { - val errors = parseSpecErrors(loadResource("invalid-spec.yaml")) - - assertTrue(errors.isNotEmpty(), "Failure should have error messages") - // Errors should be human-readable, not empty or codes-only - errors.forEach { error -> - assertTrue(error.length > 5, "Error message too short to be useful: '$error'") + fun `parse spec with missing info has descriptive warning messages`() { + val result = SpecParser.parse(loadResource("invalid-spec.yaml")) + 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'") } } @@ -244,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 @@ -292,7 +296,7 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `mixed anyOf and oneOf raises error`() { - val errors = parseSpecErrors(loadResource("mixed-combinator-spec.yaml")) + val errors = parseSpecIssues(loadResource("mixed-combinator-spec.yaml")) val errorMessages = errors.joinToString("\n") assertTrue( @@ -306,7 +310,7 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `property with allOf reference resolves to referenced type`() { val spec = - """ + $$""" openapi: 3.0.0 info: title: Test @@ -326,7 +330,7 @@ class SpecParserTest : SpecParserTestBase() { type: string config: allOf: - - ${'$'}ref: '#/components/schemas/TaskConfig' + - $ref: '#/components/schemas/TaskConfig' required: - name """.trimIndent() @@ -344,8 +348,281 @@ class SpecParserTest : SpecParserTestBase() { assertEquals("TaskConfig", configType.schemaName) } + // -- underlyingType resolution -- + + @Test + fun `primitive integer schema has underlyingType INT`() { + val spec = parseSpec( + """ + openapi: 3.0.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + GroupId: + type: integer + format: int64 + """.trimIndent().toTempFile(), + ) + + val groupId = spec.schemas.find { it.name == "GroupId" } + ?: fail("GroupId schema not found") + val underlying = assertNotNull(groupId.underlyingType, "underlyingType should be set") + val primitive = assertIs(underlying) + assertEquals(PrimitiveType.LONG, primitive.type) + } + + @Test + fun `primitive boolean schema has underlyingType BOOLEAN`() { + val spec = parseSpec( + """ + openapi: 3.0.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Active: + type: boolean + """.trimIndent().toTempFile(), + ) + + val active = spec.schemas.find { it.name == "Active" } + ?: fail("Active schema not found") + val underlying = assertNotNull(active.underlyingType, "underlyingType should be set") + val primitive = assertIs(underlying) + assertEquals(PrimitiveType.BOOLEAN, primitive.type) + } + + @Test + fun `array schema has underlyingType Array`() { + val spec = parseSpec( + """ + openapi: 3.0.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + TagList: + type: array + items: + type: string + """.trimIndent().toTempFile(), + ) + + val tagList = spec.schemas.find { it.name == "TagList" } + ?: fail("TagList schema not found") + val underlying = assertNotNull(tagList.underlyingType, "underlyingType should be set") + assertIs(underlying) + } + + @Test + fun `ref wrapper schema has underlyingType Reference`() { + val spec = parseSpec( + $$""" + openapi: 3.0.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Pet: + type: object + properties: + name: + type: string + PetAlias: + $ref: '#/components/schemas/Pet' + """.trimIndent().toTempFile(), + ) + + val petAlias = spec.schemas.find { it.name == "PetAlias" } + ?: fail("PetAlias schema not found") + val underlying = assertNotNull(petAlias.underlyingType, "underlyingType should be set") + val ref = assertIs(underlying) + assertEquals("Pet", ref.schemaName) + } + + @Test + fun `object schema with properties has no underlyingType`() { + val spec = parseSpec( + """ + openapi: 3.0.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + User: + type: object + properties: + name: + type: string + """.trimIndent().toTempFile(), + ) + + val user = spec.schemas.find { it.name == "User" } + ?: fail("User schema not found") + assertEquals(null, user.underlyingType, "object with properties should not have underlyingType") + } + + @Test + fun `schema with allOf has no underlyingType`() { + val spec = parseSpec( + $$""" + openapi: 3.0.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Base: + type: object + properties: + id: + type: integer + Extended: + allOf: + - $ref: '#/components/schemas/Base' + type: object + properties: + name: + type: string + """.trimIndent().toTempFile(), + ) + + val extended = spec.schemas.find { it.name == "Extended" } + ?: fail("Extended schema not found") + assertEquals(null, extended.underlyingType, "allOf schema should not have underlyingType") + } + + // -- x-enum-descriptions -- + + @Test + fun `enum with x-enum-descriptions as list populates value descriptions`() { + val spec = parseSpec( + """ + openapi: 3.0.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Color: + type: string + enum: + - red + - green + - blue + x-enum-descriptions: + - The color red + - The color green + - The color blue + """.trimIndent().toTempFile(), + ) + + val color = spec.enums.find { it.name == "Color" } ?: fail("Color enum not found") + assertEquals( + listOf( + EnumModel.Value("red", "The color red"), + EnumModel.Value("green", "The color green"), + EnumModel.Value("blue", "The color blue"), + ), + color.values, + ) + } + + @Test + fun `enum with x-enum-descriptions as map populates value descriptions`() { + val spec = parseSpec( + """ + openapi: 3.0.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Priority: + type: string + enum: + - low + - medium + - high + x-enum-descriptions: + low: Low priority + high: High priority + """.trimIndent().toTempFile(), + ) + + val priority = spec.enums.find { it.name == "Priority" } ?: fail("Priority enum not found") + assertEquals( + listOf( + EnumModel.Value("low", "Low priority"), + EnumModel.Value("medium", null), + EnumModel.Value("high", "High priority"), + ), + priority.values, + ) + } + + // -- SCHM-03/04/05: Extended format type mapping -- + + @Test + fun `format type mapping produces correct PrimitiveType`() { + val cases = listOf( + Triple("string", "uuid", PrimitiveType.UUID), + Triple("string", "uri", PrimitiveType.STRING), + Triple("string", "url", PrimitiveType.STRING), + Triple("string", "binary", PrimitiveType.BYTE_ARRAY), + Triple("string", "email", PrimitiveType.STRING), + Triple("string", "hostname", PrimitiveType.STRING), + Triple("string", "ipv4", PrimitiveType.STRING), + Triple("string", "ipv6", PrimitiveType.STRING), + Triple("string", "password", PrimitiveType.STRING), + Triple("string", "byte", PrimitiveType.BYTE_ARRAY), + Triple("string", "date", PrimitiveType.DATE), + Triple("string", "date-time", PrimitiveType.DATE_TIME), + Triple("integer", "int32", PrimitiveType.INT), + Triple("integer", "int64", PrimitiveType.LONG), + Triple("number", "float", PrimitiveType.FLOAT), + Triple("number", "double", PrimitiveType.DOUBLE), + ) + for ((oasType, format, expected) in cases) { + val prop = parseSpec(formatSpec(oasType, format)).schemas[0].properties[0] + val type = assertIs(prop.type, "Expected Primitive for $oasType/$format") + assertEquals(expected, type.type, "$oasType with format $format should produce $expected") + } + } + // -- Helpers -- + private fun formatSpec(type: String, format: String): File = + """ + openapi: 3.0.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + TestModel: + type: object + properties: + field: + type: $type + format: $format + """.trimIndent().toTempFile() + private fun String.toTempFile(): File { val tempFile = File.createTempFile("test-spec-", ".yaml") tempFile.deleteOnExit() 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 e0d5fa1..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.Failure -> fail("Expected success but got errors: ${result.errors}") + is ParseResult.Success -> result.value + is ParseResult.Failure -> fail("Expected success but got error: ${result.error}") } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecValidatorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecValidatorTest.kt index 67de584..a154f54 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecValidatorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecValidatorTest.kt @@ -1,18 +1,26 @@ package com.avsystem.justworks.core.parser +import arrow.core.raise.iorNel +import com.avsystem.justworks.core.Issue import io.swagger.v3.oas.models.OpenAPI import io.swagger.v3.oas.models.PathItem import io.swagger.v3.oas.models.Paths import io.swagger.v3.oas.models.info.Info import kotlin.test.Test -import kotlin.test.assertIs import kotlin.test.assertTrue class SpecValidatorTest { + private fun validateAndCollectWarnings(openApi: OpenAPI): List = + iorNel { SpecValidator.validate(openApi) }.fold( + { warnings -> warnings }, + { emptyList() }, + { warnings, _ -> warnings }, + ) + // -- VALID-01: Valid spec -- @Test - fun `valid OpenAPI object produces no errors`() { + fun `valid OpenAPI object produces no issues`() { val openApi = OpenAPI().apply { info = @@ -26,14 +34,14 @@ class SpecValidatorTest { } } - val errors = SpecValidator.validate(openApi) - assertTrue(errors.isEmpty(), "Valid spec should produce no errors, got: $errors") + val warnings = validateAndCollectWarnings(openApi) + assertTrue(warnings.isEmpty(), "Valid spec should produce no warnings, got: $warnings") } // -- VALID-02: Missing required fields -- @Test - fun `OpenAPI with null info produces errors`() { + fun `OpenAPI with null info produces warning`() { val openApi = OpenAPI().apply { info = null @@ -43,11 +51,11 @@ class SpecValidatorTest { } } - val issues = SpecValidator.validate(openApi) - assertTrue(issues.isNotEmpty(), "Missing info should produce issues") + val warnings = validateAndCollectWarnings(openApi) + assertTrue(warnings.isNotEmpty(), "Missing info should produce warnings") assertTrue( - issues.any { it.message.contains("info", ignoreCase = true) }, - "Error should mention 'info': $issues", + warnings.any { it.message.contains("info", ignoreCase = true) }, + "Warning should mention 'info': $warnings", ) } @@ -65,13 +73,11 @@ class SpecValidatorTest { paths = null } - val issues = SpecValidator.validate(openApi) - assertTrue(issues.isNotEmpty(), "Spec with no paths should produce issues") - val warning = issues.firstOrNull { it is SpecValidator.ValidationIssue.Warning } - assertIs(warning, "Expected a Warning for no paths, got: $issues") + val warnings = validateAndCollectWarnings(openApi) + assertTrue(warnings.isNotEmpty(), "Spec with no paths should produce warnings") assertTrue( - warning.message.contains("paths", ignoreCase = true), - "Warning should mention 'paths': ${warning.message}", + warnings.any { it.message.contains("paths", ignoreCase = true) }, + "Warning should mention 'paths': $warnings", ) } } diff --git a/core/src/test/resources/boolean-discriminator-spec.yaml b/core/src/test/resources/boolean-discriminator-spec.yaml new file mode 100644 index 0000000..9899706 --- /dev/null +++ b/core/src/test/resources/boolean-discriminator-spec.yaml @@ -0,0 +1,25 @@ +openapi: '3.0.0' +info: + title: Boolean Discriminator Test + version: '1.0' +paths: {} +components: + schemas: + DeviceStatus: + oneOf: + - type: object + properties: + "true": + type: object + properties: + connectedSince: + type: string + format: date-time + - type: object + properties: + "false": + type: object + properties: + lastSeen: + type: string + format: date-time 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/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt b/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt index fb50eb7..6347cf9 100644 --- a/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt +++ b/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt @@ -600,7 +600,7 @@ class JustworksPluginFunctionalTest { } @Test - fun `spec without security schemes generates backward-compatible ApiClientBase`() { + fun `spec without security schemes generates ApiClientBase with no auth params`() { writeBuildFile() runner("justworksGenerateMain").build() @@ -610,8 +610,8 @@ class JustworksPluginFunctionalTest { assertTrue(apiClientBase.exists(), "ApiClientBase.kt should exist") val content = apiClientBase.readText() - assertTrue(content.contains("token"), "Should contain backward-compat token param") - assertTrue(content.contains("Bearer"), "Should contain Bearer in applyAuth body") + 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 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 51c4f91..c584c58 100644 --- a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksGenerateTask.kt +++ b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksGenerateTask.kt @@ -47,16 +47,17 @@ abstract class JustworksGenerateTask : DefaultTask() { val outDir = outputDir.get().asFile.recreateDirectory() val spec = specFile.get().asFile - when (val result = SpecParser.parse(spec)) { + val result = SpecParser.parse(spec) + result.warnings.forEach { logger.warn(it.message) } + + when (result) { is ParseResult.Failure -> { - throw GradleException( - "Failed to parse spec (task: $name): ${spec.name}:\n${result.errors.joinToString("\n")}", - ) + throw GradleException("Failed to parse spec (task: $name): ${spec.name}:\n${result.error}") } 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 bf8cdd0..f54fe6d 100644 --- a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt +++ b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt @@ -1,7 +1,6 @@ package com.avsystem.justworks.gradle import com.avsystem.justworks.core.gen.CodeGenerator -import com.avsystem.justworks.core.model.SecurityScheme import com.avsystem.justworks.core.parser.ParseResult import com.avsystem.justworks.core.parser.SpecParser import org.gradle.api.DefaultTask @@ -18,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() { @@ -37,23 +37,31 @@ abstract class JustworksSharedTypesTask : DefaultTask() { fun generate() { val outDir = outputDir.get().asFile.recreateDirectory() - val securitySchemes = extractSecuritySchemes() - - val count = CodeGenerator.generateSharedTypes(outDir, securitySchemes.ifEmpty { null }) - - logger.lifecycle("Generated $count shared type files") - } + 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 + } - private fun extractSecuritySchemes(): List = specFiles.files - .mapNotNull { file -> - when (val result = SpecParser.parse(file)) { - is ParseResult.Success -> result.apiSpec.securitySchemes is ParseResult.Failure -> { - logger.warn("Failed to parse spec '${file.name}': ${result.errors.joinToString()}") - null + logger.warn("Failed to parse security schemes from '${file.name}': ${result.error}") + emptyList() } } } - .flatten() - .distinctBy { it.name } + + 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 }) + logger.lifecycle("Generated $count shared type files") + } } From 8def56c498f23cf2ecd0809cb85bd14698d86753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 8 Apr 2026 10:55:56 +0200 Subject: [PATCH 14/17] refactor: replace Arrow Either with sealed HttpResult interface Remove arrow-core runtime dependency from generated code. HttpResult is now a sealed interface implemented directly by HttpError and HttpSuccess, eliminating the Either typealias and all Either.Left/Right wrapping. Also addresses PR review comments: - Fix CancellationException swallowing in deserializeErrorBody - Use HTTP_ERROR_SUBTYPES loop instead of manual status code listing - Remove unused RAISE, RAISE_FUN, EITHER from Names.kt Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/avsystem/justworks/core/gen/Names.kt | 7 +- .../core/gen/shared/ApiClientBaseGenerator.kt | 85 ++++--------------- .../core/gen/shared/ApiResponseGenerator.kt | 51 +++++------ .../core/gen/ApiClientBaseGeneratorTest.kt | 4 +- .../core/gen/ApiResponseGeneratorTest.kt | 44 ++++++---- .../gradle/JustworksPluginFunctionalTest.kt | 4 +- 6 files changed, 75 insertions(+), 120 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 0ab6e8d..8e690c5 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 @@ -81,14 +81,9 @@ val UUID_TYPE = ClassName("kotlin.uuid", "Uuid") val EXPERIMENTAL_UUID_API = ClassName("kotlin.uuid", "ExperimentalUuidApi") // ============================================================================ -// Error Handling (Arrow + Kotlin stdlib) +// Error Handling // ============================================================================ -val RAISE = ClassName("arrow.core.raise", "Raise") -val RAISE_FUN = MemberName("arrow.core.raise.context", "raise") - -val EITHER = ClassName("arrow.core", "Either") - val HTTP_ERROR = ClassName("com.avsystem.justworks", "HttpError") val HTTP_SUCCESS = ClassName("com.avsystem.justworks", "HttpSuccess") val HTTP_RESULT = ClassName("com.avsystem.justworks", "HttpResult") 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 8786cf1..308c318 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 @@ -10,7 +10,6 @@ import com.avsystem.justworks.core.gen.CLOSEABLE import com.avsystem.justworks.core.gen.CONTENT_NEGOTIATION import com.avsystem.justworks.core.gen.CREATE_HTTP_CLIENT import com.avsystem.justworks.core.gen.DESERIALIZE_ERROR_BODY_FUN -import com.avsystem.justworks.core.gen.EITHER 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 @@ -31,6 +30,7 @@ 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 +import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.ExperimentalKotlinPoetApi import com.squareup.kotlinpoet.FileSpec @@ -94,7 +94,8 @@ internal object ApiClientBaseGenerator { .returns(TypeVariableName("E").copy(nullable = true)) .beginControlFlow("return try") .addStatement("%M()", BODY_FUN) - .nextControlFlow("catch (_: %T)", Exception::class) + .nextControlFlow("catch (e: %T)", Exception::class) + .addStatement("if (e is %T) throw e", ClassName("kotlinx.coroutines", "CancellationException")) .addStatement("null") .endControlFlow() .build() @@ -110,73 +111,19 @@ internal object ApiClientBaseGenerator { .returns(HTTP_RESULT.parameterizedBy(TypeVariableName("E"), TypeVariableName("T"))) .beginControlFlow("return when (status.value)") .addStatement( - "in 200..299 -> %T.Right(%T(status.value, %L()))", - EITHER, + "in 200..299 -> %T(status.value, %L())", HTTP_SUCCESS, SUCCESS_BODY, - ).addStatement( - "400 -> %T.Left(%T.BadRequest(%M()))", - EITHER, - HTTP_ERROR, - DESERIALIZE_ERROR_BODY_FUN, - ).addStatement( - "401 -> %T.Left(%T.Unauthorized(%M()))", - EITHER, - HTTP_ERROR, - DESERIALIZE_ERROR_BODY_FUN, - ).addStatement( - "403 -> %T.Left(%T.Forbidden(%M()))", - EITHER, - HTTP_ERROR, - DESERIALIZE_ERROR_BODY_FUN, - ).addStatement( - "404 -> %T.Left(%T.NotFound(%M()))", - EITHER, - HTTP_ERROR, - DESERIALIZE_ERROR_BODY_FUN, - ).addStatement( - "405 -> %T.Left(%T.MethodNotAllowed(%M()))", - EITHER, - HTTP_ERROR, - DESERIALIZE_ERROR_BODY_FUN, - ).addStatement( - "409 -> %T.Left(%T.Conflict(%M()))", - EITHER, - HTTP_ERROR, - DESERIALIZE_ERROR_BODY_FUN, - ).addStatement( - "410 -> %T.Left(%T.Gone(%M()))", - EITHER, - HTTP_ERROR, - DESERIALIZE_ERROR_BODY_FUN, - ).addStatement( - "422 -> %T.Left(%T.UnprocessableEntity(%M()))", - EITHER, - HTTP_ERROR, - DESERIALIZE_ERROR_BODY_FUN, - ).addStatement( - "429 -> %T.Left(%T.TooManyRequests(%M()))", - EITHER, - HTTP_ERROR, - DESERIALIZE_ERROR_BODY_FUN, - ).addStatement( - "500 -> %T.Left(%T.InternalServerError(%M()))", - EITHER, - HTTP_ERROR, - DESERIALIZE_ERROR_BODY_FUN, - ).addStatement( - "502 -> %T.Left(%T.BadGateway(%M()))", - EITHER, - HTTP_ERROR, - DESERIALIZE_ERROR_BODY_FUN, - ).addStatement( - "503 -> %T.Left(%T.ServiceUnavailable(%M()))", - EITHER, - HTTP_ERROR, - DESERIALIZE_ERROR_BODY_FUN, - ).addStatement( - "else -> %T.Left(%T.Other(status.value, %M()))", - EITHER, + ).apply { + for ((name, code) in ApiResponseGenerator.HTTP_ERROR_SUBTYPES) { + addStatement( + "$code -> %T.$name(%M())", + HTTP_ERROR, + DESERIALIZE_ERROR_BODY_FUN, + ) + } + }.addStatement( + "else -> %T.Other(status.value, %M())", HTTP_ERROR, DESERIALIZE_ERROR_BODY_FUN, ).endControlFlow() @@ -373,9 +320,9 @@ internal object ApiClientBaseGenerator { .beginControlFlow("return try") .addStatement("%L()", BLOCK) .nextControlFlow("catch (e: %T)", IO_EXCEPTION) - .addStatement("%T.Left(%T.Network(e))", EITHER, HTTP_ERROR) + .addStatement("%T.Network(e)", HTTP_ERROR) .nextControlFlow("catch (e: %T)", HTTP_REQUEST_TIMEOUT_EXCEPTION) - .addStatement("%T.Left(%T.Network(e))", EITHER, HTTP_ERROR) + .addStatement("%T.Network(e)", HTTP_ERROR) .endControlFlow() .build() } 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 e8b5a21..5962dde 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 @@ -1,7 +1,6 @@ package com.avsystem.justworks.core.gen.shared import com.avsystem.justworks.core.gen.BODY -import com.avsystem.justworks.core.gen.EITHER import com.avsystem.justworks.core.gen.HTTP_ERROR import com.avsystem.justworks.core.gen.HTTP_RESULT import com.avsystem.justworks.core.gen.HTTP_SUCCESS @@ -14,20 +13,19 @@ import com.squareup.kotlinpoet.ParameterSpec import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.PropertySpec import com.squareup.kotlinpoet.THROWABLE -import com.squareup.kotlinpoet.TypeAliasSpec import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.TypeVariableName /** * Generates [FileSpec]s containing: + * - `HttpResult` sealed interface for typed API responses * - `HttpError` sealed class hierarchy with predefined HTTP error subtypes - * - `HttpResult` typealias as `Either, HttpSuccess>` * - `HttpSuccess` data class wrapping successful responses */ internal object ApiResponseGenerator { private const val CODE = "code" - private val HTTP_ERROR_SUBTYPES = listOf( + internal val HTTP_ERROR_SUBTYPES = listOf( "BadRequest" to 400, "Unauthorized" to 401, "Forbidden" to 403, @@ -42,7 +40,24 @@ internal object ApiResponseGenerator { "ServiceUnavailable" to 503, ) - fun generate(): List = listOf(generateHttpError(), generateHttpSuccess()) + fun generate(): List = listOf(generateHttpResult(), generateHttpError(), generateHttpSuccess()) + + fun generateHttpResult(): FileSpec { + val e = TypeVariableName("E", variance = KModifier.OUT) + val t = TypeVariableName("T", variance = KModifier.OUT) + + val sealedInterface = TypeSpec + .interfaceBuilder(HTTP_RESULT) + .addModifiers(KModifier.SEALED) + .addTypeVariable(e) + .addTypeVariable(t) + .build() + + return FileSpec + .builder(HTTP_RESULT) + .addType(sealedInterface) + .build() + } fun generateHttpError(): FileSpec { val b = TypeVariableName("B", variance = KModifier.OUT) @@ -51,6 +66,7 @@ internal object ApiResponseGenerator { .classBuilder(HTTP_ERROR) .addModifiers(KModifier.SEALED) .addTypeVariable(b) + .addSuperinterface(HTTP_RESULT.parameterizedBy(b, NOTHING)) .addProperty( PropertySpec .builder(CODE, INT) @@ -74,24 +90,9 @@ internal object ApiResponseGenerator { // Network: no type variable, extends HttpError sealedClass.addType(buildNetworkSubtype()) - // HttpResult typealias - val e = TypeVariableName("E") - val t = TypeVariableName("T") - val httpResultAlias = TypeAliasSpec - .builder( - "HttpResult", - EITHER.parameterizedBy( - HTTP_ERROR.parameterizedBy(e), - HTTP_SUCCESS.parameterizedBy(t), - ), - ).addTypeVariable(e) - .addTypeVariable(t) - .build() - return FileSpec .builder(HTTP_ERROR) .addType(sealedClass.build()) - .addTypeAlias(httpResultAlias) .build() } @@ -105,11 +106,11 @@ internal object ApiResponseGenerator { .primaryConstructor( FunSpec .constructorBuilder() - .addParameter(BODY, b) + .addParameter(BODY, b.copy(nullable = true)) .build(), ).addProperty( PropertySpec - .builder(BODY, b) + .builder(BODY, b.copy(nullable = true)) .initializer(BODY) .addModifiers(KModifier.OVERRIDE) .build(), @@ -137,7 +138,7 @@ internal object ApiResponseGenerator { FunSpec .constructorBuilder() .addParameter(CODE, INT) - .addParameter(BODY, b) + .addParameter(BODY, b.copy(nullable = true)) .build(), ).addProperty( PropertySpec @@ -147,7 +148,7 @@ internal object ApiResponseGenerator { .build(), ).addProperty( PropertySpec - .builder(BODY, b) + .builder(BODY, b.copy(nullable = true)) .initializer(BODY) .addModifiers(KModifier.OVERRIDE) .build(), @@ -194,7 +195,6 @@ internal object ApiResponseGenerator { ).build(), ).build() - fun generateHttpSuccess(): FileSpec { val t = TypeVariableName("T") @@ -208,6 +208,7 @@ internal object ApiResponseGenerator { .classBuilder(HTTP_SUCCESS) .addModifiers(KModifier.DATA) .addTypeVariable(t) + .addSuperinterface(HTTP_RESULT.parameterizedBy(NOTHING, t)) .primaryConstructor(primaryConstructor) .addProperty(PropertySpec.builder(CODE, INT).initializer(CODE).build()) .addProperty(PropertySpec.builder(BODY, t).initializer(BODY).build()) 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 ae72113..a6c6d6b 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 @@ -96,7 +96,6 @@ class ApiClientBaseGeneratorTest { val body = safeCall.body.toString() assertTrue(body.contains("IOException"), "Expected IOException catch") assertTrue(body.contains("HttpRequestTimeoutException"), "Expected HttpRequestTimeoutException catch") - assertTrue(body.contains("Either.Left"), "Expected Either.Left in body") assertTrue(body.contains("HttpError.Network"), "Expected HttpError.Network in body") } @@ -168,8 +167,7 @@ class ApiClientBaseGeneratorTest { assertTrue(body.contains("HttpError.NotFound"), "Expected HttpError.NotFound") assertTrue(body.contains("HttpError.InternalServerError"), "Expected HttpError.InternalServerError") assertTrue(body.contains("HttpError.Other"), "Expected HttpError.Other catchall") - assertTrue(body.contains("Either.Right"), "Expected Either.Right for success") - assertTrue(body.contains("Either.Left"), "Expected Either.Left for errors") + assertTrue(body.contains("HttpSuccess"), "Expected HttpSuccess for success") } @Test 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 c6ff87d..f886dfc 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 @@ -2,7 +2,6 @@ package com.avsystem.justworks.core.gen import com.avsystem.justworks.core.gen.shared.ApiResponseGenerator import com.squareup.kotlinpoet.KModifier -import com.squareup.kotlinpoet.TypeAliasSpec import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.TypeVariableName import kotlin.test.Test @@ -13,12 +12,13 @@ import kotlin.test.assertTrue class ApiResponseGeneratorTest { private val files = ApiResponseGenerator.generate() private val httpErrorFile = files.first { it.name == "HttpError" } + private val httpResultFile = files.first { it.name == "HttpResult" } private fun httpErrorClass(): TypeSpec = httpErrorFile.members.filterIsInstance().first { it.name == "HttpError" } - private fun httpResultAlias(): TypeAliasSpec = - httpErrorFile.members.filterIsInstance().first { it.name == "HttpResult" } + private fun httpResultInterface(): TypeSpec = + httpResultFile.members.filterIsInstance().first { it.name == "HttpResult" } private fun successClass(): TypeSpec { val successFile = files.first { it.name == "HttpSuccess" } @@ -144,15 +144,29 @@ class ApiResponseGeneratorTest { } @Test - fun `HttpResult typealias is generated`() { - val alias = httpResultAlias() - assertEquals("HttpResult", alias.name) - assertEquals(2, alias.typeVariables.size) - assertEquals("E", alias.typeVariables[0].name) - assertEquals("T", alias.typeVariables[1].name) - assertTrue(alias.type.toString().contains("Either"), "Should reference Either") - assertTrue(alias.type.toString().contains("HttpError"), "Should reference HttpError") - assertTrue(alias.type.toString().contains("HttpSuccess"), "Should reference HttpSuccess") + fun `HttpResult is a sealed interface with E and T type variables`() { + val typeSpec = httpResultInterface() + assertEquals("HttpResult", typeSpec.name) + assertTrue(KModifier.SEALED in typeSpec.modifiers, "Expected SEALED modifier") + assertEquals(2, typeSpec.typeVariables.size) + assertEquals("E", typeSpec.typeVariables[0].name) + assertTrue(typeSpec.typeVariables[0].variance == KModifier.OUT, "Expected OUT variance on E") + assertEquals("T", typeSpec.typeVariables[1].name) + assertTrue(typeSpec.typeVariables[1].variance == KModifier.OUT, "Expected OUT variance on T") + } + + @Test + fun `HttpError implements HttpResult`() { + val typeSpec = httpErrorClass() + val superinterfaces = typeSpec.superinterfaces.keys.map { it.toString() } + assertTrue(superinterfaces.any { it.contains("HttpResult") }, "HttpError should implement HttpResult") + } + + @Test + fun `HttpSuccess implements HttpResult`() { + val typeSpec = successClass() + val superinterfaces = typeSpec.superinterfaces.keys.map { it.toString() } + assertTrue(superinterfaces.any { it.contains("HttpResult") }, "HttpSuccess should implement HttpResult") } @Test @@ -180,9 +194,9 @@ class ApiResponseGeneratorTest { } @Test - fun `generates two files`() { - assertEquals(2, files.size) + fun `generates three files`() { + assertEquals(3, files.size) val fileNames = files.map { it.name }.sorted() - assertEquals(listOf("HttpError", "HttpSuccess"), fileNames) + assertEquals(listOf("HttpError", "HttpResult", "HttpSuccess"), fileNames) } } 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 6347cf9..a543a48 100644 --- a/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt +++ b/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt @@ -102,7 +102,7 @@ class JustworksPluginFunctionalTest { 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 { @@ -555,7 +555,7 @@ class JustworksPluginFunctionalTest { 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 { From 2d076c406c8e074d9e05fc6fe2bb995e0b2f8f8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 8 Apr 2026 11:09:36 +0200 Subject: [PATCH 15/17] docs: update README for sealed HttpResult/HttpError API Remove Arrow references, document typed error subtypes, rewrite Making Requests and Error Handling sections with pattern matching examples. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 110 ++++++++++++++++++++++++++---------------------------- 1 file changed, 53 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 4dad22e..0d87072 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,8 @@ build/generated/justworks/ ├── shared/kotlin/ │ └── com/avsystem/justworks/ │ ├── ApiClientBase.kt # Abstract base class + helper extensions -│ ├── HttpError.kt # HttpErrorType enum + HttpError data class +│ ├── HttpResult.kt # HttpResult sealed interface +│ ├── HttpError.kt # HttpError sealed class hierarchy │ └── HttpSuccess.kt # HttpSuccess data class │ └── specName/ @@ -136,8 +137,7 @@ build/generated/justworks/ ├── model/ │ ├── Pet.kt # @Serializable data class │ ├── PetStatus.kt # @Serializable enum class - │ ├── Shape.kt # sealed interface (oneOf/anyOf) - │ ├── Circle.kt # variant data class : Shape + │ ├── Shape.kt # sealed interface + nested variants (oneOf/anyOf) │ ├── UuidSerializer.kt # (if spec uses UUID fields) │ └── SerializersModule.kt # (if spec has polymorphic types) └── api/ @@ -148,7 +148,7 @@ build/generated/justworks/ - **Data classes** -- one per named schema. Properties annotated with `@SerialName`, sorted required-first. - **Enums** -- constants in `UPPER_SNAKE_CASE` with `@SerialName` for the wire value. -- **Sealed interfaces** -- for `oneOf`/`anyOf` schemas. Variants are separate data classes implementing the interface. +- **Sealed interfaces** -- for `oneOf`/`anyOf` schemas. Discriminated variants are nested inside the sealed interface file. - **SerializersModule** -- top-level `val generatedSerializersModule` registering all polymorphic hierarchies. Only generated when needed. @@ -156,7 +156,8 @@ build/generated/justworks/ One client class per OpenAPI tag (e.g. `pets` tag -> `PetsApi`). Untagged endpoints go to `DefaultApi`. -Each endpoint becomes a `suspend` function with `context(Raise)` that returns `HttpSuccess`. +Each endpoint becomes a `suspend` function that returns `HttpResult` -- a sealed interface implemented by +`HttpError` (for failures) and `HttpSuccess` (for successes). No Arrow or other external runtime dependencies are required. ### Gradle Tasks @@ -221,22 +222,15 @@ 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") } ``` @@ -269,15 +263,20 @@ The client implements `Closeable` -- call `client.close()` when done to release ### 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 `HttpResult`: ```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 +val result: HttpResult> = client.listPets(limit = 10) + +when (result) { + is HttpSuccess -> { + println(result.body) // the deserialized response body + println(result.code) // the HTTP status code + } + is HttpError -> { + println("Error ${result.code}: ${result.body}") + } +} ``` Path, query, and header parameters map to function arguments. Optional parameters default to `null`: @@ -288,48 +287,45 @@ 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>`: +`HttpResult` is a sealed interface with two branches: + +- `HttpSuccess` -- successful response (2xx) with a deserialized body +- `HttpError` -- sealed class hierarchy for all error cases + +`HttpError` provides typed subtypes for common HTTP error codes: + +| Subtype | HTTP status | Description | +|--------------------------------|-------------|-----------------------| +| `HttpError.BadRequest` | 400 | Bad request | +| `HttpError.Unauthorized` | 401 | Unauthorized | +| `HttpError.Forbidden` | 403 | Forbidden | +| `HttpError.NotFound` | 404 | Not found | +| `HttpError.MethodNotAllowed` | 405 | Method not allowed | +| `HttpError.Conflict` | 409 | Conflict | +| `HttpError.Gone` | 410 | Gone | +| `HttpError.UnprocessableEntity`| 422 | Unprocessable entity | +| `HttpError.TooManyRequests` | 429 | Too many requests | +| `HttpError.InternalServerError`| 500 | Internal server error | +| `HttpError.BadGateway` | 502 | Bad gateway | +| `HttpError.ServiceUnavailable` | 503 | Service unavailable | +| `HttpError.Other` | *any other* | Catchall with code | +| `HttpError.Network` | -- | I/O or timeout | + +Each error subtype carries a nullable `body: E?` with the deserialized error response (or `null` if deserialization +failed), plus an `code: Int` property. ```kotlin -val result: Either> = either { - client.getPet(petId = 123) +when (result) { + is HttpSuccess -> println("Pet: ${result.body.name}") + is HttpError.NotFound -> println("Pet not found") + is HttpError.Unauthorized -> println("Please log in") + is HttpError.Network -> println("Connection failed: ${result.cause}") + is HttpError -> println("HTTP ${result.code}: ${result.body}") } - -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}") - } -) ``` -`HttpError` is a data class with the following fields: - -| Field | Type | Description | -|-----------|-----------------|----------------------------------------------| -| `code` | `Int` | HTTP status code (or `0` for network errors) | -| `message` | `String` | Response body text or exception message | -| `type` | `HttpErrorType` | Category of the error | - -`HttpErrorType` categorizes errors: - -| `HttpErrorType` value | Covered statuses / scenario | -|-----------------------|------------------------------------| -| `Client` | HTTP 4xx client errors | -| `Server` | HTTP 5xx server errors | -| `Redirect` | HTTP 3xx redirect responses | -| `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. +Network errors (connection timeouts, DNS failures) are caught and reported as `HttpError.Network` instead of +propagating exceptions. ## Publishing From 933a0484efeb4af4c681a9bf0ab836b839c3f561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 8 Apr 2026 11:34:33 +0200 Subject: [PATCH 16/17] fix: add Redirect and missing HTTP error subtypes, clean up formatting Add HttpError.Redirect for 3xx responses and four new status code subtypes (RequestTimeout 408, PayloadTooLarge 413, UnsupportedMediaType 415, GatewayTimeout 504). Fix trailing blank lines in functional tests and double blank line in ClientGeneratorTest. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 37 +++++++++++-------- .../core/gen/shared/ApiClientBaseGenerator.kt | 4 ++ .../core/gen/shared/ApiResponseGenerator.kt | 13 +++++-- .../core/gen/ApiClientBaseGeneratorTest.kt | 9 ++++- .../core/gen/ApiResponseGeneratorTest.kt | 7 +++- .../justworks/core/gen/ClientGeneratorTest.kt | 1 - .../gradle/JustworksPluginFunctionalTest.kt | 2 - 7 files changed, 49 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 0d87072..4749683 100644 --- a/README.md +++ b/README.md @@ -294,22 +294,27 @@ val result = client.findPets(status = "available", limit = 20) `HttpError` provides typed subtypes for common HTTP error codes: -| Subtype | HTTP status | Description | -|--------------------------------|-------------|-----------------------| -| `HttpError.BadRequest` | 400 | Bad request | -| `HttpError.Unauthorized` | 401 | Unauthorized | -| `HttpError.Forbidden` | 403 | Forbidden | -| `HttpError.NotFound` | 404 | Not found | -| `HttpError.MethodNotAllowed` | 405 | Method not allowed | -| `HttpError.Conflict` | 409 | Conflict | -| `HttpError.Gone` | 410 | Gone | -| `HttpError.UnprocessableEntity`| 422 | Unprocessable entity | -| `HttpError.TooManyRequests` | 429 | Too many requests | -| `HttpError.InternalServerError`| 500 | Internal server error | -| `HttpError.BadGateway` | 502 | Bad gateway | -| `HttpError.ServiceUnavailable` | 503 | Service unavailable | -| `HttpError.Other` | *any other* | Catchall with code | -| `HttpError.Network` | -- | I/O or timeout | +| Subtype | HTTP status | Description | +|---------------------------------|-------------|------------------------| +| `HttpError.BadRequest` | 400 | Bad request | +| `HttpError.Unauthorized` | 401 | Unauthorized | +| `HttpError.Forbidden` | 403 | Forbidden | +| `HttpError.NotFound` | 404 | Not found | +| `HttpError.MethodNotAllowed` | 405 | Method not allowed | +| `HttpError.RequestTimeout` | 408 | Request timeout | +| `HttpError.Conflict` | 409 | Conflict | +| `HttpError.Gone` | 410 | Gone | +| `HttpError.PayloadTooLarge` | 413 | Payload too large | +| `HttpError.UnsupportedMediaType`| 415 | Unsupported media type | +| `HttpError.UnprocessableEntity` | 422 | Unprocessable entity | +| `HttpError.TooManyRequests` | 429 | Too many requests | +| `HttpError.InternalServerError` | 500 | Internal server error | +| `HttpError.BadGateway` | 502 | Bad gateway | +| `HttpError.ServiceUnavailable` | 503 | Service unavailable | +| `HttpError.GatewayTimeout` | 504 | Gateway timeout | +| `HttpError.Redirect` | 3xx | Redirect | +| `HttpError.Other` | *any other* | Catchall with code | +| `HttpError.Network` | -- | I/O or timeout | Each error subtype carries a nullable `body: E?` with the deserialized error response (or `null` if deserialization failed), plus an `code: Int` property. 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 622328f..42e7a59 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 @@ -112,6 +112,10 @@ internal object ApiClientBaseGenerator { "in 200..299 -> %T(status.value, %L())", HTTP_SUCCESS, SUCCESS_BODY, + ).addStatement( + "in 300..399 -> %T.Redirect(status.value, %M())", + HTTP_ERROR, + DESERIALIZE_ERROR_BODY_FUN, ).apply { for ((name, code) in ApiResponseGenerator.HTTP_ERROR_SUBTYPES) { addStatement( 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 5962dde..be2fc66 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 @@ -31,13 +31,17 @@ internal object ApiResponseGenerator { "Forbidden" to 403, "NotFound" to 404, "MethodNotAllowed" to 405, + "RequestTimeout" to 408, "Conflict" to 409, "Gone" to 410, + "PayloadTooLarge" to 413, + "UnsupportedMediaType" to 415, "UnprocessableEntity" to 422, "TooManyRequests" to 429, "InternalServerError" to 500, "BadGateway" to 502, "ServiceUnavailable" to 503, + "GatewayTimeout" to 504, ) fun generate(): List = listOf(generateHttpResult(), generateHttpError(), generateHttpSuccess()) @@ -84,8 +88,11 @@ internal object ApiResponseGenerator { sealedClass.addType(buildBodySubtype(name, statusCode)) } + // Redirect: 3xx range, both code and body in constructor + sealedClass.addType(buildRangeSubtype("Redirect")) + // Other: both code and body in constructor - sealedClass.addType(buildOtherSubtype()) + sealedClass.addType(buildRangeSubtype("Other")) // Network: no type variable, extends HttpError sealedClass.addType(buildNetworkSubtype()) @@ -127,10 +134,10 @@ internal object ApiResponseGenerator { ).build() } - private fun buildOtherSubtype(): TypeSpec { + private fun buildRangeSubtype(name: String): TypeSpec { val b = TypeVariableName("B", variance = KModifier.OUT) return TypeSpec - .classBuilder("Other") + .classBuilder(name) .addModifiers(KModifier.DATA) .addTypeVariable(b) .superclass(HTTP_ERROR.parameterizedBy(b)) 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 5eff6d2..b6d2129 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 @@ -153,23 +153,30 @@ class ApiClientBaseGeneratorTest { fun `mapToResult branches on specific status codes`() { val fn = topLevelFun("mapToResult") val body = fn.body.toString() + assertTrue(body.contains("in 200..299"), "Expected 2xx success range") + assertTrue(body.contains("HttpSuccess"), "Expected HttpSuccess for success") + assertTrue(body.contains("in 300..399"), "Expected 3xx redirect range") + assertTrue(body.contains("HttpError.Redirect"), "Expected HttpError.Redirect") assertTrue(body.contains("400 ->"), "Expected 400 branch") assertTrue(body.contains("401 ->"), "Expected 401 branch") assertTrue(body.contains("403 ->"), "Expected 403 branch") assertTrue(body.contains("404 ->"), "Expected 404 branch") assertTrue(body.contains("405 ->"), "Expected 405 branch") + assertTrue(body.contains("408 ->"), "Expected 408 branch") assertTrue(body.contains("409 ->"), "Expected 409 branch") assertTrue(body.contains("410 ->"), "Expected 410 branch") + assertTrue(body.contains("413 ->"), "Expected 413 branch") + assertTrue(body.contains("415 ->"), "Expected 415 branch") assertTrue(body.contains("422 ->"), "Expected 422 branch") assertTrue(body.contains("429 ->"), "Expected 429 branch") assertTrue(body.contains("500 ->"), "Expected 500 branch") assertTrue(body.contains("502 ->"), "Expected 502 branch") assertTrue(body.contains("503 ->"), "Expected 503 branch") + assertTrue(body.contains("504 ->"), "Expected 504 branch") assertTrue(body.contains("HttpError.BadRequest"), "Expected HttpError.BadRequest") assertTrue(body.contains("HttpError.NotFound"), "Expected HttpError.NotFound") assertTrue(body.contains("HttpError.InternalServerError"), "Expected HttpError.InternalServerError") assertTrue(body.contains("HttpError.Other"), "Expected HttpError.Other catchall") - assertTrue(body.contains("HttpSuccess"), "Expected HttpSuccess for success") } @Test 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 f886dfc..eb06d24 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 @@ -63,19 +63,24 @@ class ApiResponseGeneratorTest { "BadRequest", "Conflict", "Forbidden", + "GatewayTimeout", "Gone", "InternalServerError", "MethodNotAllowed", "Network", "NotFound", "Other", + "PayloadTooLarge", + "Redirect", + "RequestTimeout", "ServiceUnavailable", "TooManyRequests", "Unauthorized", "UnprocessableEntity", + "UnsupportedMediaType", ) assertEquals(expected, subtypeNames) - assertEquals(14, subtypeNames.size) + assertEquals(19, subtypeNames.size) } @Test 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 e025b2d..82cb724 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 @@ -826,7 +826,6 @@ class ClientGeneratorTest { ) } - @Test fun `single Bearer scheme uses token param name as shorthand`() { 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 2844a0c..3639c66 100644 --- a/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt +++ b/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt @@ -102,7 +102,6 @@ class JustworksPluginFunctionalTest { 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") - } kotlin { @@ -555,7 +554,6 @@ class JustworksPluginFunctionalTest { 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") - } kotlin { From 8f407ae1b5e6f69c5c5fdf1a4eff71451b7d4737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 9 Apr 2026 11:53:27 +0200 Subject: [PATCH 17/17] chore: remove unused BSP config and `bodyAsText` import from Names.kt --- .bsp/scala-cli.json | 20 ------------------- .../com/avsystem/justworks/core/gen/Names.kt | 1 - 2 files changed, 21 deletions(-) delete mode 100644 .bsp/scala-cli.json diff --git a/.bsp/scala-cli.json b/.bsp/scala-cli.json deleted file mode 100644 index 7f70e00..0000000 --- a/.bsp/scala-cli.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "scala-cli", - "argv": [ - "/opt/homebrew/bin/scala-cli", - "bsp", - "--json-options", - "/Users/bkozak/IdeaProjects/justworks/.scala-build/ide-options-v2.json", - "--json-launcher-options", - "/Users/bkozak/IdeaProjects/justworks/.scala-build/ide-launcher-options.json", - "--envs-file", - "/Users/bkozak/IdeaProjects/justworks/.scala-build/ide-envs.json", - "/Users/bkozak/IdeaProjects/justworks" - ], - "version": "1.12.0", - "bspVersion": "2.1.1", - "languages": [ - "scala", - "java" - ] -} \ No newline at end of file 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 8e690c5..a2a3b24 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 @@ -13,7 +13,6 @@ val HTTP_HEADERS = ClassName("io.ktor.http", "HttpHeaders") val JSON_FUN = MemberName("io.ktor.serialization.kotlinx.json", "json") val BODY_FUN = MemberName("io.ktor.client.call", "body") -val BODY_AS_TEXT_FUN = MemberName("io.ktor.client.statement", "bodyAsText") val SET_BODY_FUN = MemberName("io.ktor.client.request", "setBody") val CONTENT_TYPE_FUN = MemberName("io.ktor.http", "contentType") val CONTENT_TYPE_APPLICATION = ClassName("io.ktor.http", "ContentType", "Application")