From b1110c14bf0c61854b6136b8e895d10c276095ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 25 Mar 2026 13:54:34 +0100 Subject: [PATCH 01/11] feat: add KDoc generation for properties, enum constants, and endpoints - Add description field to Endpoint, valueDescriptions to EnumModel - Parser extracts operation.description and x-enum-descriptions - ModelGenerator adds KDoc to properties and enum constants - ClientGenerator adds KDoc with summary/description/@param/@return - Tests verify all KDoc generation scenarios Co-Authored-By: Claude Opus 4.6 (1M context) --- .../justworks/core/gen/ClientGenerator.kt | 27 +++++ .../justworks/core/gen/ModelGenerator.kt | 2 + .../avsystem/justworks/core/model/ApiSpec.kt | 2 + .../justworks/core/parser/SpecParser.kt | 22 +++- .../justworks/core/gen/ClientGeneratorTest.kt | 75 +++++++++++- .../justworks/core/gen/ModelGeneratorTest.kt | 107 ++++++++++++++++++ 6 files changed, 228 insertions(+), 7 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..fd2affd 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 @@ -16,6 +16,7 @@ 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 import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.PropertySpec import com.squareup.kotlinpoet.STRING @@ -114,11 +115,37 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: ) } + val kdocLines = buildList { + endpoint.summary?.let { add(it) } + endpoint.description?.let { + if (isNotEmpty()) add("") + add(it) + } + val paramDocs = endpoint.parameters.filter { it.description != null } + if (paramDocs.isNotEmpty() && isNotEmpty()) add("") + paramDocs.forEach { param -> + add("@param ${param.name.toCamelCase()} ${param.description}") + } + if (returnBodyType != UNIT) { + if (isNotEmpty()) add("") + add("@return [HttpSuccess] containing [${returnBodyType.simpleTypeName()}] on success") + } + } + if (kdocLines.isNotEmpty()) { + funBuilder.addKdoc("%L", kdocLines.joinToString("\n")) + } + funBuilder.addCode(buildFunctionBody(endpoint, params, returnBodyType)) return funBuilder.build() } + private fun TypeName.simpleTypeName(): String = when (this) { + is ClassName -> simpleName + is ParameterizedTypeName -> rawType.simpleName + else -> toString() + } + private fun buildNullableParameter( typeRef: TypeRef, name: String, diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt index 6f0c950..8307ee7 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt @@ -325,6 +325,7 @@ class ModelGenerator(private val modelPackage: String) { .builder(kotlinName, type) .initializer(kotlinName) .addAnnotation(AnnotationSpec.builder(SERIAL_NAME).addMember("%S", prop.name).build()) + .apply { if (prop.description != null) addKdoc("%L", prop.description) } propBuilder.build() } @@ -433,6 +434,7 @@ class ModelGenerator(private val modelPackage: String) { val anonymousClass = TypeSpec .anonymousClassBuilder() .addAnnotation(AnnotationSpec.builder(SERIAL_NAME).addMember("%S", value).build()) + .apply { enum.valueDescriptions[value]?.let { addKdoc("%L", it) } } .build() typeSpec.addEnumConstant(value.toEnumConstantName(), anonymousClass) } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt b/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt index fdcc056..85ae44e 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 @@ -20,6 +20,7 @@ data class Endpoint( val method: HttpMethod, val operationId: String, val summary: String?, + val description: String? = null, val tags: List, val parameters: List, val requestBody: RequestBody?, @@ -96,6 +97,7 @@ data class EnumModel( val description: String?, val type: EnumBackingType, val values: List, + val valueDescriptions: Map = emptyMap(), ) enum class EnumBackingType { 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 38ecf01..b1864d3 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 @@ -190,6 +190,7 @@ object SpecParser { method = method, operationId = operationId, summary = operation.summary, + description = operation.description, tags = operation.tags.orEmpty(), parameters = mergedParams, requestBody = requestBody, @@ -261,12 +262,21 @@ object SpecParser { ) } - 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<*> -> enumValues.zip(ext.map { it.toString() }).toMap() + is Map<*, *> -> ext.entries.associate { (k, v) -> k.toString() to v.toString() } + else -> emptyMap() + } + return EnumModel( + name = name, + description = schema.description, + type = EnumBackingType.parse(schema.type) ?: EnumBackingType.STRING, + values = enumValues, + valueDescriptions = valueDescriptions, + ) + } // --- allOf property merging --- 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..de5228d 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 @@ -35,6 +35,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, @@ -46,7 +48,8 @@ class ClientGeneratorTest { path = path, method = method, operationId = operationId, - summary = null, + summary = summary, + description = description, tags = tags, parameters = parameters, requestBody = requestBody, @@ -425,4 +428,74 @@ class ClientGeneratorTest { val body = funSpec.body.toString() assertTrue(body.contains("toEmptyResult"), "Expected toEmptyResult call") } + + // -- DOCS-03: Endpoint KDoc generation -- + + @Test + fun `endpoint with summary generates KDoc`() { + val ep = endpoint(summary = "List all pets") + val cls = clientClass(listOf(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(listOf(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(listOf(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(listOf(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(listOf(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/ModelGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorTest.kt index 9507b32..cef07bf 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 @@ -1398,4 +1398,111 @@ class ModelGeneratorTest { 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 = generator.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 = generator.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("active", "inactive"), + valueDescriptions = mapOf("active" to "Currently active", "inactive" to "Not active"), + ) + val files = generator.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("active", "inactive"), + ) + val files = generator.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}", + ) + } } From 1e595bb6da84fe574c3ddada3c4ec511755dfc39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 25 Mar 2026 14:49:14 +0100 Subject: [PATCH 02/11] refactor: replace string-based enums with EnumModel.Value objects - Update EnumModel to use structured `Value` objects instead of raw strings. - Adjust ModelGenerator and SpecParser for new `Value` format. - Refactor related tests to validate `Value` usage and associated descriptions. --- .../com/avsystem/justworks/core/gen/ModelGenerator.kt | 8 ++++---- .../com/avsystem/justworks/core/model/ApiSpec.kt | 7 ++++--- .../com/avsystem/justworks/core/parser/SpecParser.kt | 3 +-- .../avsystem/justworks/core/gen/ModelGeneratorTest.kt | 11 +++++------ .../avsystem/justworks/core/parser/SpecParserTest.kt | 3 ++- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt index 8307ee7..b6d3001 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt @@ -325,7 +325,7 @@ class ModelGenerator(private val modelPackage: String) { .builder(kotlinName, type) .initializer(kotlinName) .addAnnotation(AnnotationSpec.builder(SERIAL_NAME).addMember("%S", prop.name).build()) - .apply { if (prop.description != null) addKdoc("%L", prop.description) } + .apply { prop.description?.let { addKdoc("%L", it) } } propBuilder.build() } @@ -433,10 +433,10 @@ class ModelGenerator(private val modelPackage: String) { enum.values.forEach { value -> val anonymousClass = TypeSpec .anonymousClassBuilder() - .addAnnotation(AnnotationSpec.builder(SERIAL_NAME).addMember("%S", value).build()) - .apply { enum.valueDescriptions[value]?.let { addKdoc("%L", it) } } + .addAnnotation(AnnotationSpec.builder(SERIAL_NAME).addMember("%S", value.name).build()) + .apply { value.description?.let { addKdoc("%L", it) } } .build() - typeSpec.addEnumConstant(value.toEnumConstantName(), anonymousClass) + typeSpec.addEnumConstant(value.name.toEnumConstantName(), anonymousClass) } if (enum.description != null) { 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 85ae44e..21d6878 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 @@ -96,9 +96,10 @@ data class EnumModel( val name: String, val description: String?, val type: EnumBackingType, - val values: List, - val valueDescriptions: Map = emptyMap(), -) + val values: List, +) { + data class Value(val name: String, val description: String? = null) +} enum class EnumBackingType { STRING, diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt index b1864d3..08afc6b 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 @@ -273,8 +273,7 @@ object SpecParser { name = name, description = schema.description, type = EnumBackingType.parse(schema.type) ?: EnumBackingType.STRING, - values = enumValues, - valueDescriptions = valueDescriptions, + values = enumValues.map { EnumModel.Value(it, valueDescriptions[it]) }, ) } 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 cef07bf..a852cfb 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 @@ -182,7 +182,7 @@ 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 @@ -243,7 +243,7 @@ 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[0].members.filterIsInstance()[0] @@ -588,7 +588,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( @@ -1461,8 +1461,7 @@ class ModelGeneratorTest { name = "Status", description = null, type = EnumBackingType.STRING, - values = listOf("active", "inactive"), - valueDescriptions = mapOf("active" to "Currently active", "inactive" to "Not active"), + values = listOf(EnumModel.Value("active", "Currently active"), EnumModel.Value("inactive", "Not active")), ) val files = generator.generate(spec(enums = listOf(enum))) val typeSpec = files @@ -1490,7 +1489,7 @@ class ModelGeneratorTest { name = "Status", description = null, type = EnumBackingType.STRING, - values = listOf("active", "inactive"), + values = listOf(EnumModel.Value("active"), EnumModel.Value("inactive")), ) val files = generator.generate(spec(enums = listOf(enum))) val typeSpec = files diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt index 8b66c06..de68182 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 @@ -2,6 +2,7 @@ package com.avsystem.justworks.core.parser import com.avsystem.justworks.core.model.ApiSpec 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 @@ -53,7 +54,7 @@ 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 From 2a5b3bca12b2c7e927ad43b24784476e9002671c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 25 Mar 2026 15:08:21 +0100 Subject: [PATCH 03/11] refactor: remove unused import of ParameterizedTypeName from ClientGenerator --- .../kotlin/com/avsystem/justworks/core/gen/ClientGenerator.kt | 1 - 1 file changed, 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 fd2affd..1d4f948 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 @@ -16,7 +16,6 @@ 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 import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.PropertySpec import com.squareup.kotlinpoet.STRING From 6f0ef4fc7139df491038d2ec4c89fa42061530c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 25 Mar 2026 15:08:25 +0100 Subject: [PATCH 04/11] refactor: sanitize KDoc content for properties, enum constants, and endpoints - Introduced `sanitizeKdoc()` to clean input before adding to KDoc. - Updated ModelGenerator and ClientGenerator to apply sanitization. - Removed unused `simpleTypeName()` helper from Client --- .../justworks/core/gen/ClientGenerator.kt | 39 ++++++++----------- .../justworks/core/gen/ModelGenerator.kt | 6 +-- 2 files changed, 19 insertions(+), 26 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 1d4f948..bc0bf47 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 @@ -114,24 +114,23 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: ) } - val kdocLines = buildList { - endpoint.summary?.let { add(it) } - endpoint.description?.let { - if (isNotEmpty()) add("") - add(it) - } - val paramDocs = endpoint.parameters.filter { it.description != null } - if (paramDocs.isNotEmpty() && isNotEmpty()) add("") - paramDocs.forEach { param -> - add("@param ${param.name.toCamelCase()} ${param.description}") - } - if (returnBodyType != UNIT) { - if (isNotEmpty()) add("") - add("@return [HttpSuccess] containing [${returnBodyType.simpleTypeName()}] on success") - } + 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 (kdocLines.isNotEmpty()) { - funBuilder.addKdoc("%L", kdocLines.joinToString("\n")) + 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)) @@ -139,12 +138,6 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: return funBuilder.build() } - private fun TypeName.simpleTypeName(): String = when (this) { - is ClassName -> simpleName - is ParameterizedTypeName -> rawType.simpleName - else -> toString() - } - private fun buildNullableParameter( typeRef: TypeRef, name: String, diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt index b6d3001..cb5891b 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt @@ -325,7 +325,7 @@ class ModelGenerator(private val modelPackage: String) { .builder(kotlinName, type) .initializer(kotlinName) .addAnnotation(AnnotationSpec.builder(SERIAL_NAME).addMember("%S", prop.name).build()) - .apply { prop.description?.let { addKdoc("%L", it) } } + .apply { prop.description?.let { addKdoc("%L", it.sanitizeKdoc()) } } propBuilder.build() } @@ -434,13 +434,13 @@ class ModelGenerator(private val modelPackage: String) { val anonymousClass = TypeSpec .anonymousClassBuilder() .addAnnotation(AnnotationSpec.builder(SERIAL_NAME).addMember("%S", value.name).build()) - .apply { value.description?.let { addKdoc("%L", it) } } + .apply { value.description?.let { addKdoc("%L", it.sanitizeKdoc()) } } .build() typeSpec.addEnumConstant(value.name.toEnumConstantName(), anonymousClass) } if (enum.description != null) { - typeSpec.addKdoc("%L", enum.description) + typeSpec.addKdoc("%L", enum.description.sanitizeKdoc()) } return FileSpec From 11a4a32dc436d0c7e6d6bab510e0ded77b63d22a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 25 Mar 2026 15:08:30 +0100 Subject: [PATCH 05/11] feat: add `sanitizeKdoc()` to clean strings for safe inclusion in KDoc - Escapes comment terminators (`*/`, `/*`) to prevent broken Kotlin source generation. --- .../kotlin/com/avsystem/justworks/core/gen/NameUtils.kt | 7 +++++++ 1 file changed, 7 insertions(+) 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". From cb358d0ba92960a9623a2748c59c2c0a471286a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 25 Mar 2026 15:08:34 +0100 Subject: [PATCH 06/11] refactor: improve handling of schema extension mappings in SpecParser - Ensure key-value consistency when mapping `x-enum-descriptions` extensions. - Replace invalid map entries with `emptyMap` for --- .../com/avsystem/justworks/core/parser/SpecParser.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt index 08afc6b..3d194e8 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,6 +1,5 @@ package com.avsystem.justworks.core.parser -import arrow.core.compareTo import arrow.core.fold import arrow.core.merge import arrow.core.raise.context.Raise @@ -29,6 +28,7 @@ import io.swagger.v3.oas.models.media.Schema 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 /** @@ -265,10 +265,11 @@ object SpecParser { 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<*> -> enumValues.zip(ext.map { it.toString() }).toMap() - is Map<*, *> -> ext.entries.associate { (k, v) -> k.toString() to v.toString() } + 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() + return EnumModel( name = name, description = schema.description, From e017a3c2d9dda47d20bcf5214ad76720fd90a024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 26 Mar 2026 14:38:28 +0100 Subject: [PATCH 07/11] ktlint --- .../com/avsystem/justworks/core/parser/SpecParserTest.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 2c92db1..85344d9 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 @@ -54,7 +54,10 @@ class SpecParserTest : SpecParserTestBase() { val petStatus = petstore.enums.find { it.name == "PetStatus" } assertNotNull(petStatus, "PetStatus enum missing") assertEquals(EnumBackingType.STRING, petStatus.type) - assertEquals(listOf(EnumModel.Value("available"), EnumModel.Value("pending"), EnumModel.Value("sold")), petStatus.values) + assertEquals( + listOf(EnumModel.Value("available"), EnumModel.Value("pending"), EnumModel.Value("sold")), + petStatus.values, + ) } @Test From ce1b4ff9ca0108f48b5ca1ad2e516244591ab56a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Mon, 30 Mar 2026 17:32:44 +0200 Subject: [PATCH 08/11] merge: resolve conflicts from feat/content-types into docs/kdoc Port KDoc generation to refactored client/ClientGenerator.kt, preserve x-enum-descriptions support and EnumModel.Value in SpecParser, fix test compilation after merge. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/avsystem/justworks/core/Utils.kt | 5 + .../justworks/core/gen/CodeGenerator.kt | 13 +- .../com/avsystem/justworks/core/gen/Names.kt | 13 + .../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 | 237 ++++++++++ .../core/gen/{ => client}/ClientGenerator.kt | 146 ++---- .../core/gen/client/ParametersGenerator.kt | 59 +++ .../core/gen/{ => model}/ModelGenerator.kt | 103 +++- .../{ => shared}/ApiClientBaseGenerator.kt | 37 +- .../gen/{ => shared}/ApiResponseGenerator.kt | 28 +- .../SerializersModuleGenerator.kt | 23 +- .../avsystem/justworks/core/model/ApiSpec.kt | 21 +- .../justworks/core/parser/SpecParser.kt | 29 +- .../core/gen/ApiClientBaseGeneratorTest.kt | 1 + .../core/gen/ApiResponseGeneratorTest.kt | 1 + .../justworks/core/gen/ClientGeneratorTest.kt | 439 +++++++++++++----- .../justworks/core/gen/IntegrationTest.kt | 28 +- .../core/gen/ModelGeneratorPolymorphicTest.kt | 52 ++- .../justworks/core/gen/ModelGeneratorTest.kt | 138 +++--- .../gen/SerializersModuleGeneratorTest.kt | 19 +- .../justworks/core/gen/TypeMappingTest.kt | 43 +- .../justworks/core/parser/SpecParserTest.kt | 3 +- 24 files changed, 1117 insertions(+), 468 deletions(-) create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/Utils.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 rename core/src/main/kotlin/com/avsystem/justworks/core/gen/{ => client}/ClientGenerator.kt (54%) 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 (83%) rename core/src/main/kotlin/com/avsystem/justworks/core/gen/{ => shared}/ApiClientBaseGenerator.kt (82%) rename core/src/main/kotlin/com/avsystem/justworks/core/gen/{ => shared}/ApiResponseGenerator.kt (73%) rename core/src/main/kotlin/com/avsystem/justworks/core/gen/{ => shared}/SerializersModuleGenerator.kt (68%) 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/CodeGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt index 10c6715..aff30d7 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,10 @@ 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.gen.shared.SerializersModuleGenerator import com.avsystem.justworks.core.model.ApiSpec import java.io.File @@ -14,14 +19,14 @@ 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 modelFiles = ModelGenerator.generate(spec) modelFiles.forEach { it.writeTo(outputDir) } val hasPolymorphicTypes = modelFiles.any { it.name == SerializersModuleGenerator.FILE_NAME } - val clientFiles = ClientGenerator(apiPackage, modelPackage).generate(spec, hasPolymorphicTypes) + val clientFiles = ClientGenerator.generate(spec, hasPolymorphicTypes) clientFiles.forEach { it.writeTo(outputDir) } return Result(modelFiles.size, clientFiles.size) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt index e2166db..e5dbd9a 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,6 +25,19 @@ 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 // ============================================================================ 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 8bb3cc2..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.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 - PrimitiveType.UUID -> UUID_TYPE - } - } - - 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 -> { - JSON_ELEMENT - } - } -} 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..af5c67d --- /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 -> { + ClassName(modelPackage, contextHint.toInlinedName()) + } + + is TypeRef.Unknown -> { + JSON_ELEMENT + } +} + +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..af8bc2f --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/BodyGenerator.kt @@ -0,0 +1,237 @@ +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 = CodeBlock + .builder() + .beginControlFlow("return $SAFE_CALL") + .apply { + val urlString = buildUrlString(endpoint, params) + when (endpoint.requestBody?.contentType) { + ContentType.MULTIPART_FORM_DATA -> buildMultipartBody(endpoint, params, urlString) + ContentType.FORM_URL_ENCODED -> buildFormUrlEncodedBody(endpoint, params, urlString) + ContentType.JSON_CONTENT_TYPE, null -> buildJsonBody(endpoint, params, urlString) + } + }.unindent() + .add("}.%M()\n", if (returnBodyType == UNIT) TO_EMPTY_RESULT_FUN else TO_RESULT_FUN) + .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) + } + + endControlFlow() // client.METHOD + } + + 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) + endControlFlow() + } + + 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/ClientGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt similarity index 54% rename from core/src/main/kotlin/com/avsystem/justworks/core/gen/ClientGenerator.kt rename to core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt index bc0bf47..330e332 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ClientGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt @@ -1,11 +1,28 @@ -package com.avsystem.justworks.core.gen - +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_ERROR +import com.avsystem.justworks.core.gen.HTTP_SUCCESS +import com.avsystem.justworks.core.gen.ModelPackage +import com.avsystem.justworks.core.gen.RAISE +import com.avsystem.justworks.core.gen.TOKEN +import com.avsystem.justworks.core.gen.client.BodyGenerator.buildFunctionBody +import com.avsystem.justworks.core.gen.client.ParametersGenerator.buildBodyParams +import com.avsystem.justworks.core.gen.client.ParametersGenerator.buildNullableParameter +import com.avsystem.justworks.core.gen.invoke +import com.avsystem.justworks.core.gen.sanitizeKdoc +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.HttpMethod -import com.avsystem.justworks.core.model.Parameter import com.avsystem.justworks.core.model.ParameterLocation -import com.avsystem.justworks.core.model.TypeRef import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.ContextParameter @@ -23,20 +40,23 @@ 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) { +internal object ClientGenerator { + private const val DEFAULT_TAG = "Default" + private const val API_SUFFIX = "Api" + + context(_: ModelPackage, _: ApiPackage) 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) } } + context(modelPackage: ModelPackage, apiPackage: ApiPackage) private fun generateClientFile( tag: String, endpoints: List, @@ -46,9 +66,9 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: val clientInitializer = if (hasPolymorphicTypes) { val generatedSerializersModule = MemberName(modelPackage, GENERATED_SERIALIZERS_MODULE) - CodeBlock.of("$CREATE_HTTP_CLIENT(%M)", generatedSerializersModule) + CodeBlock.of("${CREATE_HTTP_CLIENT}(%M)", generatedSerializersModule) } else { - CodeBlock.of("$CREATE_HTTP_CLIENT()") + CodeBlock.of("${CREATE_HTTP_CLIENT}()") } val tokenType = LambdaTypeName.get(returnType = STRING) @@ -73,7 +93,7 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: .primaryConstructor(primaryConstructor) .addProperty(httpClientProperty) - classBuilder.addFunctions(endpoints.map(::generateEndpointFunction)) + classBuilder.addFunctions(endpoints.map { generateEndpointFunction(it) }) return FileSpec .builder(className) @@ -81,6 +101,7 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: .build() } + context(_: ModelPackage) private fun generateEndpointFunction(endpoint: Endpoint): FunSpec { val functionName = endpoint.operationId.toCamelCase() val returnBodyType = resolveReturnType(endpoint) @@ -95,7 +116,7 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: val params = endpoint.parameters.groupBy { it.location } val pathParams = params[ParameterLocation.PATH].orEmpty().map { param -> - ParameterSpec(param.name.toCamelCase(), TypeMapping.toTypeName(param.schema, modelPackage)) + ParameterSpec(param.name.toCamelCase(), param.schema.toTypeName()) } val queryParams = params[ParameterLocation.QUERY].orEmpty().map { param -> @@ -109,9 +130,7 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: funBuilder.addParameters(pathParams + queryParams + headerParams) if (endpoint.requestBody != null) { - funBuilder.addParameter( - buildNullableParameter(endpoint.requestBody.schema, BODY, endpoint.requestBody.required), - ) + funBuilder.addParameters(buildBodyParams(endpoint.requestBody)) } val kdocParts = mutableListOf() @@ -138,102 +157,11 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: 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) - } - } - - code.endControlFlow() // client.METHOD - code.unindent() - code.add("}.%M()\n", resultFun) - - return code.build() - } - + context(_: ModelPackage) 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) } + ?.toTypeName() ?: 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/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 83% 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 cb5891b..b482757 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,36 @@ -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.InlineSchemaDeduplicator +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.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.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_TYPE +import com.avsystem.justworks.core.gen.invoke +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 @@ -22,15 +52,17 @@ import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.WildcardTypeName import kotlinx.datetime.LocalDate +import kotlin.sequences.flatMap 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) { +internal object ModelGenerator { + context(_: ModelPackage) fun generate(spec: ApiSpec): List = context( buildHierarchyInfo(spec.schemas), InlineSchemaDeduplicator(spec.schemas.map { it.name }.toSet()), @@ -41,9 +73,9 @@ class ModelGenerator(private val modelPackage: String) { if (it.isNested) generateNestedInlineClass(it) else generateDataClass(it) } - val enumFiles = spec.enums.map(::generateEnumClass) + val enumFiles = spec.enums.map { generateEnumClass(it) } - val serializersModuleFile = SerializersModuleGenerator(modelPackage).generate() + val serializersModuleFile = SerializersModuleGenerator.generate() val uuidSerializerFile = if (spec.usesUuid()) generateUuidSerializer() else null @@ -57,6 +89,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() @@ -118,7 +151,7 @@ class ModelGenerator(private val modelPackage: String) { }.toList() } - 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) { @@ -129,9 +162,7 @@ class ModelGenerator(private val modelPackage: String) { } schema.isPrimitiveOnly -> { - val targetType = schema.underlyingType - ?.let { TypeMapping.toTypeName(it, modelPackage) } - ?: STRING + val targetType = schema.underlyingType?.toTypeName() ?: STRING listOf(generateTypeAlias(schema, targetType)) } @@ -145,7 +176,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) @@ -193,7 +224,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") @@ -249,6 +280,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, @@ -291,7 +323,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) @@ -309,7 +341,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) @@ -324,7 +356,12 @@ class ModelGenerator(private val modelPackage: String) { val propBuilder = PropertySpec .builder(kotlinName, type) .initializer(kotlinName) - .addAnnotation(AnnotationSpec.builder(SERIAL_NAME).addMember("%S", prop.name).build()) + .addAnnotation( + AnnotationSpec + .builder(SERIAL_NAME) + .addMember("%S", prop.name) + .build(), + ) .apply { prop.description?.let { addKdoc("%L", it.sanitizeKdoc()) } } propBuilder.build() @@ -339,7 +376,12 @@ 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) { @@ -351,7 +393,10 @@ class ModelGenerator(private val modelPackage: String) { val hasUuid = schema.properties.any { it.type.containsUuid() } if (hasUuid) { fileBuilder.addAnnotation( - AnnotationSpec.builder(OPT_IN).addMember("%T::class", EXPERIMENTAL_UUID_API).build(), + AnnotationSpec + .builder(OPT_IN) + .addMember("%T::class", EXPERIMENTAL_UUID_API) + .build(), ) fileBuilder.addAnnotation( AnnotationSpec @@ -367,6 +412,8 @@ class ModelGenerator(private val modelPackage: String) { /** * 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) { @@ -425,6 +472,7 @@ class ModelGenerator(private val modelPackage: String) { } ?: variantSchemaName + context(modelPackage: ModelPackage) private fun generateEnumClass(enum: EnumModel): FileSpec { val className = ClassName(modelPackage, enum.name) @@ -433,7 +481,12 @@ class ModelGenerator(private val modelPackage: String) { enum.values.forEach { value -> val anonymousClass = TypeSpec .anonymousClassBuilder() - .addAnnotation(AnnotationSpec.builder(SERIAL_NAME).addMember("%S", value.name).build()) + .addAnnotation( + AnnotationSpec + .builder(SERIAL_NAME) + .addMember("%S", value.name) + .build(), + ) .apply { value.description?.let { addKdoc("%L", it.sanitizeKdoc()) } } .build() typeSpec.addEnumConstant(value.name.toEnumConstantName(), anonymousClass) @@ -476,7 +529,7 @@ class ModelGenerator(private val modelPackage: String) { return visited.toList() } - context(_: HierarchyInfo) + context(_: HierarchyInfo, _: ModelPackage) private fun generateNestedInlineClass(schema: SchemaModel): FileSpec = generateDataClass(schema.copy(name = schema.name.toInlinedName())) @@ -506,6 +559,7 @@ class ModelGenerator(private val modelPackage: String) { return schemaRefs.plus(endpointRefs).any { it.containsUuid() } } + context(modelPackage: ModelPackage) private fun generateUuidSerializer(): FileSpec { val uuidSerializerClass = ClassName(modelPackage, "UuidSerializer") @@ -541,11 +595,16 @@ class ModelGenerator(private val modelPackage: String) { return FileSpec .builder(uuidSerializerClass) - .addAnnotation(AnnotationSpec.builder(OPT_IN).addMember("%T::class", EXPERIMENTAL_UUID_API).build()) - .addType(objectSpec) + .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) 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 82% 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 8c953e7..0e9afdc 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.BASE_URL +import com.avsystem.justworks.core.gen.BODY_AS_TEXT_FUN +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.ENCODE_PARAM_FUN +import com.avsystem.justworks.core.gen.ENCODE_TO_STRING_FUN +import com.avsystem.justworks.core.gen.HEADERS_FUN +import com.avsystem.justworks.core.gen.HTTP_CLIENT +import com.avsystem.justworks.core.gen.HTTP_ERROR +import com.avsystem.justworks.core.gen.HTTP_ERROR_TYPE +import com.avsystem.justworks.core.gen.HTTP_HEADERS +import com.avsystem.justworks.core.gen.HTTP_REQUEST_BUILDER +import com.avsystem.justworks.core.gen.HTTP_REQUEST_TIMEOUT_EXCEPTION +import com.avsystem.justworks.core.gen.HTTP_RESPONSE +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.NETWORK_ERROR +import com.avsystem.justworks.core.gen.RAISE +import com.avsystem.justworks.core.gen.RAISE_FUN +import com.avsystem.justworks.core.gen.SAFE_CALL +import com.avsystem.justworks.core.gen.SERIALIZERS_MODULE +import com.avsystem.justworks.core.gen.TOKEN import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.ContextParameter import com.squareup.kotlinpoet.ExperimentalKotlinPoetApi @@ -24,7 +53,7 @@ import com.squareup.kotlinpoet.UNIT * - `ApiClientBase` abstract class with common client infrastructure */ @OptIn(ExperimentalKotlinPoetApi::class) -object ApiClientBaseGenerator { +internal object ApiClientBaseGenerator { private const val BLOCK = "block" private const val MAP_TO_RESULT = "mapToResult" private const val SUCCESS_BODY = "successBody" @@ -44,7 +73,7 @@ object ApiClientBaseGenerator { } private fun buildEncodeParam(t: TypeVariableName): FunSpec = FunSpec - .builder("encodeParam") + .builder(ENCODE_PARAM_FUN.simpleName) .addModifiers(KModifier.INLINE) .addTypeVariable(t) .addParameter("value", TypeVariableName("T")) 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 73% 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 f983258..7596b08 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,9 @@ -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.HTTP_ERROR +import com.avsystem.justworks.core.gen.HTTP_ERROR_TYPE +import com.avsystem.justworks.core.gen.HTTP_SUCCESS import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.INT @@ -10,7 +14,7 @@ import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.TypeVariableName /** - * Generates [FileSpec]s containing: + * Generates [com.squareup.kotlinpoet.FileSpec]s containing: * - `HttpErrorType` enum class with Client, Server, Network values * - `HttpError` data class with code, message, type fields * - `HttpSuccess` data class wrapping successful responses @@ -42,10 +46,22 @@ object ApiResponseGenerator { .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()) - .build() + .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(), + ).build() return FileSpec .builder(HTTP_ERROR) 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 68% 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..1d1dd54 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,10 +1,17 @@ -package com.avsystem.justworks.core.gen +package com.avsystem.justworks.core.gen.shared -import com.avsystem.justworks.core.gen.ModelGenerator.HierarchyInfo +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 import com.squareup.kotlinpoet.PropertySpec +import kotlin.collections.iterator /** * Generates a `SerializersModule` registration file for all polymorphic sealed hierarchies. @@ -12,17 +19,15 @@ 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 { + const val FILE_NAME = "SerializersModule" /** - * 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. @@ -52,7 +57,7 @@ class SerializersModuleGenerator(private val modelPackage: String) { .build() return FileSpec - .builder(modelPackage, FILE_NAME) + .builder(modelPackage.name, FILE_NAME) .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 21d6878..c77b1d8 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 @@ -32,11 +32,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( @@ -51,19 +47,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?, 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 acd336b..19f1cf1 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 @@ -14,6 +14,7 @@ import arrow.core.toNonEmptyListOrNull import com.avsystem.justworks.core.Issue import com.avsystem.justworks.core.Warnings 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 @@ -27,9 +28,11 @@ 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 +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.parser.core.models.ParseOptions import java.io.File @@ -173,7 +176,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) @@ -184,11 +187,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, ) } @@ -199,7 +210,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"), ) @@ -222,7 +233,7 @@ 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, @@ -445,11 +456,13 @@ 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( 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..4027fd8 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.squareup.kotlinpoet.ExperimentalKotlinPoetApi import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.KModifier 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..8f971c0 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.TypeSpec import com.squareup.kotlinpoet.TypeVariableName 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 de5228d..870778f 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,32 +1,41 @@ package com.avsystem.justworks.core.gen +import com.avsystem.justworks.core.gen.client.ClientGenerator 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.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) = ApiSpec( + private fun generate(spec: ApiSpec, hasPolymorphicTypes: Boolean = false): List = + context(ModelPackage(modelPackage), ApiPackage(apiPackage)) { + ClientGenerator.generate(spec, hasPolymorphicTypes) + } + + private fun spec(vararg endpoints: Endpoint) = ApiSpec( title = "Test", version = "1.0", - endpoints = endpoints, + endpoints = endpoints.toList(), schemas = emptyList(), enums = emptyList(), ) @@ -56,8 +65,8 @@ class ClientGeneratorTest { responses = responses, ) - private fun clientClass(endpoints: List): TypeSpec { - val files = generator.generate(spec(endpoints)) + private fun clientClass(vararg endpoints: Endpoint): TypeSpec { + val files = generate(spec(*endpoints)) return files .first() .members @@ -69,12 +78,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 +99,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 +108,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 +149,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 +171,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 +181,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 +199,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 +214,7 @@ class ClientGeneratorTest { @Test fun `return type is Success parameterized`() { - val cls = clientClass(listOf(endpoint())) + val cls = clientClass(endpoint()) val funSpec = cls.funSpecs.first { it.name == "listPets" } val returnType = funSpec.returnType assertNotNull(returnType) @@ -230,7 +228,7 @@ class ClientGeneratorTest { @OptIn(ExperimentalKotlinPoetApi::class) @Test fun `endpoint functions have Raise HttpError context parameter`() { - val cls = clientClass(listOf(endpoint())) + val cls = clientClass(endpoint()) val funSpec = cls.funSpecs.first { it.name == "listPets" } val contextParameters = funSpec.contextParameters assertTrue(contextParameters.isNotEmpty(), "Expected context parameter") @@ -244,21 +242,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()) @@ -266,21 +263,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") @@ -291,7 +287,7 @@ 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()) @@ -301,7 +297,7 @@ class ClientGeneratorTest { @Test fun `client constructor has token provider parameter`() { - val cls = clientClass(listOf(endpoint())) + 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") @@ -312,7 +308,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() @@ -327,24 +323,57 @@ class ClientGeneratorTest { @Test fun `void response uses 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.HttpSuccess", returnType.rawType.toString()) assertEquals("kotlin.Unit", returnType.typeArguments.first().toString()) } + // -- 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.HttpSuccess", returnType.rawType.toString()) + assertEquals("com.example.model.Pet", returnType.typeArguments.first().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.first().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()) } @@ -352,10 +381,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 @@ -371,12 +397,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 @@ -393,7 +614,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") @@ -401,7 +622,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") @@ -409,7 +630,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") @@ -423,7 +644,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") @@ -434,7 +655,7 @@ class ClientGeneratorTest { @Test fun `endpoint with summary generates KDoc`() { val ep = endpoint(summary = "List all pets") - val cls = clientClass(listOf(ep)) + val cls = clientClass(ep) val funSpec = cls.funSpecs.first { it.name == "listPets" } assertTrue( funSpec.kdoc.toString().contains("List all pets"), @@ -445,7 +666,7 @@ class ClientGeneratorTest { @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(listOf(ep)) + 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") @@ -465,7 +686,7 @@ class ClientGeneratorTest { ), ), ) - val cls = clientClass(listOf(ep)) + 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") @@ -477,7 +698,7 @@ class ClientGeneratorTest { val ep = endpoint( responses = mapOf("200" to Response("200", "OK", TypeRef.Reference("Pet"))), ) - val cls = clientClass(listOf(ep)) + 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") @@ -491,7 +712,7 @@ class ClientGeneratorTest { parameters = emptyList(), 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 == "listPets" } assertTrue( funSpec.kdoc.toString().isEmpty(), diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt index cbf97de..d23ba1c 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 @@ -35,14 +40,21 @@ class IntegrationTest { } } + private fun generateModel(spec: ApiSpec): List = + context(ModelPackage(modelPackage)) { ModelGenerator.generate(spec) } + + private fun generateClient(spec: ApiSpec, hasPolymorphicTypes: Boolean = false): List = + context(ModelPackage(modelPackage), ApiPackage(apiPackage)) { + ClientGenerator.generate(spec, hasPolymorphicTypes) + } + @Test fun `real-world specs generate compilable enum code without class body conflicts`() { for (fixture in SPEC_FIXTURES) { val spec = parseSpec(fixture).apiSpec 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 @@ -98,13 +110,11 @@ class IntegrationTest { for (fixture in SPEC_FIXTURES) { val spec = parseSpec(fixture).apiSpec - val modelGenerator = ModelGenerator(modelPackage) - val modelFiles = modelGenerator.generate(spec) + val modelFiles = generateModel(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(spec) assertTrue( clientFiles.isNotEmpty(), "$fixture: ClientGenerator should produce files for a spec with endpoints", @@ -123,8 +133,7 @@ class IntegrationTest { for (fixture in SPEC_FIXTURES) { val spec = parseSpec(fixture).apiSpec - val generator = ModelGenerator(modelPackage) - val files = generator.generate(spec) + val files = generateModel(spec) assertTrue(files.isNotEmpty(), "$fixture: ModelGenerator should produce output files") val allSources = files.map { it.toString() } @@ -154,8 +163,7 @@ class IntegrationTest { for (fixture in SPEC_FIXTURES) { val spec = parseSpec(fixture).apiSpec - val generator = ModelGenerator(modelPackage) - val files = generator.generate(spec) + val files = generateModel(spec) assertTrue(files.isNotEmpty(), "$fixture: ModelGenerator should produce output files") for (file in files) { diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorPolymorphicTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorPolymorphicTest.kt index 649d8f5..a75a79a 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,9 +17,12 @@ 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("com.example.model")) { + ModelGenerator.generate(spec) + } + + private fun spec(schemas: List = emptyList(), enums: List = emptyList()) = ApiSpec( title = "Test", version = "1.0", endpoints = emptyList(), @@ -45,7 +49,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 +89,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 +106,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 +129,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 +153,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 +187,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 +220,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 +248,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 +291,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 +327,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 +378,7 @@ class ModelGeneratorPolymorphicTest { ), ) - val files = generator.generate( + val files = generate( spec(schemas = listOf(networkMeshSchema, extenderPropsSchema, ethernetPropsSchema)), ) val networkMeshType = findType(files, "NetworkMeshDevice") @@ -410,7 +414,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 +448,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 +478,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 +506,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 +540,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 +569,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 +593,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 = ...) @@ -645,7 +649,7 @@ class ModelGeneratorPolymorphicTest { requiredProperties = setOf("lastSeen"), ) - val files = generator.generate( + val files = generate( spec(schemas = listOf(deviceStatusSchema, trueSchema, falseSchema)), ) @@ -698,7 +702,7 @@ class ModelGeneratorPolymorphicTest { ) } - val files = generator.generate( + val files = generate( spec(schemas = listOf(networkMeshSchema) + variantSchemas), ) @@ -759,7 +763,7 @@ class ModelGeneratorPolymorphicTest { requiredProperties = setOf("lastSeen"), ) - val files = generator.generate( + val files = generate( spec(schemas = listOf(deviceStatusSchema, trueSchema, falseSchema)), ) @@ -796,7 +800,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 a852cfb..be7e4bb 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,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.Endpoint import com.avsystem.justworks.core.model.EnumBackingType @@ -21,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("com.example.model")) { + ModelGenerator.generate(spec) + } private fun spec(schemas: List = emptyList(), enums: List = emptyList()) = ApiSpec( title = "Test", @@ -52,7 +56,7 @@ 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[0].members.filterIsInstance()[0] assertTrue(KModifier.DATA in typeSpec.modifiers, "Expected DATA modifier") @@ -60,7 +64,7 @@ class ModelGeneratorTest { @Test fun `generates class with Serializable annotation`() { - val files = generator.generate(spec(schemas = listOf(petSchema))) + 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") @@ -68,7 +72,7 @@ class ModelGeneratorTest { @Test fun `required property is non-nullable in constructor`() { - val files = generator.generate(spec(schemas = listOf(petSchema))) + 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" } @@ -77,7 +81,7 @@ class ModelGeneratorTest { @Test fun `optional property is nullable with default null`() { - val files = generator.generate(spec(schemas = listOf(petSchema))) + 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" } @@ -88,7 +92,7 @@ class ModelGeneratorTest { @Test fun `every property has SerialName annotation with wire name`() { - val files = generator.generate(spec(schemas = listOf(petSchema))) + val files = generate(spec(schemas = listOf(petSchema))) val typeSpec = files[0].members.filterIsInstance()[0] for (prop in typeSpec.propertySpecs) { val serialName = @@ -115,7 +119,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files[0].members.filterIsInstance()[0] val prop = typeSpec.propertySpecs[0] assertEquals("createdAt", prop.name) @@ -131,7 +135,7 @@ class ModelGeneratorTest { @Test fun `schema with description produces KDoc`() { - val files = generator.generate(spec(schemas = listOf(petSchema))) + 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") } @@ -152,7 +156,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + 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()) @@ -161,13 +165,13 @@ 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 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 } @@ -187,7 +191,7 @@ class ModelGeneratorTest { @Test fun `string enum has Serializable annotation`() { - val files = generator.generate(spec(enums = listOf(statusEnum))) + 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") @@ -195,7 +199,7 @@ class ModelGeneratorTest { @Test fun `string enum constants have SerialName with wire value`() { - val files = generator.generate(spec(enums = listOf(statusEnum))) + 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) { @@ -209,7 +213,7 @@ class ModelGeneratorTest { @Test fun `enum constant names are UPPER_SNAKE_CASE`() { - val files = generator.generate(spec(enums = listOf(statusEnum))) + 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) @@ -217,7 +221,7 @@ class ModelGeneratorTest { @Test fun `enum constants do not produce anonymous class body`() { - val files = generator.generate(spec(enums = listOf(statusEnum))) + val files = generate(spec(enums = listOf(statusEnum))) val source = files[0].toString() // Assert no class body braces on enum constants assertFalse( @@ -245,7 +249,7 @@ class ModelGeneratorTest { type = EnumBackingType.INTEGER, values = listOf(EnumModel.Value("1"), EnumModel.Value("2"), EnumModel.Value("3")), ) - val files = generator.generate(spec(enums = listOf(intEnum))) + 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) @@ -269,13 +273,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) } @@ -308,7 +312,7 @@ class ModelGeneratorTest { discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(paymentSchema, creditCardSchema))) + 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") @@ -340,7 +344,7 @@ class ModelGeneratorTest { discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(paymentSchema, creditCardSchema))) + val files = generate(spec(schemas = listOf(paymentSchema, creditCardSchema))) val creditCardType = files.first { it.name == "CreditCard" }.members.filterIsInstance()[0] val serialNameAnnotation = @@ -379,7 +383,7 @@ class ModelGeneratorTest { discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(paymentSchema, creditCardSchema))) + val files = generate(spec(schemas = listOf(paymentSchema, creditCardSchema))) val paymentType = files.first { it.name == "Payment" }.members.filterIsInstance()[0] val discriminatorAnnotation = @@ -411,7 +415,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + 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" } @@ -435,7 +439,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + 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" } @@ -460,7 +464,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + 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" } @@ -489,7 +493,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + 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" } @@ -519,7 +523,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + 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" } @@ -548,7 +552,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + 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 } @@ -572,7 +576,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + 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" } @@ -604,7 +608,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema), enums = listOf(statusEnum))) + 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" } @@ -629,7 +633,7 @@ class ModelGeneratorTest { 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[0] @@ -657,7 +661,7 @@ class ModelGeneratorTest { discriminator = null, underlyingType = TypeRef.Primitive(PrimitiveType.INT), ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeAlias = files .first() .members @@ -679,7 +683,7 @@ class ModelGeneratorTest { discriminator = null, underlyingType = TypeRef.Primitive(PrimitiveType.BOOLEAN), ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeAlias = files .first() .members @@ -701,7 +705,7 @@ class ModelGeneratorTest { discriminator = null, underlyingType = TypeRef.Primitive(PrimitiveType.LONG), ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeAlias = files .first() .members @@ -723,7 +727,7 @@ class ModelGeneratorTest { discriminator = null, underlyingType = TypeRef.Primitive(PrimitiveType.DOUBLE), ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeAlias = files .first() .members @@ -745,7 +749,7 @@ class ModelGeneratorTest { discriminator = null, underlyingType = TypeRef.Array(TypeRef.Primitive(PrimitiveType.STRING)), ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeAlias = files .first() .members @@ -767,7 +771,7 @@ class ModelGeneratorTest { discriminator = null, underlyingType = TypeRef.Reference("OtherSchema"), ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeAlias = files .first() .members @@ -788,7 +792,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(userIdSchema))) + val files = generate(spec(schemas = listOf(userIdSchema))) val typeAlias = files[0].members.filterIsInstance()[0] assertTrue( @@ -800,7 +804,7 @@ 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[0].members.filterIsInstance() val typeAliases = files[0].members.filterIsInstance() @@ -832,7 +836,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val source = files[0].toString() // Hard keyword: KotlinPoet should backtick-escape @@ -858,7 +862,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val source = files[0].toString() // Hard keyword: KotlinPoet should backtick-escape @@ -884,7 +888,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val source = files[0].toString() // Hard keyword: KotlinPoet should backtick-escape @@ -910,7 +914,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files[0].members.filterIsInstance()[0] val prop = typeSpec.propertySpecs[0] @@ -934,7 +938,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files[0].members.filterIsInstance()[0] val prop = typeSpec.propertySpecs[0] @@ -958,7 +962,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files[0].members.filterIsInstance()[0] val prop = typeSpec.propertySpecs[0] @@ -982,7 +986,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files[0].members.filterIsInstance()[0] val prop = typeSpec.propertySpecs[0] @@ -1028,7 +1032,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") } @@ -1049,7 +1053,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files[0].members.filterIsInstance()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) @@ -1090,7 +1094,7 @@ 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()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) @@ -1121,7 +1125,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files .first() .members @@ -1146,7 +1150,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files .first() .members @@ -1173,7 +1177,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files .first() .members @@ -1203,7 +1207,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + 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() @@ -1225,7 +1229,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val deviceFile = files.find { it.name == "Device" } assertNotNull(deviceFile) val content = deviceFile.toString() @@ -1249,7 +1253,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val deviceFile = files.find { it.name == "Device" } assertNotNull(deviceFile) val content = deviceFile.toString() @@ -1278,7 +1282,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val deviceFile = files.find { it.name == "DeviceWithUuidList" } assertNotNull(deviceFile) val content = deviceFile.toString() @@ -1316,7 +1320,7 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val deviceFile = files.find { it.name == "DeviceWithUuidMap" } assertNotNull(deviceFile) val content = deviceFile.toString() @@ -1337,7 +1341,7 @@ class ModelGeneratorTest { @Test fun `data class without UUID property does not generate UuidSerializer`() { - val files = generator.generate(spec(schemas = listOf(petSchema))) + val files = generate(spec(schemas = listOf(petSchema))) val uuidSerializerFile = files.find { it.name == "UuidSerializer" } assertEquals(null, uuidSerializerFile, "Expected no UuidSerializer file without UUID properties") } @@ -1369,7 +1373,7 @@ class ModelGeneratorTest { schemas = emptyList(), enums = emptyList(), ) - val files = generator.generate(apiSpec) + val files = generate(apiSpec) val uuidSerializerFile = files.find { it.name == "UuidSerializer" } assertNotNull(uuidSerializerFile, "Expected UuidSerializer when UUID is used in endpoint parameter") } @@ -1389,7 +1393,7 @@ 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()[0] val constructor = assertNotNull(typeSpec.primaryConstructor) @@ -1415,11 +1419,11 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files .first() .members - .filterIsInstance() + .filterIsInstance() .first() val prop = typeSpec.propertySpecs.first { it.name == "name" } assertTrue( @@ -1442,11 +1446,11 @@ class ModelGeneratorTest { anyOf = null, discriminator = null, ) - val files = generator.generate(spec(schemas = listOf(schema))) + val files = generate(spec(schemas = listOf(schema))) val typeSpec = files .first() .members - .filterIsInstance() + .filterIsInstance() .first() val prop = typeSpec.propertySpecs.first { it.name == "name" } assertTrue( @@ -1463,11 +1467,11 @@ class ModelGeneratorTest { type = EnumBackingType.STRING, values = listOf(EnumModel.Value("active", "Currently active"), EnumModel.Value("inactive", "Not active")), ) - val files = generator.generate(spec(enums = listOf(enum))) + val files = generate(spec(enums = listOf(enum))) val typeSpec = files .first() .members - .filterIsInstance() + .filterIsInstance() .first() val activeConstant = typeSpec.enumConstants["ACTIVE"] assertNotNull(activeConstant, "Expected ACTIVE enum constant") @@ -1491,11 +1495,11 @@ class ModelGeneratorTest { type = EnumBackingType.STRING, values = listOf(EnumModel.Value("active"), EnumModel.Value("inactive")), ) - val files = generator.generate(spec(enums = listOf(enum))) + val files = generate(spec(enums = listOf(enum))) val typeSpec = files .first() .members - .filterIsInstance() + .filterIsInstance() .first() val activeConstant = typeSpec.enumConstants["ACTIVE"] assertNotNull(activeConstant, "Expected ACTIVE enum constant") 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 ab79650..712eace 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,71 +3,76 @@ 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 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 = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.UUID), pkg) + val result = map(TypeRef.Primitive(PrimitiveType.UUID)) assertEquals("kotlin.uuid.Uuid", result.toString()) } @@ -76,7 +81,7 @@ class TypeMappingTest { @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()) } @@ -85,7 +90,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()) } @@ -94,7 +99,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()) } @@ -103,7 +108,7 @@ 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()) } @@ -116,7 +121,7 @@ class TypeMappingTest { requiredProperties = setOf("name"), contextHint = "Pet.Address", ) - val result = TypeMapping.toTypeName(ref, pkg) + val result = map(ref) assertEquals("com.example.model.Pet_Address", result.toString()) } @@ -132,19 +137,19 @@ class TypeMappingTest { @Test fun `maps Unknown to kotlinx serialization json JsonElement`() { - val result = TypeMapping.toTypeName(TypeRef.Unknown, pkg) + val result = map(TypeRef.Unknown) assertEquals("kotlinx.serialization.json.JsonElement", result.toString()) } @Test fun `maps Array of Unknown to List of JsonElement`() { - val result = TypeMapping.toTypeName(TypeRef.Array(TypeRef.Unknown), pkg) + 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 = TypeMapping.toTypeName(TypeRef.Map(TypeRef.Unknown), pkg) + val result = map(TypeRef.Map(TypeRef.Unknown)) assertEquals("kotlin.collections.Map", result.toString()) } } 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 85344d9..1be095e 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,6 +1,7 @@ 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 @@ -133,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) From 760a8e65c049f7105e60807f8b3181c91de3a429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 2 Apr 2026 15:23:31 +0200 Subject: [PATCH 09/11] test: add SpecParser tests for x-enum-descriptions handling in enums --- .../avsystem/justworks/core/model/ApiSpec.kt | 2 +- .../justworks/core/parser/SpecParserTest.kt | 71 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) 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 b098249..ed87933 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 @@ -20,7 +20,7 @@ data class Endpoint( val method: HttpMethod, val operationId: String, val summary: String?, - val description: String? = null, + val description: String?, val tags: List, val parameters: List, val requestBody: RequestBody?, 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 1be095e..09f02e7 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 @@ -505,6 +505,77 @@ class SpecParserTest : SpecParserTestBase() { 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 From aedf458814677aa61c90f0b0ea0e38bc2f1361d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 2 Apr 2026 15:26:05 +0200 Subject: [PATCH 10/11] fix: sanitize KDoc descriptions and correct `$ref` usage in SpecParser tests - Add `sanitizeKdoc` to handle special characters in schema descriptions. - Replace `${'$'}ref` with `$ref` in test specifications for better consistency. --- .../justworks/core/gen/model/ModelGenerator.kt | 6 +++--- .../justworks/core/parser/SpecParserTest.kt | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/model/ModelGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/model/ModelGenerator.kt index fc50c60..4281efe 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/model/ModelGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/model/ModelGenerator.kt @@ -245,7 +245,7 @@ internal object ModelGenerator { } if (schema.description != null) { - typeSpec.addKdoc("%L", schema.description) + typeSpec.addKdoc("%L", schema.description.sanitizeKdoc()) } val fileBuilder = FileSpec.builder(className).addType(typeSpec.build()) @@ -425,7 +425,7 @@ internal object ModelGenerator { } if (schema.description != null) { - typeSpec.addKdoc("%L", schema.description) + typeSpec.addKdoc("%L", schema.description.sanitizeKdoc()) } val fileBuilder = FileSpec.builder(className).addType(typeSpec.build()) @@ -644,7 +644,7 @@ internal object ModelGenerator { 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/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt index 09f02e7..71ccbe1 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 @@ -221,7 +221,7 @@ 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) } @@ -311,7 +311,7 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `property with allOf reference resolves to referenced type`() { val spec = - """ + $$""" openapi: 3.0.0 info: title: Test @@ -331,7 +331,7 @@ class SpecParserTest : SpecParserTestBase() { type: string config: allOf: - - ${'$'}ref: '#/components/schemas/TaskConfig' + - $ref: '#/components/schemas/TaskConfig' required: - name """.trimIndent() @@ -425,7 +425,7 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `ref wrapper schema has underlyingType Reference`() { val spec = parseSpec( - """ + $$""" openapi: 3.0.0 info: title: Test @@ -439,7 +439,7 @@ class SpecParserTest : SpecParserTestBase() { name: type: string PetAlias: - ${'$'}ref: '#/components/schemas/Pet' + $ref: '#/components/schemas/Pet' """.trimIndent().toTempFile(), ) @@ -477,7 +477,7 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `schema with allOf has no underlyingType`() { val spec = parseSpec( - """ + $$""" openapi: 3.0.0 info: title: Test @@ -492,7 +492,7 @@ class SpecParserTest : SpecParserTestBase() { type: integer Extended: allOf: - - ${'$'}ref: '#/components/schemas/Base' + - $ref: '#/components/schemas/Base' type: object properties: name: From b4c531790db59de369d5d28a4ecb9fbb7a0dfced Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 2 Apr 2026 15:29:26 +0200 Subject: [PATCH 11/11] test: add missing `description` field assertions and improve test readability - Add `description = null` field in multiple test cases for improved APISpec coverage. - Refactor tests to use `assert` for clearer error messaging. - Fix minor formatting issues in `PropertyModel` and test comments. - Replace `filterIsInstance` with `filterIsInstance` for consistency. --- .../justworks/core/gen/ClientGeneratorTest.kt | 2 +- .../core/gen/InlineTypeResolverTest.kt | 2 ++ .../justworks/core/gen/ModelGeneratorTest.kt | 19 +++++++++++++------ .../justworks/core/parser/SpecParserTest.kt | 7 +++---- 4 files changed, 19 insertions(+), 11 deletions(-) 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 57dd462..6e353b2 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 @@ -27,7 +27,7 @@ class ClientGeneratorTest { private val apiPackage = "com.example.api" private val modelPackage = "com.example.model" - private fun generate(spec: ApiSpec, hasPolymorphicTypes: Boolean = false,): List = + private fun generate(spec: ApiSpec, hasPolymorphicTypes: Boolean = false): List = context(ModelPackage(modelPackage), ApiPackage(apiPackage)) { ClientGenerator.generate(spec, hasPolymorphicTypes, NameRegistry()) } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineTypeResolverTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineTypeResolverTest.kt index 92f0ca8..7c72fd1 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineTypeResolverTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineTypeResolverTest.kt @@ -124,6 +124,7 @@ class InlineTypeResolverTest { parameters = emptyList(), requestBody = null, responses = mapOf("200" to Response("200", null, inline)), + description = null, ), ), ) @@ -157,6 +158,7 @@ class InlineTypeResolverTest { parameters = emptyList(), requestBody = RequestBody(true, ContentType.JSON_CONTENT_TYPE, inline), responses = emptyMap(), + description = null, ), ), ) 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 5248b31..417d3d1 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 @@ -456,7 +456,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, @@ -823,7 +829,7 @@ class ModelGeneratorTest { @Test fun `property named 'class' generates backtick-escaped name with SerialName`() { - // KotlinPoet auto-escapes hard keywords — 'class' should become `class` in generated source + // KotlinPoet auto-escapes hard keywords — 'class' should become `class` in a generated source val schema = SchemaModel( name = "Reserved", description = null, @@ -849,7 +855,7 @@ class ModelGeneratorTest { @Test fun `property named 'object' generates backtick-escaped name with SerialName`() { - // KotlinPoet auto-escapes hard keywords — 'object' should become `object` in generated source + // KotlinPoet auto-escapes hard keywords — 'object' should become `object` in a generated source val schema = SchemaModel( name = "Item", description = null, @@ -1129,7 +1135,7 @@ class ModelGeneratorTest { val typeSpec = files .first() .members - .filterIsInstance() + .filterIsInstance() .first() val constructor = assertNotNull(typeSpec.primaryConstructor) val param = constructor.parameters.first { it.name == "response" } @@ -1154,7 +1160,7 @@ class ModelGeneratorTest { val typeSpec = files .first() .members - .filterIsInstance() + .filterIsInstance() .first() val constructor = assertNotNull(typeSpec.primaryConstructor) val param = constructor.parameters.first { it.name == "response" } @@ -1181,7 +1187,7 @@ class ModelGeneratorTest { val typeSpec = files .first() .members - .filterIsInstance() + .filterIsInstance() .first() val constructor = assertNotNull(typeSpec.primaryConstructor) val param = constructor.parameters.first { it.name == "healthChecks" } @@ -1365,6 +1371,7 @@ class ModelGeneratorTest { ), requestBody = null, responses = emptyMap(), + description = null, ) val apiSpec = ApiSpec( title = "Test", 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 71ccbe1..e531d94 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 @@ -200,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" }