From cbc7b64d01bde4c4a699a6065c9451e09562be8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 10 Jun 2026 11:05:27 +0200 Subject: [PATCH 1/6] feat(core): nest inline operation body types inside the client class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline (anonymous) request/response body schemas were generated as flat, structurally-deduplicated top-level model files with awkward names like `Domains_updateRequest`. They are now lifted per operation and generated as data classes nested inside the owning client class, e.g. `DomainsApi.DomainsUpdateRequest` / `DomainsApi.DomainsUpdateResponse`. - New `planOperationInlineTypes` pass (run by CodeGenerator) rewrites inline operation bodies into per-operation reference ids — no structural dedup, so each operation gets its own copy. - `Hierarchy` resolves those ids to the nested `ClassName`. - `ClientGenerator` nests the data classes in the client class and registers the ids before generating functions, so signatures resolve. - Naming: `PascalCase(operationId)` + `Request`/`Response` (no tag stripping — operationId is author-controlled and arbitrary); non-2xx and default responses get distinct suffixes. Inline objects nested in model properties (-> nested under the parent type) and PascalCasing of top-level component names are follow-ups. Refs #92 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../justworks/core/gen/CodeGenerator.kt | 10 +- .../avsystem/justworks/core/gen/Hierarchy.kt | 15 ++- .../justworks/core/gen/PlannedInlineType.kt | 86 ++++++++++++++ .../core/gen/client/ClientGenerator.kt | 30 ++++- .../core/gen/model/ModelGenerator.kt | 15 +++ .../justworks/core/gen/CodeGeneratorTest.kt | 68 +++++++++++ .../core/gen/OperationInlineTypesTest.kt | 106 ++++++++++++++++++ 7 files changed, 323 insertions(+), 7 deletions(-) create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/gen/PlannedInlineType.kt create mode 100644 core/src/test/kotlin/com/avsystem/justworks/core/gen/OperationInlineTypesTest.kt 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 55f2ecd5..a35c0696 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt @@ -20,12 +20,16 @@ object CodeGenerator { apiPackage: String, outputDir: File, ): Result { + // Lift inline operation request/response bodies out into per-operation types that + // ClientGenerator will nest inside the owning client class. + val (plannedSpec, operationInlineTypes) = planOperationInlineTypes(spec) + val hierarchy = Hierarchy(ModelPackage(modelPackage)).apply { - addSchemas(spec.schemas) + addSchemas(plannedSpec.schemas) } val (modelFiles, resolvedSpec) = context(hierarchy, NameRegistry()) { - ModelGenerator.generateWithResolvedSpec(spec) + ModelGenerator.generateWithResolvedSpec(plannedSpec) } modelFiles.forEach { it.writeTo(outputDir) } @@ -33,7 +37,7 @@ object CodeGenerator { val hasPolymorphicTypes = modelFiles.any { it.name == SERIALIZERS_MODULE.simpleName } val clientFiles = context(hierarchy, ApiPackage(apiPackage), NameRegistry()) { - ClientGenerator.generate(resolvedSpec, hasPolymorphicTypes) + ClientGenerator.generate(resolvedSpec, hasPolymorphicTypes, operationInlineTypes) } clientFiles.forEach { it.writeTo(outputDir) } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt index b03f363e..aabc409f 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt @@ -10,6 +10,17 @@ internal class Hierarchy(val modelPackage: ModelPackage) { private val schemaModels = mutableSetOf() private val memoScope = MemoScope() + /** + * Resolution overrides for inline operation body types nested inside client classes, + * keyed by the reference id assigned by [planOperationInlineTypes]. + */ + private val inlineRefs = mutableMapOf() + + /** Registers the nested [ClassName] an inline-operation reference id resolves to. */ + fun registerInlineRef(id: String, className: ClassName) { + inlineRefs[id] = className + } + /** * Updates the underlying schemas and invalidates all cached derived views. * This is necessary when schemas are updated (e.g., after inlining types). @@ -72,8 +83,8 @@ internal class Hierarchy(val modelPackage: ModelPackage) { }.toMap() } - /** Resolves a schema name to its [ClassName], falling back to a flat top-level class. */ - operator fun get(name: String): ClassName = lookup[name] ?: ClassName(modelPackage, name) + /** Resolves a schema name (or inline-ref id) to its [ClassName], falling back to a flat top-level class. */ + operator fun get(name: String): ClassName = inlineRefs[name] ?: lookup[name] ?: ClassName(modelPackage, name) } private fun SchemaModel.variants() = oneOf ?: anyOf diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/PlannedInlineType.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/PlannedInlineType.kt new file mode 100644 index 00000000..c624e067 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/PlannedInlineType.kt @@ -0,0 +1,86 @@ +package com.avsystem.justworks.core.gen + +import com.avsystem.justworks.core.model.ApiSpec +import com.avsystem.justworks.core.model.SchemaModel +import com.avsystem.justworks.core.model.TypeRef + +/** + * An inline (anonymous) request/response body schema lifted out of an operation so it can + * be generated as a type nested inside the owning client class. + * + * @param id stable reference id stored in [TypeRef.Reference] in place of the inline schema; + * the generator resolves it to the nested [com.squareup.kotlinpoet.ClassName] via [Hierarchy]. + * @param simpleName the nested type's simple name (e.g. `DomainsUpdateRequest`). + * @param schema the schema to generate the nested data class from. + */ +internal data class PlannedInlineType( + val id: String, + val simpleName: String, + val schema: SchemaModel, +) + +/** + * Rewrites operation request/response bodies that are [TypeRef.Inline] into + * [TypeRef.Reference]s pointing at per-operation ids, and returns, per operationId, + * the inline types that must be generated nested inside that operation's client class. + * + * No structural deduplication: each operation gets its own copy, named after the + * operation, so identical shapes across operations do not collapse into one shared type. + */ +internal fun planOperationInlineTypes(spec: ApiSpec): Pair>> { + val byOperation = mutableMapOf>() + + fun lift( + operationId: String, + role: String, + simpleName: String, + inline: TypeRef.Inline + ): TypeRef.Reference { + val id = "$operationId#$role" + byOperation.getOrPut(operationId) { mutableListOf() }.add( + PlannedInlineType( + id = id, + simpleName = simpleName, + schema = SchemaModel( + name = simpleName, + description = null, + properties = inline.properties, + requiredProperties = inline.requiredProperties, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ), + ), + ) + return TypeRef.Reference(id) + } + + val newEndpoints = spec.endpoints.map { endpoint -> + val opName = endpoint.operationId.toPascalCase() + + val newRequestBody = endpoint.requestBody?.let { body -> + (body.schema as? TypeRef.Inline) + ?.let { body.copy(schema = lift(endpoint.operationId, "request", "${opName}Request", it)) } + ?: body + } + + val newResponses = endpoint.responses.mapValues { (code, response) -> + (response.schema as? TypeRef.Inline)?.let { inline -> + response.copy( + schema = lift(endpoint.operationId, "response#$code", responseTypeName(opName, code), inline), + ) + } ?: response + } + + endpoint.copy(requestBody = newRequestBody, responses = newResponses) + } + + return spec.copy(endpoints = newEndpoints) to byOperation +} + +private fun responseTypeName(opName: String, code: String): String = when { + code.toIntOrNull()?.let { it in 200..299 } == true -> "${opName}Response" + code == "default" -> "${opName}DefaultResponse" + else -> "${opName}Response$code" +} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt index 88469a44..0a790132 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt @@ -17,11 +17,13 @@ import com.avsystem.justworks.core.gen.HTTP_SUCCESS import com.avsystem.justworks.core.gen.Hierarchy import com.avsystem.justworks.core.gen.JSON_ELEMENT import com.avsystem.justworks.core.gen.NameRegistry +import com.avsystem.justworks.core.gen.PlannedInlineType 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.model.ModelGenerator import com.avsystem.justworks.core.gen.shared.toAuthParam import com.avsystem.justworks.core.gen.toCamelCase import com.avsystem.justworks.core.gen.toPascalCase @@ -56,10 +58,21 @@ internal object ClientGenerator { private const val API_SUFFIX = "Api" context(_: Hierarchy, _: ApiPackage, _: NameRegistry) - fun generate(spec: ApiSpec, hasPolymorphicTypes: Boolean): List { + fun generate( + spec: ApiSpec, + hasPolymorphicTypes: Boolean, + operationInlineTypes: Map> = emptyMap(), + ): List { val grouped = spec.endpoints.groupBy { it.tags.firstOrNull() ?: DEFAULT_TAG } return grouped.map { (tag, endpoints) -> - generateClientFile(tag, endpoints, hasPolymorphicTypes, spec.securitySchemes, spec.title) + generateClientFile( + tag, + endpoints, + hasPolymorphicTypes, + spec.securitySchemes, + spec.title, + operationInlineTypes, + ) } } @@ -70,6 +83,7 @@ internal object ClientGenerator { hasPolymorphicTypes: Boolean, securitySchemes: List, specTitle: String, + operationInlineTypes: Map>, ): FileSpec { val className = ClassName(apiPackage, nameRegistry.register("${tag.toPascalCase()}$API_SUFFIX")) @@ -138,6 +152,18 @@ internal object ClientGenerator { classBuilder.addFunction(buildApplyAuth(securitySchemes, isSingleBearer, specTitle)) } + // Nest each operation's inline request/response body types inside the client class, + // registering their reference ids so endpoint signatures resolve to the nested classes. + // Done before generating functions so type resolution sees the registered names. + val nestedNameRegistry = NameRegistry() + endpoints + .flatMap { operationInlineTypes[it.operationId].orEmpty() } + .forEach { planned -> + val nestedName = nestedNameRegistry.register(planned.simpleName) + hierarchy.registerInlineRef(planned.id, className.nestedClass(nestedName)) + classBuilder.addType(ModelGenerator.buildNestedBodyType(nestedName, planned.schema)) + } + context(NameRegistry()) { classBuilder.addFunctions(endpoints.map { generateEndpointFunction(it) }) } 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 23e1415f..8fcf7376 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 @@ -277,6 +277,21 @@ internal object ModelGenerator { return builder.build() } + /** + * Builds a `@Serializable data class` [TypeSpec] for an inline operation body schema, + * suitable for nesting inside a client class. Carries no superinterfaces or discriminator + * wiring — operation bodies are plain anonymous objects. + */ + context(_: Hierarchy) + internal fun buildNestedBodyType(simpleName: String, schema: SchemaModel): TypeSpec { + val builder = TypeSpec + .classBuilder(simpleName) + .addModifiers(KModifier.DATA) + .addAnnotation(SERIALIZABLE) + buildConstructorAndProperties(schema, builder) + return builder.build() + } + /** * Builds primary constructor and data class properties from a schema's property list. * Shared by [generateDataClass] and [buildNestedVariant]. diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/CodeGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/CodeGeneratorTest.kt index abd834a4..b666e59e 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/CodeGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/CodeGeneratorTest.kt @@ -52,4 +52,72 @@ class CodeGeneratorTest { } } } + + @Test + fun `inline operation bodies are nested inside the client class`() { + val yaml = """ + openapi: "3.0.0" + info: { title: T, version: "1" } + paths: + /domains/{id}: + put: + operationId: domains_update + tags: [domains] + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: { type: string } + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + id: { type: string } + """.trimIndent() + val specFile = File.createTempFile("nested-inline", ".yaml").apply { + writeText(yaml) + deleteOnExit() + } + val spec = when (val r = SpecParser.parse(specFile)) { + is ParseResult.Success -> r.value + is ParseResult.Failure -> fail("parse failed: ${r.error}") + } + + val outputDir = Files.createTempDirectory("codegen-nested").toFile() + try { + CodeGenerator.generate(spec, "com.example.model", "com.example.api", outputDir) + + val apiFile = outputDir.resolve("com/example/api/DomainsApi.kt") + assertTrue(apiFile.exists(), "DomainsApi.kt should exist") + val api = apiFile.readText() + + // Request/response bodies are nested types, referenced by the function. + assertTrue(api.contains("data class DomainsUpdateRequest"), "request body should be nested in client") + assertTrue(api.contains("data class DomainsUpdateResponse"), "response body should be nested in client") + assertTrue( + api.contains("body: DomainsUpdateRequest"), + "function should reference the nested request type", + ) + + // No top-level model files were emitted for the inline bodies. + val modelDir = outputDir.resolve("com/example/model") + val modelFiles = modelDir.listFiles()?.map { it.name }.orEmpty() + assertTrue( + modelFiles.none { it.contains("DomainsUpdate") }, + "inline bodies should not be top-level model files, got: $modelFiles", + ) + } finally { + outputDir.deleteRecursively() + } + } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/OperationInlineTypesTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/OperationInlineTypesTest.kt new file mode 100644 index 00000000..96f76d47 --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/OperationInlineTypesTest.kt @@ -0,0 +1,106 @@ +package com.avsystem.justworks.core.gen + +import com.avsystem.justworks.core.model.ApiSpec +import com.avsystem.justworks.core.model.ContentType +import com.avsystem.justworks.core.model.Endpoint +import com.avsystem.justworks.core.model.HttpMethod +import com.avsystem.justworks.core.model.PrimitiveType +import com.avsystem.justworks.core.model.PropertyModel +import com.avsystem.justworks.core.model.RequestBody +import com.avsystem.justworks.core.model.Response +import com.avsystem.justworks.core.model.TypeRef +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class OperationInlineTypesTest { + private fun inline(vararg props: String) = TypeRef.Inline( + properties = props.map { PropertyModel(it, TypeRef.Primitive(PrimitiveType.STRING), null, false) }, + requiredProperties = emptySet(), + contextHint = "x", + ) + + private fun endpoint( + operationId: String, + requestBody: RequestBody? = null, + responses: Map = emptyMap(), + ) = Endpoint( + path = "/x", + method = HttpMethod.POST, + operationId = operationId, + summary = null, + description = null, + tags = listOf("domains"), + parameters = emptyList(), + requestBody = requestBody, + responses = responses, + ) + + private fun spec(vararg endpoints: Endpoint) = ApiSpec( + title = "T", + version = "1", + endpoints = endpoints.toList(), + schemas = emptyList(), + enums = emptyList(), + securitySchemes = emptyList(), + ) + + @Test + fun `lifts inline request and response into planned types named after the operation`() { + val ep = endpoint( + operationId = "domains_update", + requestBody = RequestBody(true, ContentType.JSON_CONTENT_TYPE, inline("name")), + responses = mapOf("200" to Response("200", "ok", inline("id"))), + ) + val (rewritten, byOp) = planOperationInlineTypes(spec(ep)) + + val planned = byOp.getValue("domains_update") + assertEquals( + listOf("DomainsUpdateRequest", "DomainsUpdateResponse"), + planned.map { it.simpleName }, + ) + + // Spec rewritten: inline schemas replaced by references to the planned ids. + val newEp = rewritten.endpoints.single() + val reqRef = assertIs(newEp.requestBody!!.schema) + assertEquals("domains_update#request", reqRef.schemaName) + val respRef = assertIs(newEp.responses.getValue("200").schema) + assertEquals("domains_update#response#200", respRef.schemaName) + } + + @Test + fun `non-2xx and default responses get distinct names`() { + val ep = endpoint( + operationId = "getThing", + responses = mapOf( + "200" to Response("200", "ok", inline("ok")), + "404" to Response("404", "nf", inline("err")), + "default" to Response("default", "def", inline("def")), + ), + ) + val (_, byOp) = planOperationInlineTypes(spec(ep)) + assertEquals( + setOf("GetThingResponse", "GetThingResponse404", "GetThingDefaultResponse"), + byOp.getValue("getThing").map { it.simpleName }.toSet(), + ) + } + + @Test + fun `referenced (non-inline) bodies are left untouched`() { + val ep = endpoint( + operationId = "createPet", + requestBody = RequestBody(true, ContentType.JSON_CONTENT_TYPE, TypeRef.Reference("NewPet")), + responses = mapOf("200" to Response("200", "ok", TypeRef.Reference("Pet"))), + ) + val (rewritten, byOp) = planOperationInlineTypes(spec(ep)) + assertNull(byOp["createPet"]) + assertTrue( + rewritten.endpoints + .single() + .requestBody!! + .schema is TypeRef.Reference, + ) + } +} From aae2a7da49d5cba4bfc8054371eef8f07c3bdfe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 10 Jun 2026 11:06:31 +0200 Subject: [PATCH 2/6] refactor(core): use smart-cast if over as?/let in inline planner Co-Authored-By: Claude Opus 4.8 (1M context) --- .../justworks/core/gen/PlannedInlineType.kt | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/PlannedInlineType.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/PlannedInlineType.kt index c624e067..a7673c36 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/PlannedInlineType.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/PlannedInlineType.kt @@ -60,17 +60,23 @@ internal fun planOperationInlineTypes(spec: ApiSpec): Pair - (body.schema as? TypeRef.Inline) - ?.let { body.copy(schema = lift(endpoint.operationId, "request", "${opName}Request", it)) } - ?: body + val schema = body.schema + if (schema is TypeRef.Inline) { + body.copy(schema = lift(endpoint.operationId, "request", "${opName}Request", schema)) + } else { + body + } } val newResponses = endpoint.responses.mapValues { (code, response) -> - (response.schema as? TypeRef.Inline)?.let { inline -> + val schema = response.schema + if (schema is TypeRef.Inline) { response.copy( - schema = lift(endpoint.operationId, "response#$code", responseTypeName(opName, code), inline), + schema = lift(endpoint.operationId, "response#$code", responseTypeName(opName, code), schema), ) - } ?: response + } else { + response + } } endpoint.copy(requestBody = newRequestBody, responses = newResponses) From 6a6e8e3e3e1d73d10ddc04cfeb2fe7ff7fc08d86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 10 Jun 2026 11:32:03 +0200 Subject: [PATCH 3/6] refactor(core): unify inline handling into one pass; nest property inline too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the two separate inline mechanisms (ModelGenerator's collectAllInlineSchemas + the operation-only planner) with a single ownership-aware pass, planInlineTypes, that lifts every inline object schema — operation request/response bodies AND object-typed properties, recursively — into PlannedInlineType trees and rewrites the spec to references. - Parser: TypeRef.Inline no longer carries a stringly-typed contextHint; inline detection is purely structural. Naming/placement is now a pure generation concern. - Operation bodies nest in the client class (as before); object properties now nest in their parent data class (e.g. Pet.Address), recursively for nested inline objects. - No structural dedup: each occurrence is named/placed relative to its owner. Removes InlineSchemaKey, InlineTypeResolver (resolveInlineTypes), collectInlineTypeRefs. - Shared recursive emitter ModelGenerator.emitNestedInline registers reference ids -> nested ClassNames so resolution stays consistent. Refs #92 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../justworks/core/gen/CodeGenerator.kt | 12 +- .../avsystem/justworks/core/gen/Hierarchy.kt | 9 +- .../justworks/core/gen/InlineSchemaKey.kt | 52 ----- .../justworks/core/gen/InlineTypeResolver.kt | 75 -------- .../justworks/core/gen/PlannedInlineType.kt | 141 +++++++++----- .../com/avsystem/justworks/core/gen/Utils.kt | 2 +- .../core/gen/client/ClientGenerator.kt | 8 +- .../core/gen/model/ModelGenerator.kt | 147 ++++----------- .../avsystem/justworks/core/model/TypeRef.kt | 6 +- .../justworks/core/parser/SpecParser.kt | 48 +++-- .../justworks/core/gen/ClientGeneratorTest.kt | 9 +- .../justworks/core/gen/CodeGeneratorTest.kt | 44 +++++ .../core/gen/InlineSchemaDedupTest.kt | 158 ---------------- .../core/gen/InlineTypeResolverTest.kt | 177 ------------------ .../justworks/core/gen/IntegrationTest.kt | 34 ++-- .../core/gen/ModelGeneratorRegressionTest.kt | 15 +- .../justworks/core/gen/ModelGeneratorTest.kt | 41 ---- .../core/gen/OperationInlineTypesTest.kt | 106 ----------- .../justworks/core/gen/TypeMappingTest.kt | 1 - 19 files changed, 238 insertions(+), 847 deletions(-) delete mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaKey.kt delete mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineTypeResolver.kt delete mode 100644 core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDedupTest.kt delete mode 100644 core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineTypeResolverTest.kt delete mode 100644 core/src/test/kotlin/com/avsystem/justworks/core/gen/OperationInlineTypesTest.kt 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 a35c0696..7459638e 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt @@ -20,16 +20,16 @@ object CodeGenerator { apiPackage: String, outputDir: File, ): Result { - // Lift inline operation request/response bodies out into per-operation types that - // ClientGenerator will nest inside the owning client class. - val (plannedSpec, operationInlineTypes) = planOperationInlineTypes(spec) + // Lift inline object schemas (operation bodies + object properties) into nested types. + val plan = planInlineTypes(spec) val hierarchy = Hierarchy(ModelPackage(modelPackage)).apply { - addSchemas(plannedSpec.schemas) + addSchemas(plan.spec.schemas) + modelInline = plan.modelInline } val (modelFiles, resolvedSpec) = context(hierarchy, NameRegistry()) { - ModelGenerator.generateWithResolvedSpec(plannedSpec) + ModelGenerator.generateWithResolvedSpec(plan.spec) } modelFiles.forEach { it.writeTo(outputDir) } @@ -37,7 +37,7 @@ object CodeGenerator { val hasPolymorphicTypes = modelFiles.any { it.name == SERIALIZERS_MODULE.simpleName } val clientFiles = context(hierarchy, ApiPackage(apiPackage), NameRegistry()) { - ClientGenerator.generate(resolvedSpec, hasPolymorphicTypes, operationInlineTypes) + ClientGenerator.generate(resolvedSpec, hasPolymorphicTypes, plan.clientInline) } clientFiles.forEach { it.writeTo(outputDir) } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt index aabc409f..2d63107c 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt @@ -11,12 +11,15 @@ internal class Hierarchy(val modelPackage: ModelPackage) { private val memoScope = MemoScope() /** - * Resolution overrides for inline operation body types nested inside client classes, - * keyed by the reference id assigned by [planOperationInlineTypes]. + * Resolution overrides for inline body types nested inside client/model classes, + * keyed by the reference id assigned by [planInlineTypes]. */ private val inlineRefs = mutableMapOf() - /** Registers the nested [ClassName] an inline-operation reference id resolves to. */ + /** Inline object types to nest inside a component data class, keyed by the component name. */ + var modelInline: Map> = emptyMap() + + /** Registers the nested [ClassName] an inline reference id resolves to. */ fun registerInlineRef(id: String, className: ClassName) { inlineRefs[id] = className } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaKey.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaKey.kt deleted file mode 100644 index dd6047a7..00000000 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaKey.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.avsystem.justworks.core.gen - -import com.avsystem.justworks.core.model.PropertyModel -import com.avsystem.justworks.core.model.TypeRef - -/** - * Key for structural equality of inline schemas. - * Two inline schemas are considered equal if they have the same properties - * (name, type, required status, nullable status) regardless of property order. - * Nested [TypeRef.Inline] types are normalized to ignore [TypeRef.Inline.contextHint], - * ensuring purely structural comparison. - */ -internal data class InlineSchemaKey(val properties: Set) { - data class PropertyKey( - val name: String, - val type: TypeRef, - val required: Boolean, - val nullable: Boolean, - val defaultValue: Any?, - ) - - companion object { - fun from(properties: List, required: Set): InlineSchemaKey { - val keys = properties.map { - PropertyKey( - name = it.name, - type = normalizeType(it.type), - required = it.name in required, - nullable = it.nullable, - defaultValue = it.defaultValue, - ) - } - return InlineSchemaKey(keys.toSet()) - } - - private fun normalizeType(type: TypeRef): TypeRef = when (type) { - is TypeRef.Inline -> TypeRef.Inline( - properties = type.properties - .map { it.copy(type = normalizeType(it.type)) } - .sortedBy { it.name }, - requiredProperties = type.requiredProperties, - contextHint = "", - ) - - is TypeRef.Array -> TypeRef.Array(normalizeType(type.items)) - - is TypeRef.Map -> TypeRef.Map(normalizeType(type.valueType)) - - else -> type - } - } -} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineTypeResolver.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineTypeResolver.kt deleted file mode 100644 index f81d4dca..00000000 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineTypeResolver.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.avsystem.justworks.core.gen - -import com.avsystem.justworks.core.model.ApiSpec -import com.avsystem.justworks.core.model.Endpoint -import com.avsystem.justworks.core.model.PropertyModel -import com.avsystem.justworks.core.model.RequestBody -import com.avsystem.justworks.core.model.Response -import com.avsystem.justworks.core.model.SchemaModel -import com.avsystem.justworks.core.model.TypeRef - -/** - * Resolves a single [TypeRef.Inline] to [TypeRef.Reference] using the provided [nameMap]. - * Non-inline types are returned as-is; containers ([TypeRef.Array], [TypeRef.Map]) are resolved recursively. - */ -internal fun ApiSpec.resolveTypeRef(type: TypeRef, nameMap: Map): TypeRef = when (type) { - is TypeRef.Inline -> { - val key = InlineSchemaKey.from(type.properties, type.requiredProperties) - val className = nameMap[key] - ?: error( - "Missing inline schema mapping for key (contextHint=${type.contextHint}). " + - "This indicates a mismatch between inline schema collection and resolution.", - ) - TypeRef.Reference(className) - } - - is TypeRef.Array -> { - TypeRef.Array(resolveTypeRef(type.items, nameMap)) - } - - is TypeRef.Map -> { - TypeRef.Map(resolveTypeRef(type.valueType, nameMap)) - } - - else -> { - type - } -} - -/** - * Rewrites all [TypeRef.Inline] references in an [ApiSpec] to [TypeRef.Reference], - * using the provided [nameMap] that maps structural keys to generated class names. - * - * This is applied once after inline schema collection, so downstream generators - * never encounter [TypeRef.Inline] and need no special handling. - */ -internal fun ApiSpec.resolveInlineTypes(nameMap: Map): ApiSpec { - if (nameMap.isEmpty()) return this - - fun TypeRef.resolve(): TypeRef = resolveTypeRef(this, nameMap) - - fun PropertyModel.resolve() = copy(type = type.resolve()) - - fun SchemaModel.resolve() = copy( - properties = properties.map { it.resolve() }, - allOf = allOf?.map { it.resolve() }, - oneOf = oneOf?.map { it.resolve() }, - anyOf = anyOf?.map { it.resolve() }, - underlyingType = underlyingType?.resolve(), - ) - - fun Response.resolve() = copy(schema = schema?.resolve()) - - fun RequestBody.resolve() = copy(schema = schema.resolve()) - - fun Endpoint.resolve() = copy( - parameters = parameters.map { it.copy(schema = it.schema.resolve()) }, - requestBody = requestBody?.resolve(), - responses = responses.mapValues { (_, v) -> v.resolve() }, - ) - - return copy( - schemas = schemas.map { it.resolve() }, - endpoints = endpoints.map { it.resolve() }, - ) -} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/PlannedInlineType.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/PlannedInlineType.kt index a7673c36..fa5ac3d9 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/PlannedInlineType.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/PlannedInlineType.kt @@ -5,84 +5,123 @@ import com.avsystem.justworks.core.model.SchemaModel import com.avsystem.justworks.core.model.TypeRef /** - * An inline (anonymous) request/response body schema lifted out of an operation so it can - * be generated as a type nested inside the owning client class. + * An inline (anonymous) object schema lifted out so it can be generated as a type nested + * inside its owner (a client class for operation bodies, or a parent data class for + * properties). Forms a tree: [children] are inline objects nested within this one. * * @param id stable reference id stored in [TypeRef.Reference] in place of the inline schema; * the generator resolves it to the nested [com.squareup.kotlinpoet.ClassName] via [Hierarchy]. - * @param simpleName the nested type's simple name (e.g. `DomainsUpdateRequest`). - * @param schema the schema to generate the nested data class from. + * @param simpleName the nested type's simple name (e.g. `DomainsUpdateRequest`, `Address`). + * @param schema the schema to generate the nested data class from, with its own inline + * properties already rewritten to references pointing at [children]. */ internal data class PlannedInlineType( val id: String, val simpleName: String, val schema: SchemaModel, + val children: List, ) /** - * Rewrites operation request/response bodies that are [TypeRef.Inline] into - * [TypeRef.Reference]s pointing at per-operation ids, and returns, per operationId, - * the inline types that must be generated nested inside that operation's client class. + * Result of [planInlineTypes]: the spec with every [TypeRef.Inline] rewritten to a + * [TypeRef.Reference], plus the inline types to nest, grouped by owner. + */ +internal data class InlinePlan( + val spec: ApiSpec, + /** Operation id -> inline body types to nest inside that operation's client class. */ + val clientInline: Map>, + /** Component schema name -> inline property types to nest inside that data class. */ + val modelInline: Map>, +) + +/** + * Single pass over the spec that lifts every inline object schema (operation request/response + * bodies and object-typed properties, recursively) into [PlannedInlineType] trees and rewrites + * the spec to reference them. * - * No structural deduplication: each operation gets its own copy, named after the - * operation, so identical shapes across operations do not collapse into one shared type. + * No structural deduplication: each occurrence gets its own copy, named after its position, so + * inline types are placed (and named) relative to the type that owns them. */ -internal fun planOperationInlineTypes(spec: ApiSpec): Pair>> { - val byOperation = mutableMapOf>() - - fun lift( - operationId: String, - role: String, - simpleName: String, - inline: TypeRef.Inline - ): TypeRef.Reference { - val id = "$operationId#$role" - byOperation.getOrPut(operationId) { mutableListOf() }.add( - PlannedInlineType( - id = id, - simpleName = simpleName, - schema = SchemaModel( - name = simpleName, - description = null, - properties = inline.properties, - requiredProperties = inline.requiredProperties, - allOf = null, - oneOf = null, - anyOf = null, - discriminator = null, - ), - ), - ) - return TypeRef.Reference(id) +internal fun planInlineTypes(spec: ApiSpec): InlinePlan { + var counter = 0 + + // Rewrites a type (and inline objects nested in arrays/maps) to references, returning the + // rewritten type and, if an inline object was lifted, its PlannedInlineType. + fun plan(type: TypeRef, hint: String): Pair = when (type) { + is TypeRef.Inline -> { + val id = "inline${counter++}" + val children = mutableListOf() + val newProperties = type.properties.map { property -> + val (newType, child) = plan(property.type, property.name.toPascalCase()) + if (child != null) children.add(child) + property.copy(type = newType) + } + val schema = SchemaModel( + name = hint, + description = null, + properties = newProperties, + requiredProperties = type.requiredProperties, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + TypeRef.Reference(id) to PlannedInlineType(id, hint, schema, children) + } + + is TypeRef.Array -> { + plan(type.items, "${hint}Item").let { (item, child) -> TypeRef.Array(item) to child } + } + + is TypeRef.Map -> { + plan(type.valueType, "${hint}Value").let { (value, child) -> TypeRef.Map(value) to child } + } + + else -> { + type to null + } } + val clientInline = mutableMapOf>() + val modelInline = mutableMapOf>() + val newEndpoints = spec.endpoints.map { endpoint -> val opName = endpoint.operationId.toPascalCase() + val owned = mutableListOf() val newRequestBody = endpoint.requestBody?.let { body -> - val schema = body.schema - if (schema is TypeRef.Inline) { - body.copy(schema = lift(endpoint.operationId, "request", "${opName}Request", schema)) - } else { - body - } + val (newType, planned) = plan(body.schema, "${opName}Request") + if (planned != null) owned.add(planned) + body.copy(schema = newType) } val newResponses = endpoint.responses.mapValues { (code, response) -> - val schema = response.schema - if (schema is TypeRef.Inline) { - response.copy( - schema = lift(endpoint.operationId, "response#$code", responseTypeName(opName, code), schema), - ) - } else { - response - } + val schema = response.schema ?: return@mapValues response + val (newType, planned) = plan(schema, responseTypeName(opName, code)) + if (planned != null) owned.add(planned) + response.copy(schema = newType) } + if (owned.isNotEmpty()) clientInline[endpoint.operationId] = owned endpoint.copy(requestBody = newRequestBody, responses = newResponses) } - return spec.copy(endpoints = newEndpoints) to byOperation + val newSchemas = spec.schemas.map { schema -> + val owned = mutableListOf() + val newProperties = schema.properties.map { property -> + val (newType, planned) = plan(property.type, property.name.toPascalCase()) + if (planned != null) owned.add(planned) + property.copy(type = newType) + } + if (owned.isNotEmpty()) modelInline[schema.name] = owned + schema.copy(properties = newProperties) + } + + return InlinePlan( + spec = spec.copy(endpoints = newEndpoints, schemas = newSchemas), + clientInline = clientInline, + modelInline = modelInline, + ) } private fun responseTypeName(opName: String, code: String): String = when { 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 index 9d326ebb..d4187b29 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Utils.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Utils.kt @@ -59,7 +59,7 @@ internal fun TypeRef.toTypeName(): TypeName = when (this) { } is TypeRef.Inline -> { - error("TypeRef.Inline should have been resolved by InlineTypeResolver (contextHint=$contextHint)") + error("TypeRef.Inline should have been lifted and rewritten to a reference by planInlineTypes") } is TypeRef.Unknown -> { diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt index 0a790132..3e066ef0 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt @@ -61,7 +61,7 @@ internal object ClientGenerator { fun generate( spec: ApiSpec, hasPolymorphicTypes: Boolean, - operationInlineTypes: Map> = emptyMap(), + operationInlineTypes: Map>, ): List { val grouped = spec.endpoints.groupBy { it.tags.firstOrNull() ?: DEFAULT_TAG } return grouped.map { (tag, endpoints) -> @@ -155,13 +155,11 @@ internal object ClientGenerator { // Nest each operation's inline request/response body types inside the client class, // registering their reference ids so endpoint signatures resolve to the nested classes. // Done before generating functions so type resolution sees the registered names. - val nestedNameRegistry = NameRegistry() + val nestedNames = NameRegistry() endpoints .flatMap { operationInlineTypes[it.operationId].orEmpty() } .forEach { planned -> - val nestedName = nestedNameRegistry.register(planned.simpleName) - hierarchy.registerInlineRef(planned.id, className.nestedClass(nestedName)) - classBuilder.addType(ModelGenerator.buildNestedBodyType(nestedName, planned.schema)) + classBuilder.addType(ModelGenerator.emitNestedInline(className, planned, nestedNames)) } context(NameRegistry()) { 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 8fcf7376..daa79d0b 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 @@ -7,7 +7,6 @@ import com.avsystem.justworks.core.gen.EXPERIMENTAL_SERIALIZATION_API import com.avsystem.justworks.core.gen.EXPERIMENTAL_UUID_API import com.avsystem.justworks.core.gen.Hierarchy import com.avsystem.justworks.core.gen.INSTANT -import com.avsystem.justworks.core.gen.InlineSchemaKey import com.avsystem.justworks.core.gen.JSON_CLASS_DISCRIMINATOR import com.avsystem.justworks.core.gen.JSON_CONTENT_POLYMORPHIC_SERIALIZER import com.avsystem.justworks.core.gen.JSON_ELEMENT @@ -18,6 +17,7 @@ import com.avsystem.justworks.core.gen.NameRegistry import com.avsystem.justworks.core.gen.OPT_IN import com.avsystem.justworks.core.gen.PRIMITIVE_KIND import com.avsystem.justworks.core.gen.PRIMITIVE_SERIAL_DESCRIPTOR_FUN +import com.avsystem.justworks.core.gen.PlannedInlineType import com.avsystem.justworks.core.gen.SERIALIZABLE import com.avsystem.justworks.core.gen.SERIALIZATION_EXCEPTION import com.avsystem.justworks.core.gen.SERIALIZERS_MODULE @@ -29,9 +29,7 @@ import com.avsystem.justworks.core.gen.UUID_TYPE import com.avsystem.justworks.core.gen.invoke import com.avsystem.justworks.core.gen.model.ModelGenerator.buildNestedVariant import com.avsystem.justworks.core.gen.model.ModelGenerator.generateDataClass -import com.avsystem.justworks.core.gen.resolveInlineTypes import com.avsystem.justworks.core.gen.resolveSerialName -import com.avsystem.justworks.core.gen.resolveTypeRef import com.avsystem.justworks.core.gen.shared.SerializersModuleGenerator import com.avsystem.justworks.core.gen.toCamelCase import com.avsystem.justworks.core.gen.toEnumConstantName @@ -73,93 +71,58 @@ internal object ModelGenerator { context(_: Hierarchy, _: NameRegistry) fun generate(spec: ApiSpec): List = generateWithResolvedSpec(spec).files - context(hierarchy: Hierarchy, nameRegistry: NameRegistry) + context(hierarchy: Hierarchy, _: NameRegistry) fun generateWithResolvedSpec(spec: ApiSpec): GenerateResult { - ensureReserved(spec, nameRegistry) - val (inlineSchemas, nameMap) = collectAllInlineSchemas(spec) - val resolvedSpec = spec.resolveInlineTypes(nameMap) - - val resolvedInlineSchemas = inlineSchemas.map { schema -> - schema.copy( - properties = schema.properties.map { prop -> - prop.copy(type = resolvedSpec.resolveTypeRef(prop.type, nameMap)) - }, - ) - } - - hierarchy.addSchemas(resolvedSpec.schemas + resolvedInlineSchemas) - + // Inline schemas have already been lifted and rewritten to references by planInlineTypes; + // the inline types to nest are available via hierarchy.modelInline. val nestedVariantNames = hierarchy.sealedHierarchies .asSequence() .filterNot { (key, _) -> key in hierarchy.anyOfWithoutDiscriminator } .flatMap { (_, names) -> names } .toSet() - val schemaFiles = resolvedSpec.schemas + val schemaFiles = spec.schemas .asSequence() .filterNot { it.name in nestedVariantNames } .flatMap { generateSchemaFiles(it) } .toList() - val inlineSchemaFiles = resolvedInlineSchemas.map { generateDataClass(it) } - - val enumFiles = resolvedSpec.enums.map { generateEnumClass(it) } + val enumFiles = spec.enums.map { generateEnumClass(it) } val serializersModuleFile = SerializersModuleGenerator.generate() - val uuidSerializerFile = if (resolvedSpec.usesUuid()) generateUuidSerializer() else null + val uuidSerializerFile = if (spec.usesUuid()) generateUuidSerializer() else null - val files = - schemaFiles + inlineSchemaFiles + enumFiles + listOfNotNull(serializersModuleFile, uuidSerializerFile) + val files = schemaFiles + enumFiles + listOfNotNull(serializersModuleFile, uuidSerializerFile) - return GenerateResult(files, resolvedSpec) + return GenerateResult(files, spec) } /** - * Ensures all top-level schema/enum names are reserved in [nameRegistry], - * preventing inline schemas from colliding with component types even if - * the caller supplied an empty registry. + * Recursively builds a `@Serializable data class` [TypeSpec] for a lifted inline type, + * nesting its child inline types and registering each reference id with [hierarchy] so + * properties referencing them resolve to the nested [ClassName]. */ - private fun ensureReserved(spec: ApiSpec, nameRegistry: NameRegistry) { - spec.schemas.forEach { nameRegistry.reserve(it.name) } - spec.enums.forEach { nameRegistry.reserve(it.name) } - nameRegistry.reserve(UUID_SERIALIZER.simpleName) - nameRegistry.reserve(SERIALIZERS_MODULE.simpleName) - } - - context(nameRegistry: NameRegistry) - private fun collectAllInlineSchemas(spec: ApiSpec): Pair, Map> { - val endpointRefs = spec.endpoints.flatMap { endpoint -> - val requestRef = endpoint.requestBody?.schema - val responseRefs = endpoint.responses.values.map { it.schema } - responseRefs + requestRef - } - - val schemaPropertyRefs = spec.schemas.flatMap { schema -> schema.properties.map { it.type } } - - val nameMap = mutableMapOf() + context(hierarchy: Hierarchy) + internal fun emitNestedInline( + parentClass: ClassName, + planned: PlannedInlineType, + siblingNames: NameRegistry, + ): TypeSpec { + val name = siblingNames.register(planned.simpleName) + val className = parentClass.nestedClass(name) + hierarchy.registerInlineRef(planned.id, className) - val schemas = collectInlineTypeRefs(endpointRefs + schemaPropertyRefs) - .asSequence() - .sortedBy { it.contextHint } - .distinctBy { InlineSchemaKey.from(it.properties, it.requiredProperties) } - .map { ref -> - val key = InlineSchemaKey.from(ref.properties, ref.requiredProperties) - val generatedName = nameRegistry.register(ref.contextHint.toInlinedName()) - nameMap[key] = generatedName - SchemaModel( - name = generatedName, - description = null, - properties = ref.properties, - requiredProperties = ref.requiredProperties, - allOf = null, - oneOf = null, - anyOf = null, - discriminator = null, - ) - }.toList() + val childNames = NameRegistry() + val childSpecs = planned.children.map { emitNestedInline(className, it, childNames) } - return schemas to nameMap + val builder = TypeSpec + .classBuilder(name) + .addModifiers(KModifier.DATA) + .addAnnotation(SERIALIZABLE) + buildConstructorAndProperties(planned.schema, builder) + childSpecs.forEach { builder.addType(it) } + return builder.build() } context(hierarchy: Hierarchy) @@ -277,21 +240,6 @@ internal object ModelGenerator { return builder.build() } - /** - * Builds a `@Serializable data class` [TypeSpec] for an inline operation body schema, - * suitable for nesting inside a client class. Carries no superinterfaces or discriminator - * wiring — operation bodies are plain anonymous objects. - */ - context(_: Hierarchy) - internal fun buildNestedBodyType(simpleName: String, schema: SchemaModel): TypeSpec { - val builder = TypeSpec - .classBuilder(simpleName) - .addModifiers(KModifier.DATA) - .addAnnotation(SERIALIZABLE) - buildConstructorAndProperties(schema, builder) - return builder.build() - } - /** * Builds primary constructor and data class properties from a schema's property list. * Shared by [generateDataClass] and [buildNestedVariant]. @@ -490,7 +438,15 @@ internal object ModelGenerator { ) } + // Nest this schema's inline property types, registering their ids before resolving + // properties so references to them resolve to the nested classes. + val nestedNames = NameRegistry() + val nestedSpecs = hierarchy.modelInline[schema.name] + .orEmpty() + .map { emitNestedInline(className, it, nestedNames) } + buildConstructorAndProperties(schema, typeSpec) + nestedSpecs.forEach { typeSpec.addType(it) } val fileBuilder = FileSpec.builder(className).addType(typeSpec.build()) @@ -594,33 +550,6 @@ internal object ModelGenerator { .build() } - /** - * Iteratively collects all [TypeRef.Inline] instances from a [TypeRef] tree. - */ - private fun collectInlineTypeRefs(initialTodo: List): List { - val todo = ArrayDeque(initialTodo.filterNotNull()) - val visited = linkedSetOf() - - while (todo.isNotEmpty()) { - when (val current = todo.removeFirst()) { - is TypeRef.Inline if visited.add(current) -> { - todo.addAll(current.properties.map { it.type }) - } - - is TypeRef.Array -> { - todo.addFirst(current.items) - } - - is TypeRef.Map -> { - todo.addFirst(current.valueType) - } - - else -> {} - } - } - return visited.toList() - } - private val SchemaModel.isPrimitiveOnly: Boolean get() = properties.isEmpty() && allOf == null && oneOf == null && anyOf == null diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt b/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt index 17d5461d..5e851fa0 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt @@ -9,11 +9,7 @@ sealed interface TypeRef { data class Map(val valueType: TypeRef) : TypeRef - data class Inline( - val properties: List, - val requiredProperties: Set, - val contextHint: String, // "request"|"response"|property name for context-aware naming - ) : TypeRef + data class Inline(val properties: List, val requiredProperties: Set,) : TypeRef data object Unknown : TypeRef } 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 535e9fc3..767d3e14 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 @@ -263,7 +263,7 @@ object SpecParser { val mediaType = content[contentType].bind() val schema = mediaType.schema - ?.toTypeRef("${operationId.replaceFirstChar { it.uppercase() }}Request") + ?.toTypeRef() .bind() RequestBody( @@ -282,7 +282,7 @@ object SpecParser { schema = resp.content ?.get(ContentType.JSON_CONTENT_TYPE.value) ?.schema - ?.toTypeRef("${operationId.replaceFirstChar { it.uppercase() }}Response"), + ?.toTypeRef(), ) } @@ -330,7 +330,7 @@ object SpecParser { } else { val requiredProps = schema.required.orEmpty().toSet() val props = schema - .propertyModels(requiredProps) { propName -> "$name.${propName.toPascalCase()}" } + .propertyModels(requiredProps) .values .toList() props to requiredProps @@ -384,17 +384,16 @@ object SpecParser { context(_: ComponentSchemaIdentity, _: ComponentSchemas) private fun extractAllOfProperties(parentName: String, schema: Schema<*>): Pair, Set> { val topRequired = schema.required.orEmpty().toSet() - val contextCreator: (String) -> String? = { propName -> "$parentName.${propName.toPascalCase()}" } val (required, properties) = schema.allOf .orEmpty() .fold(topRequired to emptyMap()) { (accRequired, accProperties), subSchema -> val resolvedSchema = subSchema.resolveSubSchema() val mergedRequired = accRequired + resolvedSchema.required.orEmpty().toSet() - mergedRequired to accProperties + resolvedSchema.propertyModels(mergedRequired, contextCreator) + mergedRequired to accProperties + resolvedSchema.propertyModels(mergedRequired) } - val topLevelProperties = schema.propertyModels(required, contextCreator) + val topLevelProperties = schema.propertyModels(required) val finalProperties = properties.plus(topLevelProperties).values.map { prop -> prop.copy(nullable = prop.name !in required) } @@ -451,14 +450,14 @@ object SpecParser { } context(_: ComponentSchemaIdentity, _: ComponentSchemas) - private fun Schema<*>.toTypeRef(contextName: String? = null): TypeRef = contextName?.let { toInlineTypeRef(it) } + private fun Schema<*>.toTypeRef(): TypeRef = toInlineTypeRef() ?: (resolveName() ?: allOf?.singleOrNull()?.resolveName())?.let(TypeRef::Reference) ?: TypeRef.Unknown.takeIf { (allOf?.size ?: 0) > 1 } - ?: resolveByType(contextName) + ?: resolveByType() /** Resolves a [TypeRef] based on the schema's structural type/format, ignoring component identity. */ context(_: ComponentSchemaIdentity, _: ComponentSchemas) - private fun Schema<*>.resolveByType(contextName: String? = null): TypeRef = when (type) { + private fun Schema<*>.resolveByType(): TypeRef = when (type) { "string" -> STRING_FORMAT_MAP[format] ?: TypeRef.Primitive(PrimitiveType.STRING) "integer" -> INTEGER_FORMAT_MAP[format] ?: TypeRef.Primitive(PrimitiveType.INT) @@ -467,7 +466,7 @@ object SpecParser { "boolean" -> TypeRef.Primitive(PrimitiveType.BOOLEAN) - "array" -> TypeRef.Array(items?.toTypeRef(contextName?.let { "${it}Item" }) ?: TypeRef.Unknown) + "array" -> TypeRef.Array(items?.toTypeRef() ?: TypeRef.Unknown) "object" -> when (val ap = additionalProperties) { is Schema<*> -> TypeRef.Map(ap.toTypeRef()) @@ -478,13 +477,13 @@ object SpecParser { else -> TypeRef.Unknown } + /** An anonymous object schema becomes an [TypeRef.Inline]; the generator decides its name and placement. */ context(_: ComponentSchemaIdentity, _: ComponentSchemas) - private fun Schema<*>.toInlineTypeRef(contextName: String): TypeRef? = takeIf { isInlineObject }?.let { + private fun Schema<*>.toInlineTypeRef(): TypeRef? = takeIf { isInlineObject }?.let { val required = required.orEmpty().toSet() TypeRef.Inline( - properties = propertyModels(required) { "$contextName.${it.toPascalCase()}" }.values.toList(), + properties = propertyModels(required).values.toList(), requiredProperties = required, - contextHint = contextName, ) } @@ -499,18 +498,17 @@ object SpecParser { private val Schema<*>.isEnumSchema get(): Boolean = !enum.isNullOrEmpty() context(_: ComponentSchemaIdentity, _: ComponentSchemas) - private fun Schema<*>.propertyModels(required: Set, createContext: (String) -> String? = { null }) = - properties - .orEmpty() - .mapValues { (propName, propSchema) -> - PropertyModel( - name = propName, - type = propSchema.toTypeRef(createContext(propName)), - description = propSchema.description, - nullable = propName !in required, - defaultValue = propSchema.default, - ) - } + private fun Schema<*>.propertyModels(required: Set) = properties + .orEmpty() + .mapValues { (propName, propSchema) -> + PropertyModel( + name = propName, + type = propSchema.toTypeRef(), + description = propSchema.description, + nullable = propName !in required, + defaultValue = propSchema.default, + ) + } context(_: Warnings) private fun warnOnUnknownTypes(endpoints: List, schemas: List) { 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 c98d642a..61f66415 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 @@ -36,7 +36,7 @@ class ClientGeneratorTest { ApiPackage(apiPackage), NameRegistry(), ) { - ClientGenerator.generate(spec, hasPolymorphicTypes) + ClientGenerator.generate(spec, hasPolymorphicTypes, emptyMap()) } private fun spec(vararg endpoints: Endpoint) = spec(endpoints.toList()) @@ -520,7 +520,6 @@ class ClientGeneratorTest { PropertyModel("description", TypeRef.Primitive(PrimitiveType.STRING), null, false), ), requiredProperties = setOf("file", "description"), - contextHint = "request", ), ), ) @@ -544,7 +543,6 @@ class ClientGeneratorTest { PropertyModel("file", TypeRef.Primitive(PrimitiveType.BYTE_ARRAY), null, false), ), requiredProperties = setOf("file"), - contextHint = "request", ), ), ) @@ -570,7 +568,6 @@ class ClientGeneratorTest { PropertyModel("description", TypeRef.Primitive(PrimitiveType.STRING), null, false), ), requiredProperties = setOf("file", "description"), - contextHint = "request", ), ), ) @@ -593,7 +590,6 @@ class ClientGeneratorTest { PropertyModel("file", TypeRef.Primitive(PrimitiveType.BYTE_ARRAY), null, false), ), requiredProperties = setOf("file"), - contextHint = "request", ), ), ) @@ -670,7 +666,6 @@ class ClientGeneratorTest { PropertyModel("age", TypeRef.Primitive(PrimitiveType.INT), null, false), ), requiredProperties = setOf("username", "age"), - contextHint = "request", ), ), ) @@ -699,7 +694,6 @@ class ClientGeneratorTest { PropertyModel("age", TypeRef.Primitive(PrimitiveType.INT), null, false), ), requiredProperties = setOf("username", "age"), - contextHint = "request", ), ), ) @@ -724,7 +718,6 @@ class ClientGeneratorTest { PropertyModel("nickname", TypeRef.Primitive(PrimitiveType.STRING), null, false), ), requiredProperties = setOf("username"), - contextHint = "request", ), ), ) diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/CodeGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/CodeGeneratorTest.kt index b666e59e..e0b10f20 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/CodeGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/CodeGeneratorTest.kt @@ -120,4 +120,48 @@ class CodeGeneratorTest { outputDir.deleteRecursively() } } + + @Test + fun `inline object properties are nested inside the parent model type`() { + val yaml = """ + openapi: "3.0.0" + info: { title: T, version: "1" } + paths: {} + components: + schemas: + DataSetList: + type: object + properties: + total: { type: integer, format: int32 } + address: + type: object + properties: + street: { type: string } + """.trimIndent() + val specFile = File.createTempFile("nested-prop", ".yaml").apply { + writeText(yaml) + deleteOnExit() + } + val spec = when (val r = SpecParser.parse(specFile)) { + is ParseResult.Success -> r.value + is ParseResult.Failure -> fail("parse failed: ${r.error}") + } + + val outputDir = Files.createTempDirectory("codegen-prop").toFile() + try { + CodeGenerator.generate(spec, "com.example.model", "com.example.api", outputDir) + + val modelDir = outputDir.resolve("com/example/model") + val files = modelDir.listFiles()?.map { it.name }.orEmpty() + assertTrue(files.contains("DataSetList.kt"), "got: $files") + // The inline `address` object is nested in DataSetList, not a separate file. + assertTrue(files.none { it.contains("Address") }, "inline property should not be top-level, got: $files") + + val src = modelDir.resolve("DataSetList.kt").readText() + assertTrue(src.contains("data class Address"), "address should be nested in DataSetList") + assertTrue(src.contains("address: Address"), "property should reference the nested type") + } finally { + outputDir.deleteRecursively() + } + } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDedupTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDedupTest.kt deleted file mode 100644 index 65c22e05..00000000 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDedupTest.kt +++ /dev/null @@ -1,158 +0,0 @@ -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 kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals - -class InlineSchemaDedupTest { - @Test - fun `identical schemas return same name via InlineSchemaKey`() { - val props1 = listOf( - PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, false), - PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), - ) - val required = setOf("id", "name") - - val props2 = listOf( - PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, false), - PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), - ) - - val key1 = InlineSchemaKey.from(props1, required) - val key2 = InlineSchemaKey.from(props2, required) - - assertEquals(key1, key2) - } - - @Test - fun `different schemas produce different keys`() { - val props1 = listOf( - PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, false), - ) - - val props2 = listOf( - PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), - ) - - val key1 = InlineSchemaKey.from(props1, setOf("id")) - val key2 = InlineSchemaKey.from(props2, setOf("name")) - - assertNotEquals(key1, key2) - } - - @Test - fun `name collision with component schema uses numeric suffix`() { - val registry = NameRegistry().apply { - reserve("Pet") - } - - val name = registry.register("Pet") - - assertEquals("Pet2", name) - } - - @Test - fun `property order does not affect equality`() { - val props1 = listOf( - PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), - PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, false), - ) - - val props2 = listOf( - PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, false), - PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), - ) - - val required = setOf("id", "name") - - val key1 = InlineSchemaKey.from(props1, required) - val key2 = InlineSchemaKey.from(props2, required) - - assertEquals(key1, key2) - } - - @Test - fun `different required sets produce different keys`() { - val props = listOf( - PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, false), - PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, true), - ) - - val key1 = InlineSchemaKey.from(props, setOf("id", "name")) - val key2 = InlineSchemaKey.from(props, setOf("id")) - - assertNotEquals(key1, key2) - } - - @Test - fun `nested inline schemas with different contextHints produce same key`() { - val nestedProps = listOf( - PropertyModel("street", TypeRef.Primitive(PrimitiveType.STRING), null, nullable = false), - ) - val props1 = listOf( - PropertyModel( - "address", - TypeRef.Inline(nestedProps, setOf("street"), "RequestAddress"), - null, - nullable = false, - ), - ) - val props2 = listOf( - PropertyModel( - "address", - TypeRef.Inline(nestedProps, setOf("street"), "ResponseAddress"), - null, - nullable = false, - ), - ) - - val key1 = InlineSchemaKey.from(props1, setOf("address")) - val key2 = InlineSchemaKey.from(props2, setOf("address")) - - assertEquals(key1, key2) - } - - @Test - fun `different nullable flags produce different keys`() { - val props1 = listOf( - PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, nullable = false), - ) - val props2 = listOf( - PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, nullable = true), - ) - - val key1 = InlineSchemaKey.from(props1, setOf("id")) - val key2 = InlineSchemaKey.from(props2, setOf("id")) - - assertNotEquals(key1, key2) - } - - @Test - fun `different defaultValues produce different keys`() { - val props1 = listOf( - PropertyModel("count", TypeRef.Primitive(PrimitiveType.INT), null, nullable = false, defaultValue = 0), - ) - val props2 = listOf( - PropertyModel("count", TypeRef.Primitive(PrimitiveType.INT), null, nullable = false, defaultValue = 10), - ) - - val key1 = InlineSchemaKey.from(props1, setOf("count")) - val key2 = InlineSchemaKey.from(props2, setOf("count")) - - assertNotEquals(key1, key2) - } - - @Test - fun `collision with existing inline schema name uses numeric suffix`() { - val registry = NameRegistry() - - val name1 = registry.register("Context") - assertEquals("Context", name1) - - val name2 = registry.register("Context") - assertEquals("Context2", name2) - } -} diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineTypeResolverTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineTypeResolverTest.kt deleted file mode 100644 index 1a55755c..00000000 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineTypeResolverTest.kt +++ /dev/null @@ -1,177 +0,0 @@ -package com.avsystem.justworks.core.gen - -import com.avsystem.justworks.core.model.ApiSpec -import com.avsystem.justworks.core.model.ContentType -import com.avsystem.justworks.core.model.Endpoint -import com.avsystem.justworks.core.model.HttpMethod -import com.avsystem.justworks.core.model.PrimitiveType -import com.avsystem.justworks.core.model.PropertyModel -import com.avsystem.justworks.core.model.RequestBody -import com.avsystem.justworks.core.model.Response -import com.avsystem.justworks.core.model.TypeRef -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith - -class InlineTypeResolverTest { - private fun emptySpec() = ApiSpec( - title = "test", - version = "1.0", - schemas = emptyList(), - enums = emptyList(), - endpoints = emptyList(), - securitySchemes = emptyList(), - ) - - private fun inlineType(vararg propNames: String, contextHint: String = "Test") = TypeRef.Inline( - properties = propNames.map { PropertyModel(it, TypeRef.Primitive(PrimitiveType.STRING), null, false) }, - requiredProperties = propNames.toSet(), - contextHint = contextHint, - ) - - private fun nameMapFor(vararg types: TypeRef.Inline): Map = - types.associate { InlineSchemaKey.from(it.properties, it.requiredProperties) to "${it.contextHint}Resolved" } - - @Test - fun `resolveTypeRef replaces Inline with Reference`() { - val spec = emptySpec() - val inline = inlineType("id") - val nameMap = nameMapFor(inline) - - val resolved = spec.resolveTypeRef(inline, nameMap) - - assertEquals(TypeRef.Reference("TestResolved"), resolved) - } - - @Test - fun `resolveTypeRef passes through Primitive unchanged`() { - val spec = emptySpec() - val primitive = TypeRef.Primitive(PrimitiveType.INT) - - val resolved = spec.resolveTypeRef(primitive, emptyMap()) - - assertEquals(primitive, resolved) - } - - @Test - fun `resolveTypeRef passes through Reference unchanged`() { - val spec = emptySpec() - val ref = TypeRef.Reference("Foo") - - val resolved = spec.resolveTypeRef(ref, emptyMap()) - - assertEquals(ref, resolved) - } - - @Test - fun `resolveTypeRef resolves Inline inside Array`() { - val spec = emptySpec() - val inline = inlineType("name") - val nameMap = nameMapFor(inline) - - val resolved = spec.resolveTypeRef(TypeRef.Array(inline), nameMap) - - assertEquals(TypeRef.Array(TypeRef.Reference("TestResolved")), resolved) - } - - @Test - fun `resolveTypeRef resolves Inline inside Map`() { - val spec = emptySpec() - val inline = inlineType("name") - val nameMap = nameMapFor(inline) - - val resolved = spec.resolveTypeRef(TypeRef.Map(inline), nameMap) - - assertEquals(TypeRef.Map(TypeRef.Reference("TestResolved")), resolved) - } - - @Test - fun `resolveTypeRef fails fast on missing mapping`() { - val spec = emptySpec() - val inline = inlineType("unknown") - - val error = assertFailsWith { - spec.resolveTypeRef(inline, emptyMap()) - } - assertEquals(true, error.message?.contains("Missing inline schema mapping")) - } - - @Test - fun `resolveInlineTypes returns spec unchanged when nameMap is empty`() { - val spec = emptySpec() - - val resolved = spec.resolveInlineTypes(emptyMap()) - - assertEquals(spec, resolved) - } - - @Test - fun `resolveInlineTypes resolves inline types in endpoint responses`() { - val inline = inlineType("status") - val nameMap = nameMapFor(inline) - - val spec = ApiSpec( - title = "test", - version = "1.0", - schemas = emptyList(), - enums = emptyList(), - securitySchemes = emptyList(), - endpoints = listOf( - Endpoint( - path = "/test", - method = HttpMethod.GET, - operationId = "getTest", - summary = null, - tags = emptyList(), - parameters = emptyList(), - requestBody = null, - responses = mapOf("200" to Response("200", null, inline)), - description = null, - ), - ), - ) - - val resolved = spec.resolveInlineTypes(nameMap) - - val resolvedResponseType = resolved.endpoints - .first() - .responses["200"] - ?.schema - assertEquals(TypeRef.Reference("TestResolved"), resolvedResponseType) - } - - @Test - fun `resolveInlineTypes resolves inline types in request body`() { - val inline = inlineType("payload") - val nameMap = nameMapFor(inline) - - val spec = ApiSpec( - title = "test", - version = "1.0", - schemas = emptyList(), - enums = emptyList(), - securitySchemes = emptyList(), - endpoints = listOf( - Endpoint( - path = "/test", - method = HttpMethod.POST, - operationId = "postTest", - summary = null, - tags = emptyList(), - parameters = emptyList(), - requestBody = RequestBody(true, ContentType.JSON_CONTENT_TYPE, inline), - responses = emptyMap(), - description = null, - ), - ), - ) - - val resolved = spec.resolveInlineTypes(nameMap) - - val resolvedRequestType = resolved.endpoints - .first() - .requestBody - ?.schema - assertEquals(TypeRef.Reference("TestResolved"), resolvedRequestType) - } -} diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt index 9cb17fce..5388af98 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 @@ -40,24 +40,26 @@ class IntegrationTest { } } - private fun generateModel(spec: ApiSpec): List = - context(Hierarchy(ModelPackage(modelPackage)).apply { addSchemas(spec.schemas) }, NameRegistry()) { - ModelGenerator.generate(spec) - } + private fun hierarchyFor(plan: InlinePlan) = Hierarchy(ModelPackage(modelPackage)).apply { + addSchemas(plan.spec.schemas) + modelInline = plan.modelInline + } - private fun generateModelWithResolvedSpec(spec: ApiSpec): ModelGenerator.GenerateResult = - context(Hierarchy(ModelPackage(modelPackage)).apply { addSchemas(spec.schemas) }, NameRegistry()) { - ModelGenerator.generateWithResolvedSpec(spec) - } + private fun generateModel(spec: ApiSpec): List { + val plan = planInlineTypes(spec) + return context(hierarchyFor(plan), NameRegistry()) { ModelGenerator.generate(plan.spec) } + } - private fun generateClient(spec: ApiSpec, hasPolymorphicTypes: Boolean = false): List = context( - Hierarchy(ModelPackage(modelPackage)).apply { - addSchemas(spec.schemas) - }, - ApiPackage(apiPackage), - NameRegistry(), - ) { - ClientGenerator.generate(spec, hasPolymorphicTypes) + private fun generateModelWithResolvedSpec(spec: ApiSpec): ModelGenerator.GenerateResult { + val plan = planInlineTypes(spec) + return context(hierarchyFor(plan), NameRegistry()) { ModelGenerator.generateWithResolvedSpec(plan.spec) } + } + + private fun generateClient(spec: ApiSpec, hasPolymorphicTypes: Boolean = false): List { + val plan = planInlineTypes(spec) + return context(hierarchyFor(plan), ApiPackage(apiPackage), NameRegistry()) { + ClientGenerator.generate(plan.spec, hasPolymorphicTypes, plan.clientInline) + } } @Test diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorRegressionTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorRegressionTest.kt index 09eaf6d0..d5bfeea7 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorRegressionTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorRegressionTest.kt @@ -11,13 +11,13 @@ import kotlin.test.Test class ModelGeneratorRegressionTest { private val modelPackage = "com.example.model" - private fun generate(spec: ApiSpec) = context( - Hierarchy(ModelPackage(modelPackage)).apply { - addSchemas(spec.schemas) - }, - NameRegistry(), - ) { - ModelGenerator.generate(spec) + private fun generate(spec: ApiSpec): List { + val plan = planInlineTypes(spec) + val hierarchy = Hierarchy(ModelPackage(modelPackage)).apply { + addSchemas(plan.spec.schemas) + modelInline = plan.modelInline + } + return context(hierarchy, NameRegistry()) { ModelGenerator.generate(plan.spec) } } private fun spec(schemas: List = emptyList()) = ApiSpec( @@ -61,7 +61,6 @@ class ModelGeneratorRegressionTest { PropertyModel("radius", TypeRef.Primitive(PrimitiveType.DOUBLE), null, false), ), requiredProperties = setOf("radius"), - contextHint = "Circle_config", ), description = null, nullable = false, 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 a43e7199..b6d5f1ef 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 @@ -1007,47 +1007,6 @@ class ModelGeneratorTest { assertTrue(serialName.members.any { it.toString().contains("\"keys\"") }) } - // -- ROB-01: Circular schema visited-set guard -- - - @Test - fun `collectInlineTypeRefs with nested inline TypeRef does not stack overflow`() { - // Build a schema model containing a deeply nested inline structure - // (inline -> property -> another inline with the same shape) - val innerInline = TypeRef.Inline( - properties = listOf( - PropertyModel("value", TypeRef.Primitive(PrimitiveType.STRING), null, false), - ), - requiredProperties = emptySet(), - contextHint = "treeNode", - ) - - val selfReferencingInline = TypeRef.Inline( - properties = listOf( - PropertyModel("children", TypeRef.Array(innerInline), null, true), - ), - requiredProperties = emptySet(), - contextHint = "treeNode", - ) - - val schema = SchemaModel( - name = "TreeNode", - description = null, - properties = listOf( - PropertyModel("value", TypeRef.Primitive(PrimitiveType.STRING), null, false), - PropertyModel("children", TypeRef.Array(selfReferencingInline), null, true), - ), - requiredProperties = setOf("value"), - allOf = null, - oneOf = null, - anyOf = null, - discriminator = null, - ) - - // Should complete without StackOverflowError - val files = generate(spec(schemas = listOf(schema))) - assertNotNull(files, "generate should return results without StackOverflowError") - } - // -- SER-02: Nullable/optional property defaults regression tests -- @Test diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/OperationInlineTypesTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/OperationInlineTypesTest.kt deleted file mode 100644 index 96f76d47..00000000 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/OperationInlineTypesTest.kt +++ /dev/null @@ -1,106 +0,0 @@ -package com.avsystem.justworks.core.gen - -import com.avsystem.justworks.core.model.ApiSpec -import com.avsystem.justworks.core.model.ContentType -import com.avsystem.justworks.core.model.Endpoint -import com.avsystem.justworks.core.model.HttpMethod -import com.avsystem.justworks.core.model.PrimitiveType -import com.avsystem.justworks.core.model.PropertyModel -import com.avsystem.justworks.core.model.RequestBody -import com.avsystem.justworks.core.model.Response -import com.avsystem.justworks.core.model.TypeRef -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class OperationInlineTypesTest { - private fun inline(vararg props: String) = TypeRef.Inline( - properties = props.map { PropertyModel(it, TypeRef.Primitive(PrimitiveType.STRING), null, false) }, - requiredProperties = emptySet(), - contextHint = "x", - ) - - private fun endpoint( - operationId: String, - requestBody: RequestBody? = null, - responses: Map = emptyMap(), - ) = Endpoint( - path = "/x", - method = HttpMethod.POST, - operationId = operationId, - summary = null, - description = null, - tags = listOf("domains"), - parameters = emptyList(), - requestBody = requestBody, - responses = responses, - ) - - private fun spec(vararg endpoints: Endpoint) = ApiSpec( - title = "T", - version = "1", - endpoints = endpoints.toList(), - schemas = emptyList(), - enums = emptyList(), - securitySchemes = emptyList(), - ) - - @Test - fun `lifts inline request and response into planned types named after the operation`() { - val ep = endpoint( - operationId = "domains_update", - requestBody = RequestBody(true, ContentType.JSON_CONTENT_TYPE, inline("name")), - responses = mapOf("200" to Response("200", "ok", inline("id"))), - ) - val (rewritten, byOp) = planOperationInlineTypes(spec(ep)) - - val planned = byOp.getValue("domains_update") - assertEquals( - listOf("DomainsUpdateRequest", "DomainsUpdateResponse"), - planned.map { it.simpleName }, - ) - - // Spec rewritten: inline schemas replaced by references to the planned ids. - val newEp = rewritten.endpoints.single() - val reqRef = assertIs(newEp.requestBody!!.schema) - assertEquals("domains_update#request", reqRef.schemaName) - val respRef = assertIs(newEp.responses.getValue("200").schema) - assertEquals("domains_update#response#200", respRef.schemaName) - } - - @Test - fun `non-2xx and default responses get distinct names`() { - val ep = endpoint( - operationId = "getThing", - responses = mapOf( - "200" to Response("200", "ok", inline("ok")), - "404" to Response("404", "nf", inline("err")), - "default" to Response("default", "def", inline("def")), - ), - ) - val (_, byOp) = planOperationInlineTypes(spec(ep)) - assertEquals( - setOf("GetThingResponse", "GetThingResponse404", "GetThingDefaultResponse"), - byOp.getValue("getThing").map { it.simpleName }.toSet(), - ) - } - - @Test - fun `referenced (non-inline) bodies are left untouched`() { - val ep = endpoint( - operationId = "createPet", - requestBody = RequestBody(true, ContentType.JSON_CONTENT_TYPE, TypeRef.Reference("NewPet")), - responses = mapOf("200" to Response("200", "ok", TypeRef.Reference("Pet"))), - ) - val (rewritten, byOp) = planOperationInlineTypes(spec(ep)) - assertNull(byOp["createPet"]) - assertTrue( - rewritten.endpoints - .single() - .requestBody!! - .schema is TypeRef.Reference, - ) - } -} 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 5c784203..1b275661 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 @@ -121,7 +121,6 @@ class TypeMappingTest { val ref = TypeRef.Inline( properties = listOf(PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false)), requiredProperties = setOf("name"), - contextHint = "Pet.Address", ) assertFailsWith { map(ref) } } From fd996e16132f82eae466f70da399971cc2359fcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 10 Jun 2026 11:36:25 +0200 Subject: [PATCH 4/6] feat(core): normalize generated component type names to PascalCase Component schema, enum, and typealias type names are now emitted as PascalCase Kotlin identifiers (e.g. dataSetList -> DataSetList, data_owner -> DataOwner), resolved consistently through a single Hierarchy.classNameFor so $ref usages, nested-variant lookups, and the get() fallback all agree. Wire identity is untouched: @SerialName / @JsonClassDiscriminator and discriminator mapping keep the original spec names; only the Kotlin identifier changes (a non-polymorphic type name never appears on the wire). Closes #92 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../avsystem/justworks/core/gen/Hierarchy.kt | 7 ++- .../core/gen/model/ModelGenerator.kt | 22 +++++----- .../justworks/core/gen/CodeGeneratorTest.kt | 43 +++++++++++++++++++ 3 files changed, 59 insertions(+), 13 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt index 2d63107c..d3877f83 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt @@ -74,20 +74,23 @@ internal class Hierarchy(val modelPackage: ModelPackage) { .mapValues { (_, parents) -> parents.toSet() } } + /** Top-level [ClassName] for a component schema/enum name, normalized to a PascalCase identifier. */ + fun classNameFor(schemaName: String): ClassName = ClassName(modelPackage, schemaName.toPascalCase()) + /** Maps schema name to its [ClassName], using nested class for discriminated hierarchy variants. */ private val lookup: Map by memoized(memoScope) { sealedHierarchies .asSequence() .filterNot { (parent, _) -> parent in anyOfWithoutDiscriminator } .flatMap { (parent, variants) -> - val parentClass = ClassName(modelPackage, parent) + val parentClass = classNameFor(parent) variants.map { variant -> variant to parentClass.nestedClass(variant.toPascalCase()) } + (parent to parentClass) }.toMap() } /** Resolves a schema name (or inline-ref id) to its [ClassName], falling back to a flat top-level class. */ - operator fun get(name: String): ClassName = inlineRefs[name] ?: lookup[name] ?: ClassName(modelPackage, name) + operator fun get(name: String): ClassName = inlineRefs[name] ?: lookup[name] ?: classNameFor(name) } private fun SchemaModel.variants() = oneOf ?: anyOf 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 daa79d0b..31d16d47 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 @@ -153,7 +153,7 @@ internal object ModelGenerator { */ context(hierarchy: Hierarchy) private fun generateSealedHierarchy(schema: SchemaModel): FileSpec { - val className = ClassName(hierarchy.modelPackage, schema.name) + val className = hierarchy.classNameFor(schema.name) val parentBuilder = TypeSpec.interfaceBuilder(className).addModifiers(KModifier.SEALED) parentBuilder.addAnnotation(SERIALIZABLE) @@ -288,11 +288,11 @@ internal object ModelGenerator { */ context(hierarchy: Hierarchy) private fun generateSealedInterface(schema: SchemaModel): FileSpec { - val className = ClassName(hierarchy.modelPackage, schema.name) + val className = hierarchy.classNameFor(schema.name) val typeSpec = TypeSpec.interfaceBuilder(className).addModifiers(KModifier.SEALED) - val serializerClassName = ClassName(hierarchy.modelPackage, "${schema.name}Serializer") + val serializerClassName = hierarchy.classNameFor("${schema.name}Serializer") typeSpec.addAnnotation( AnnotationSpec .builder(SERIALIZABLE) @@ -312,8 +312,8 @@ internal object ModelGenerator { */ context(hierarchy: Hierarchy) private fun generatePolymorphicSerializer(schema: SchemaModel): FileSpec { - val sealedClassName = ClassName(hierarchy.modelPackage, schema.name) - val serializerClassName = ClassName(hierarchy.modelPackage, "${schema.name}Serializer") + val sealedClassName = hierarchy.classNameFor(schema.name) + val serializerClassName = hierarchy.classNameFor("${schema.name}Serializer") val variantProperties = schema.anyOf .orEmpty() @@ -414,11 +414,11 @@ internal object ModelGenerator { */ context(hierarchy: Hierarchy) private fun generateDataClass(schema: SchemaModel): FileSpec { - val className = ClassName(hierarchy.modelPackage, schema.name) + val className = hierarchy.classNameFor(schema.name) // For anyOf-without-discriminator variants: find parent interfaces and serialName val parentNames = hierarchy.anyOfParents[schema.name].orEmpty() - val superinterfaces = parentNames.map { ClassName(hierarchy.modelPackage, it) } + val superinterfaces = parentNames.map { hierarchy.classNameFor(it) } val serialName = parentNames.firstOrNull()?.let { parentName -> hierarchy.schemasById[parentName]?.resolveSerialName(schema.name) } @@ -512,7 +512,7 @@ internal object ModelGenerator { is TypeRef.Reference -> { val constantName = prop.defaultValue.toString().toEnumConstantName() - CodeBlock.of("%T.%L", ClassName(hierarchy.modelPackage, prop.type.schemaName), constantName) + CodeBlock.of("%T.%L", hierarchy.classNameFor(prop.type.schemaName), constantName) } else -> { @@ -522,7 +522,7 @@ internal object ModelGenerator { context(hierarchy: Hierarchy) private fun generateEnumClass(enum: EnumModel): FileSpec { - val className = ClassName(hierarchy.modelPackage, enum.name) + val className = hierarchy.classNameFor(enum.name) val typeSpec = TypeSpec.enumBuilder(className).addAnnotation(SERIALIZABLE) @@ -620,9 +620,9 @@ internal object ModelGenerator { context(hierarchy: Hierarchy) private fun generateTypeAlias(schema: SchemaModel, primitiveType: TypeName): FileSpec { - val className = ClassName(hierarchy.modelPackage, schema.name) + val className = hierarchy.classNameFor(schema.name) - val typeAlias = TypeAliasSpec.builder(schema.name, primitiveType) + val typeAlias = TypeAliasSpec.builder(schema.name.toPascalCase(), primitiveType) if (schema.description != null) { typeAlias.addKdoc("%L", schema.description) diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/CodeGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/CodeGeneratorTest.kt index e0b10f20..81508046 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/CodeGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/CodeGeneratorTest.kt @@ -164,4 +164,47 @@ class CodeGeneratorTest { outputDir.deleteRecursively() } } + + @Test + fun `lowercase component schema names become PascalCase types`() { + val yaml = """ + openapi: "3.0.0" + info: { title: T, version: "1" } + paths: {} + components: + schemas: + dataSetList: + type: object + properties: + owner: { ${'$'}ref: '#/components/schemas/data_owner' } + data_owner: + type: object + properties: + name: { type: string } + """.trimIndent() + val specFile = File.createTempFile("pascal", ".yaml").apply { + writeText(yaml) + deleteOnExit() + } + val spec = when (val r = SpecParser.parse(specFile)) { + is ParseResult.Success -> r.value + is ParseResult.Failure -> fail("parse failed: ${r.error}") + } + + val outputDir = Files.createTempDirectory("codegen-pascal").toFile() + try { + CodeGenerator.generate(spec, "com.example.model", "com.example.api", outputDir) + val modelDir = outputDir.resolve("com/example/model") + val files = modelDir.listFiles()?.map { it.name }.orEmpty() + assertTrue(files.contains("DataSetList.kt"), "got: $files") + assertTrue(files.contains("DataOwner.kt"), "got: $files") + + val src = modelDir.resolve("DataSetList.kt").readText() + assertTrue(src.contains("data class DataSetList"), "type name should be PascalCase") + // $ref to data_owner resolves to the PascalCase DataOwner type. + assertTrue(src.contains("owner: DataOwner"), "ref should resolve to PascalCase type; src: $src") + } finally { + outputDir.deleteRecursively() + } + } } From e232595a1bbd466bd39f1f8925f7c10b98eeee61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 10 Jun 2026 13:43:45 +0200 Subject: [PATCH 5/6] refactor(core): carry lifted inline types on a TransformedApiSpec Replace the two id-keyed inline maps (clientInline/modelInline) with a gen-layer TransformedApiSpec whose endpoints and schemas carry their own lifted inline types (TransformedEndpoint/TransformedSchema). This removes the soft foreign keys between the spec and the inline maps, drops the Hierarchy.modelInline field and the ClientGenerator operationInlineTypes parameter, and keeps inline placement local to its owner. The single-pass planner is now the ApiSpec.transform() extension, with its mutually recursive walk deduplicated into plan()/planProperties()/collect(). Fix a latent bug: request bodies were lifted for every content type, so form/multipart inline bodies became a Reference and their properties no longer expanded into individual function parameters. Only JSON bodies are nested now; form/multipart bodies stay inline. Co-Authored-By: Claude Opus 4.8 --- .../justworks/core/gen/CodeGenerator.kt | 10 +- .../avsystem/justworks/core/gen/Hierarchy.kt | 5 +- .../justworks/core/gen/PlannedInlineType.kt | 151 +++++++++--------- .../com/avsystem/justworks/core/gen/Utils.kt | 2 +- .../core/gen/client/ClientGenerator.kt | 20 +-- .../core/gen/model/ModelGenerator.kt | 51 +++--- .../avsystem/justworks/core/model/TypeRef.kt | 2 +- .../justworks/core/gen/ClientGeneratorTest.kt | 21 +-- .../justworks/core/gen/IntegrationTest.kt | 23 ++- .../core/gen/ModelGeneratorPolymorphicTest.kt | 16 +- .../core/gen/ModelGeneratorRegressionTest.kt | 7 +- .../justworks/core/gen/ModelGeneratorTest.kt | 16 +- 12 files changed, 156 insertions(+), 168 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt index 7459638e..5e7ad5a7 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt @@ -20,16 +20,14 @@ object CodeGenerator { apiPackage: String, outputDir: File, ): Result { - // Lift inline object schemas (operation bodies + object properties) into nested types. - val plan = planInlineTypes(spec) + val transformed = spec.transform() val hierarchy = Hierarchy(ModelPackage(modelPackage)).apply { - addSchemas(plan.spec.schemas) - modelInline = plan.modelInline + addSchemas(transformed.schemas.map { it.schema }) } val (modelFiles, resolvedSpec) = context(hierarchy, NameRegistry()) { - ModelGenerator.generateWithResolvedSpec(plan.spec) + ModelGenerator.generateWithResolvedSpec(transformed) } modelFiles.forEach { it.writeTo(outputDir) } @@ -37,7 +35,7 @@ object CodeGenerator { val hasPolymorphicTypes = modelFiles.any { it.name == SERIALIZERS_MODULE.simpleName } val clientFiles = context(hierarchy, ApiPackage(apiPackage), NameRegistry()) { - ClientGenerator.generate(resolvedSpec, hasPolymorphicTypes, plan.clientInline) + ClientGenerator.generate(resolvedSpec, hasPolymorphicTypes) } clientFiles.forEach { it.writeTo(outputDir) } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt index d3877f83..5d78f341 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt @@ -12,13 +12,10 @@ internal class Hierarchy(val modelPackage: ModelPackage) { /** * Resolution overrides for inline body types nested inside client/model classes, - * keyed by the reference id assigned by [planInlineTypes]. + * keyed by the reference id assigned by [transform]. */ private val inlineRefs = mutableMapOf() - /** Inline object types to nest inside a component data class, keyed by the component name. */ - var modelInline: Map> = emptyMap() - /** Registers the nested [ClassName] an inline reference id resolves to. */ fun registerInlineRef(id: String, className: ClassName) { inlineRefs[id] = className diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/PlannedInlineType.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/PlannedInlineType.kt index fa5ac3d9..5d2a2f3f 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/PlannedInlineType.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/PlannedInlineType.kt @@ -1,19 +1,18 @@ package com.avsystem.justworks.core.gen import com.avsystem.justworks.core.model.ApiSpec +import com.avsystem.justworks.core.model.ContentType +import com.avsystem.justworks.core.model.Endpoint +import com.avsystem.justworks.core.model.EnumModel +import com.avsystem.justworks.core.model.PropertyModel import com.avsystem.justworks.core.model.SchemaModel +import com.avsystem.justworks.core.model.SecurityScheme import com.avsystem.justworks.core.model.TypeRef /** - * An inline (anonymous) object schema lifted out so it can be generated as a type nested - * inside its owner (a client class for operation bodies, or a parent data class for - * properties). Forms a tree: [children] are inline objects nested within this one. - * - * @param id stable reference id stored in [TypeRef.Reference] in place of the inline schema; - * the generator resolves it to the nested [com.squareup.kotlinpoet.ClassName] via [Hierarchy]. - * @param simpleName the nested type's simple name (e.g. `DomainsUpdateRequest`, `Address`). - * @param schema the schema to generate the nested data class from, with its own inline - * properties already rewritten to references pointing at [children]. + * An inline object schema lifted out to be generated as a type nested inside its owner. + * [children] are inline objects nested within this one. [id] is the reference id placed in + * [TypeRef.Reference] and resolved to the nested class via [Hierarchy]. */ internal data class PlannedInlineType( val id: String, @@ -23,43 +22,42 @@ internal data class PlannedInlineType( ) /** - * Result of [planInlineTypes]: the spec with every [TypeRef.Inline] rewritten to a - * [TypeRef.Reference], plus the inline types to nest, grouped by owner. + * An [ApiSpec] whose inline schemas have been lifted: every [TypeRef.Inline] is rewritten to a + * [TypeRef.Reference], and the lifted types travel with the endpoint/schema that owns them. */ -internal data class InlinePlan( - val spec: ApiSpec, - /** Operation id -> inline body types to nest inside that operation's client class. */ - val clientInline: Map>, - /** Component schema name -> inline property types to nest inside that data class. */ - val modelInline: Map>, +internal data class TransformedApiSpec( + val title: String, + val version: String, + val endpoints: List, + val schemas: List, + val enums: List, + val securitySchemes: List, ) -/** - * Single pass over the spec that lifts every inline object schema (operation request/response - * bodies and object-typed properties, recursively) into [PlannedInlineType] trees and rewrites - * the spec to reference them. - * - * No structural deduplication: each occurrence gets its own copy, named after its position, so - * inline types are placed (and named) relative to the type that owns them. - */ -internal fun planInlineTypes(spec: ApiSpec): InlinePlan { - var counter = 0 +/** An endpoint plus the inline request/response body types to nest inside its client class. */ +internal data class TransformedEndpoint(val endpoint: Endpoint, val inlineTypes: List) + +/** A component schema plus the inline property types to nest inside its data class. */ +internal data class TransformedSchema(val schema: SchemaModel, val inlineTypes: List) + +/** Lifts every inline object schema (bodies and properties, recursively) into [PlannedInlineType] trees. */ +internal fun ApiSpec.transform(): TransformedApiSpec = object { // object for mutual recursion + private val spec = this@transform + private var counter = 0 - // Rewrites a type (and inline objects nested in arrays/maps) to references, returning the - // rewritten type and, if an inline object was lifted, its PlannedInlineType. - fun plan(type: TypeRef, hint: String): Pair = when (type) { + private fun planProperties(properties: List, sink: MutableList) = + properties.map { property -> + property.copy(type = sink.collect(property.type, property.name.toPascalCase())) + } + + private fun plan(type: TypeRef, hint: String): Pair = when (type) { is TypeRef.Inline -> { val id = "inline${counter++}" val children = mutableListOf() - val newProperties = type.properties.map { property -> - val (newType, child) = plan(property.type, property.name.toPascalCase()) - if (child != null) children.add(child) - property.copy(type = newType) - } val schema = SchemaModel( name = hint, description = null, - properties = newProperties, + properties = planProperties(type.properties, children), requiredProperties = type.requiredProperties, allOf = null, oneOf = null, @@ -82,50 +80,51 @@ internal fun planInlineTypes(spec: ApiSpec): InlinePlan { } } - val clientInline = mutableMapOf>() - val modelInline = mutableMapOf>() + private fun MutableList.collect(type: TypeRef, hint: String): TypeRef { + val (rewritten, planned) = plan(type, hint) + if (planned != null) this.add(planned) + return rewritten + } - val newEndpoints = spec.endpoints.map { endpoint -> - val opName = endpoint.operationId.toPascalCase() - val owned = mutableListOf() + fun run(): TransformedApiSpec { + val endpoints = spec.endpoints.map { endpoint -> + val opName = endpoint.operationId.toPascalCase() + val owned = mutableListOf() + + // Only JSON bodies become a nested body type; form/multipart bodies stay inline so their + // properties expand into individual function parameters. + val newRequestBody = endpoint.requestBody?.let { + if (it.contentType == ContentType.JSON_CONTENT_TYPE) { + it.copy(schema = owned.collect(it.schema, "${opName}Request")) + } else { + it + } + } + val newResponses = endpoint.responses.mapValues { (code, response) -> + response.schema?.let { + response.copy( + schema = owned.collect( + it, + when { + code.toIntOrNull() in 200..299 -> "${opName}Response" + code == "default" -> "${opName}DefaultResponse" + else -> "${opName}Response$code" + }, + ), + ) + } + ?: response + } - val newRequestBody = endpoint.requestBody?.let { body -> - val (newType, planned) = plan(body.schema, "${opName}Request") - if (planned != null) owned.add(planned) - body.copy(schema = newType) + TransformedEndpoint(endpoint.copy(requestBody = newRequestBody, responses = newResponses), owned) } - val newResponses = endpoint.responses.mapValues { (code, response) -> - val schema = response.schema ?: return@mapValues response - val (newType, planned) = plan(schema, responseTypeName(opName, code)) - if (planned != null) owned.add(planned) - response.copy(schema = newType) + val schemas = spec.schemas.map { schema -> + val owned = mutableListOf() + val newProperties = planProperties(schema.properties, owned) + TransformedSchema(schema.copy(properties = newProperties), owned) } - if (owned.isNotEmpty()) clientInline[endpoint.operationId] = owned - endpoint.copy(requestBody = newRequestBody, responses = newResponses) + return TransformedApiSpec(spec.title, spec.version, endpoints, schemas, spec.enums, spec.securitySchemes) } - - val newSchemas = spec.schemas.map { schema -> - val owned = mutableListOf() - val newProperties = schema.properties.map { property -> - val (newType, planned) = plan(property.type, property.name.toPascalCase()) - if (planned != null) owned.add(planned) - property.copy(type = newType) - } - if (owned.isNotEmpty()) modelInline[schema.name] = owned - schema.copy(properties = newProperties) - } - - return InlinePlan( - spec = spec.copy(endpoints = newEndpoints, schemas = newSchemas), - clientInline = clientInline, - modelInline = modelInline, - ) -} - -private fun responseTypeName(opName: String, code: String): String = when { - code.toIntOrNull()?.let { it in 200..299 } == true -> "${opName}Response" - code == "default" -> "${opName}DefaultResponse" - else -> "${opName}Response$code" -} +}.run() 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 index d4187b29..413c6db0 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Utils.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Utils.kt @@ -59,7 +59,7 @@ internal fun TypeRef.toTypeName(): TypeName = when (this) { } is TypeRef.Inline -> { - error("TypeRef.Inline should have been lifted and rewritten to a reference by planInlineTypes") + error("TypeRef.Inline should have been lifted and rewritten to a reference by ApiSpec.transform") } is TypeRef.Unknown -> { diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt index 3e066ef0..41302801 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt @@ -17,8 +17,9 @@ import com.avsystem.justworks.core.gen.HTTP_SUCCESS import com.avsystem.justworks.core.gen.Hierarchy import com.avsystem.justworks.core.gen.JSON_ELEMENT import com.avsystem.justworks.core.gen.NameRegistry -import com.avsystem.justworks.core.gen.PlannedInlineType import com.avsystem.justworks.core.gen.TOKEN +import com.avsystem.justworks.core.gen.TransformedApiSpec +import com.avsystem.justworks.core.gen.TransformedEndpoint 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 @@ -29,7 +30,6 @@ import com.avsystem.justworks.core.gen.toCamelCase import com.avsystem.justworks.core.gen.toPascalCase import com.avsystem.justworks.core.gen.toTypeName import com.avsystem.justworks.core.model.ApiKeyLocation -import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.Endpoint import com.avsystem.justworks.core.model.ParameterLocation import com.avsystem.justworks.core.model.SecurityScheme @@ -58,12 +58,8 @@ internal object ClientGenerator { private const val API_SUFFIX = "Api" context(_: Hierarchy, _: ApiPackage, _: NameRegistry) - fun generate( - spec: ApiSpec, - hasPolymorphicTypes: Boolean, - operationInlineTypes: Map>, - ): List { - val grouped = spec.endpoints.groupBy { it.tags.firstOrNull() ?: DEFAULT_TAG } + fun generate(spec: TransformedApiSpec, hasPolymorphicTypes: Boolean): List { + val grouped = spec.endpoints.groupBy { it.endpoint.tags.firstOrNull() ?: DEFAULT_TAG } return grouped.map { (tag, endpoints) -> generateClientFile( tag, @@ -71,7 +67,6 @@ internal object ClientGenerator { hasPolymorphicTypes, spec.securitySchemes, spec.title, - operationInlineTypes, ) } } @@ -79,11 +74,10 @@ internal object ClientGenerator { context(hierarchy: Hierarchy, apiPackage: ApiPackage, nameRegistry: NameRegistry) private fun generateClientFile( tag: String, - endpoints: List, + endpoints: List, hasPolymorphicTypes: Boolean, securitySchemes: List, specTitle: String, - operationInlineTypes: Map>, ): FileSpec { val className = ClassName(apiPackage, nameRegistry.register("${tag.toPascalCase()}$API_SUFFIX")) @@ -157,13 +151,13 @@ internal object ClientGenerator { // Done before generating functions so type resolution sees the registered names. val nestedNames = NameRegistry() endpoints - .flatMap { operationInlineTypes[it.operationId].orEmpty() } + .flatMap { it.inlineTypes } .forEach { planned -> classBuilder.addType(ModelGenerator.emitNestedInline(className, planned, nestedNames)) } context(NameRegistry()) { - classBuilder.addFunctions(endpoints.map { generateEndpointFunction(it) }) + classBuilder.addFunctions(endpoints.map { generateEndpointFunction(it.endpoint) }) } return FileSpec 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 31d16d47..82a841dc 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 @@ -20,23 +20,21 @@ import com.avsystem.justworks.core.gen.PRIMITIVE_SERIAL_DESCRIPTOR_FUN import com.avsystem.justworks.core.gen.PlannedInlineType import com.avsystem.justworks.core.gen.SERIALIZABLE import com.avsystem.justworks.core.gen.SERIALIZATION_EXCEPTION -import com.avsystem.justworks.core.gen.SERIALIZERS_MODULE import com.avsystem.justworks.core.gen.SERIAL_DESCRIPTOR import com.avsystem.justworks.core.gen.SERIAL_NAME +import com.avsystem.justworks.core.gen.TransformedApiSpec +import com.avsystem.justworks.core.gen.TransformedSchema import com.avsystem.justworks.core.gen.USE_SERIALIZERS import com.avsystem.justworks.core.gen.UUID_SERIALIZER import com.avsystem.justworks.core.gen.UUID_TYPE -import com.avsystem.justworks.core.gen.invoke import com.avsystem.justworks.core.gen.model.ModelGenerator.buildNestedVariant import com.avsystem.justworks.core.gen.model.ModelGenerator.generateDataClass import com.avsystem.justworks.core.gen.resolveSerialName 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.toPascalCase 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 import com.avsystem.justworks.core.model.PropertyModel @@ -60,21 +58,19 @@ import kotlinx.datetime.LocalDate import kotlin.time.Instant /** - * Generates KotlinPoet [FileSpec] instances from an [ApiSpec]. + * Generates KotlinPoet [FileSpec] instances from a [TransformedApiSpec]. * * Produces one file per [SchemaModel] (data class, sealed class hierarchy, or allOf composed class) * and one file per [EnumModel] (enum class), all annotated with kotlinx.serialization annotations. */ internal object ModelGenerator { - data class GenerateResult(val files: List, val resolvedSpec: ApiSpec) + data class GenerateResult(val files: List, val resolvedSpec: TransformedApiSpec) context(_: Hierarchy, _: NameRegistry) - fun generate(spec: ApiSpec): List = generateWithResolvedSpec(spec).files + fun generate(spec: TransformedApiSpec): List = generateWithResolvedSpec(spec).files context(hierarchy: Hierarchy, _: NameRegistry) - fun generateWithResolvedSpec(spec: ApiSpec): GenerateResult { - // Inline schemas have already been lifted and rewritten to references by planInlineTypes; - // the inline types to nest are available via hierarchy.modelInline. + fun generateWithResolvedSpec(spec: TransformedApiSpec): GenerateResult { val nestedVariantNames = hierarchy.sealedHierarchies .asSequence() .filterNot { (key, _) -> key in hierarchy.anyOfWithoutDiscriminator } @@ -83,7 +79,7 @@ internal object ModelGenerator { val schemaFiles = spec.schemas .asSequence() - .filterNot { it.name in nestedVariantNames } + .filterNot { it.schema.name in nestedVariantNames } .flatMap { generateSchemaFiles(it) } .toList() @@ -126,25 +122,25 @@ internal object ModelGenerator { } context(hierarchy: Hierarchy) - private fun generateSchemaFiles(schema: SchemaModel): List = when { - !schema.anyOf.isNullOrEmpty() || !schema.oneOf.isNullOrEmpty() -> { - if (schema.name in hierarchy.anyOfWithoutDiscriminator) { + private fun generateSchemaFiles(transformed: TransformedSchema): List = when { + !transformed.schema.anyOf.isNullOrEmpty() || !transformed.schema.oneOf.isNullOrEmpty() -> { + if (transformed.schema.name in hierarchy.anyOfWithoutDiscriminator) { listOf( - generateSealedInterface(schema), - generatePolymorphicSerializer(schema), + generateSealedInterface(transformed.schema), + generatePolymorphicSerializer(transformed.schema), ) } else { - listOf(generateSealedHierarchy(schema)) + listOf(generateSealedHierarchy(transformed.schema)) } } - schema.isPrimitiveOnly -> { - val targetType = schema.underlyingType?.toTypeName() ?: STRING - listOf(generateTypeAlias(schema, targetType)) + transformed.schema.isPrimitiveOnly -> { + val targetType = transformed.schema.underlyingType?.toTypeName() ?: STRING + listOf(generateTypeAlias(transformed.schema, targetType)) } else -> { - listOf(generateDataClass(schema)) + listOf(generateDataClass(transformed.schema, transformed.inlineTypes)) } } @@ -413,7 +409,7 @@ internal object ModelGenerator { * Used for: standalone schemas, allOf composed classes, and anyOf-without-discriminator variants. */ context(hierarchy: Hierarchy) - private fun generateDataClass(schema: SchemaModel): FileSpec { + private fun generateDataClass(schema: SchemaModel, inlineTypes: List): FileSpec { val className = hierarchy.classNameFor(schema.name) // For anyOf-without-discriminator variants: find parent interfaces and serialName @@ -441,9 +437,7 @@ internal object ModelGenerator { // Nest this schema's inline property types, registering their ids before resolving // properties so references to them resolve to the nested classes. val nestedNames = NameRegistry() - val nestedSpecs = hierarchy.modelInline[schema.name] - .orEmpty() - .map { emitNestedInline(className, it, nestedNames) } + val nestedSpecs = inlineTypes.map { emitNestedInline(className, it, nestedNames) } buildConstructorAndProperties(schema, typeSpec) nestedSpecs.forEach { typeSpec.addType(it) } @@ -561,9 +555,10 @@ internal object ModelGenerator { is TypeRef.Reference, TypeRef.Unknown -> false } - private fun ApiSpec.usesUuid(): Boolean { - val schemaRefs = schemas.asSequence().flatMap { schema -> schema.properties.map { it.type } } - val endpointRefs = endpoints.asSequence().flatMap { endpoint -> + private fun TransformedApiSpec.usesUuid(): Boolean { + val schemaRefs = schemas.asSequence().flatMap { schema -> schema.schema.properties.map { it.type } } + val endpointRefs = endpoints.asSequence().flatMap { transformed -> + val endpoint = transformed.endpoint val responseRefs = endpoint.responses.values .asSequence() .mapNotNull { it.schema } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt b/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt index 5e851fa0..9adb9a01 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt @@ -9,7 +9,7 @@ sealed interface TypeRef { data class Map(val valueType: TypeRef) : TypeRef - data class Inline(val properties: List, val requiredProperties: Set,) : TypeRef + data class Inline(val properties: List, val requiredProperties: Set) : TypeRef data object Unknown : TypeRef } 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 61f66415..5a6978f8 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 @@ -29,15 +29,18 @@ class ClientGeneratorTest { private val apiPackage = "com.example.api" private val modelPackage = "com.example.model" - private fun generate(spec: ApiSpec, hasPolymorphicTypes: Boolean = false): List = context( - Hierarchy(ModelPackage(modelPackage)).apply { - addSchemas(spec.schemas) - }, - ApiPackage(apiPackage), - NameRegistry(), - ) { - ClientGenerator.generate(spec, hasPolymorphicTypes, emptyMap()) - } + private fun generate(spec: ApiSpec, hasPolymorphicTypes: Boolean = false): List = + spec.transform().let { transformed -> + context( + Hierarchy(ModelPackage(modelPackage)).apply { + addSchemas(transformed.schemas.map { it.schema }) + }, + ApiPackage(apiPackage), + NameRegistry(), + ) { + ClientGenerator.generate(transformed, hasPolymorphicTypes) + } + } private fun spec(vararg endpoints: Endpoint) = spec(endpoints.toList()) 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 5388af98..aae83fcc 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 @@ -40,27 +40,26 @@ class IntegrationTest { } } - private fun hierarchyFor(plan: InlinePlan) = Hierarchy(ModelPackage(modelPackage)).apply { - addSchemas(plan.spec.schemas) - modelInline = plan.modelInline + private fun hierarchyFor(transformed: TransformedApiSpec) = Hierarchy(ModelPackage(modelPackage)).apply { + addSchemas(transformed.schemas.map { it.schema }) } private fun generateModel(spec: ApiSpec): List { - val plan = planInlineTypes(spec) - return context(hierarchyFor(plan), NameRegistry()) { ModelGenerator.generate(plan.spec) } + val transformed = spec.transform() + return context(hierarchyFor(transformed), NameRegistry()) { ModelGenerator.generate(transformed) } } private fun generateModelWithResolvedSpec(spec: ApiSpec): ModelGenerator.GenerateResult { - val plan = planInlineTypes(spec) - return context(hierarchyFor(plan), NameRegistry()) { ModelGenerator.generateWithResolvedSpec(plan.spec) } + val transformed = spec.transform() + return context(hierarchyFor(transformed), NameRegistry()) { + ModelGenerator.generateWithResolvedSpec(transformed) + } } - private fun generateClient(spec: ApiSpec, hasPolymorphicTypes: Boolean = false): List { - val plan = planInlineTypes(spec) - return context(hierarchyFor(plan), ApiPackage(apiPackage), NameRegistry()) { - ClientGenerator.generate(plan.spec, hasPolymorphicTypes, plan.clientInline) + private fun generateClient(transformed: TransformedApiSpec, hasPolymorphicTypes: Boolean = false,): List = + context(hierarchyFor(transformed), ApiPackage(apiPackage), NameRegistry()) { + ClientGenerator.generate(transformed, hasPolymorphicTypes) } - } @Test fun `real-world specs generate compilable enum code without class body conflicts`() { 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 742bae2b..567e4bc1 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 @@ -20,13 +20,15 @@ import kotlin.test.assertTrue class ModelGeneratorPolymorphicTest { private val modelPackage = "com.example.model" - private fun generate(spec: ApiSpec) = context( - Hierarchy(ModelPackage(modelPackage)).apply { - addSchemas(spec.schemas) - }, - NameRegistry(), - ) { - ModelGenerator.generate(spec) + private fun generate(spec: ApiSpec) = spec.transform().let { transformed -> + context( + Hierarchy(ModelPackage(modelPackage)).apply { + addSchemas(transformed.schemas.map { it.schema }) + }, + NameRegistry(), + ) { + ModelGenerator.generate(transformed) + } } private fun spec(schemas: List = emptyList(), enums: List = emptyList()) = ApiSpec( diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorRegressionTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorRegressionTest.kt index d5bfeea7..8cfb73db 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorRegressionTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorRegressionTest.kt @@ -12,12 +12,11 @@ class ModelGeneratorRegressionTest { private val modelPackage = "com.example.model" private fun generate(spec: ApiSpec): List { - val plan = planInlineTypes(spec) + val transformed = spec.transform() val hierarchy = Hierarchy(ModelPackage(modelPackage)).apply { - addSchemas(plan.spec.schemas) - modelInline = plan.modelInline + addSchemas(transformed.schemas.map { it.schema }) } - return context(hierarchy, NameRegistry()) { ModelGenerator.generate(plan.spec) } + return context(hierarchy, NameRegistry()) { ModelGenerator.generate(transformed) } } private fun spec(schemas: List = emptyList()) = ApiSpec( 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 b6d5f1ef..4f51177e 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 @@ -23,13 +23,15 @@ import kotlin.test.assertTrue class ModelGeneratorTest { private val modelPackage = "com.example.model" - private fun generate(spec: ApiSpec) = context( - Hierarchy(ModelPackage(modelPackage)).apply { - addSchemas(spec.schemas) - }, - NameRegistry(), - ) { - ModelGenerator.generate(spec) + private fun generate(spec: ApiSpec) = spec.transform().let { transformed -> + context( + Hierarchy(ModelPackage(modelPackage)).apply { + addSchemas(transformed.schemas.map { it.schema }) + }, + NameRegistry(), + ) { + ModelGenerator.generate(transformed) + } } private fun spec(schemas: List = emptyList(), enums: List = emptyList()) = ApiSpec( From fa81b061ae3013609154941dc43852ed2e0d0e5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 10 Jun 2026 16:19:14 +0200 Subject: [PATCH 6/6] refactor(core): rename inline-lifting types to Resolved*/NestedType, port master features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconcile the merge from master: keep the nested-inline architecture and rename its types coherently — ApiSpec.resolveInlines() returns a ResolvedApiSpec whose ResolvedEndpoint/ResolvedSchema carry NestedType (sealed Obj/Enum) trees. Ported from master onto this architecture: - inline enums generated as enums nested in their owner (incl. oneOf/anyOf variants) - uniqueItems -> Set, honor property defaults, empty response -> Unit - stripDiscriminatorProperties to suppress orphan discriminator enums Co-Authored-By: Claude Opus 4.8 --- .../justworks/core/gen/CodeGenerator.kt | 6 +- .../avsystem/justworks/core/gen/Hierarchy.kt | 2 +- ...lannedInlineType.kt => ResolvedApiSpec.kt} | 65 ++++++++---------- .../core/gen/client/ClientGenerator.kt | 15 ++--- .../core/gen/model/ModelGenerator.kt | 66 +++++++++---------- .../avsystem/justworks/core/model/TypeRef.kt | 2 - .../justworks/core/parser/SpecParser.kt | 29 ++------ .../justworks/core/gen/ClientGeneratorTest.kt | 6 +- .../justworks/core/gen/IntegrationTest.kt | 24 ++++--- .../core/gen/ModelGeneratorInlineEnumTest.kt | 10 +-- .../core/gen/ModelGeneratorPolymorphicTest.kt | 9 +-- .../core/gen/ModelGeneratorRegressionTest.kt | 10 ++- .../justworks/core/gen/ModelGeneratorTest.kt | 8 ++- 13 files changed, 113 insertions(+), 139 deletions(-) rename core/src/main/kotlin/com/avsystem/justworks/core/gen/{PlannedInlineType.kt => ResolvedApiSpec.kt} (68%) 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 5e7ad5a7..cabac836 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt @@ -20,14 +20,14 @@ object CodeGenerator { apiPackage: String, outputDir: File, ): Result { - val transformed = spec.transform() + val resolvedApiSpec = spec.resolveInlines() val hierarchy = Hierarchy(ModelPackage(modelPackage)).apply { - addSchemas(transformed.schemas.map { it.schema }) + addSchemas(resolvedApiSpec.schemas.map { it.schema }) } val (modelFiles, resolvedSpec) = context(hierarchy, NameRegistry()) { - ModelGenerator.generateWithResolvedSpec(transformed) + ModelGenerator.generate(resolvedApiSpec) } modelFiles.forEach { it.writeTo(outputDir) } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt index 5d78f341..768a423a 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Hierarchy.kt @@ -12,7 +12,7 @@ internal class Hierarchy(val modelPackage: ModelPackage) { /** * Resolution overrides for inline body types nested inside client/model classes, - * keyed by the reference id assigned by [transform]. + * keyed by the reference id assigned by [resolved]. */ private val inlineRefs = mutableMapOf() diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/PlannedInlineType.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ResolvedApiSpec.kt similarity index 68% rename from core/src/main/kotlin/com/avsystem/justworks/core/gen/PlannedInlineType.kt rename to core/src/main/kotlin/com/avsystem/justworks/core/gen/ResolvedApiSpec.kt index f4c73942..4b97c0fd 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/PlannedInlineType.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ResolvedApiSpec.kt @@ -13,59 +13,54 @@ import com.avsystem.justworks.core.model.TypeRef * An inline (anonymous) schema lifted out to be generated as a type nested inside its owner. * [id] is the reference id placed in [TypeRef.Reference] and resolved to the nested class via [Hierarchy]. */ -internal sealed interface PlannedInlineType { +internal sealed interface NestedType { val id: String val simpleName: String - /** An inline object; [children] are inline schemas nested within it. */ data class Obj( override val id: String, override val simpleName: String, val schema: SchemaModel, - val children: List, - ) : PlannedInlineType + val children: List, + ) : NestedType - /** An inline enum. */ data class Enum( override val id: String, override val simpleName: String, val model: EnumModel, - ) : PlannedInlineType + ) : NestedType } /** * An [ApiSpec] whose inline schemas have been lifted: every [TypeRef.Inline]/[TypeRef.InlineEnum] is * rewritten to a [TypeRef.Reference], and the lifted types travel with the endpoint/schema that owns them. */ -internal data class TransformedApiSpec( +internal data class ResolvedApiSpec( val title: String, val version: String, - val endpoints: List, - val schemas: List, + val endpoints: List, + val schemas: List, val enums: List, val securitySchemes: List, ) -/** An endpoint plus the inline request/response body types to nest inside its client class. */ -internal data class TransformedEndpoint(val endpoint: Endpoint, val inlineTypes: List) +internal data class ResolvedEndpoint(val endpoint: Endpoint, val inlineTypes: List) -/** A component schema plus the inline property types to nest inside its data class. */ -internal data class TransformedSchema(val schema: SchemaModel, val inlineTypes: List) +internal data class ResolvedSchema(val schema: SchemaModel, val inlineTypes: List) -/** Lifts every inline schema (bodies and properties, recursively) into [PlannedInlineType] trees. */ -internal fun ApiSpec.transform(): TransformedApiSpec = object { // object for mutual recursion - private val spec = this@transform.stripDiscriminatorProperties() +/** Lifts every inline schema (bodies and properties, recursively) into [NestedType] trees. */ +internal fun ApiSpec.resolveInlines(): ResolvedApiSpec = object { // object for mutual recursion private var counter = 0 - private fun planProperties(properties: List, sink: MutableList) = + private fun planProperties(properties: List, sink: MutableList) = properties.map { property -> property.copy(type = sink.collect(property.type, property.name.toPascalCase())) } - private fun plan(type: TypeRef, hint: String): Pair = when (type) { + private fun plan(type: TypeRef, hint: String): Pair = when (type) { is TypeRef.Inline -> { val id = "inline${counter++}" - val children = mutableListOf() + val children = mutableListOf() val schema = SchemaModel( name = hint, description = null, @@ -76,7 +71,7 @@ internal fun ApiSpec.transform(): TransformedApiSpec = object { // object for mu anyOf = null, discriminator = null, ) - TypeRef.Reference(id) to PlannedInlineType.Obj(id, hint, schema, children) + TypeRef.Reference(id) to NestedType.Obj(id, hint, schema, children) } is TypeRef.InlineEnum -> { @@ -87,7 +82,7 @@ internal fun ApiSpec.transform(): TransformedApiSpec = object { // object for mu type = type.backingType, values = type.values.map { EnumModel.Value(it) }, ) - TypeRef.Reference(id) to PlannedInlineType.Enum(id, hint, model) + TypeRef.Reference(id) to NestedType.Enum(id, hint, model) } is TypeRef.Array -> { @@ -103,16 +98,18 @@ internal fun ApiSpec.transform(): TransformedApiSpec = object { // object for mu } } - private fun MutableList.collect(type: TypeRef, hint: String): TypeRef { - val (rewritten, planned) = plan(type, hint) - if (planned != null) this.add(planned) + private fun MutableList.collect(type: TypeRef, hint: String): TypeRef { + val (rewritten, nested) = plan(type, hint) + if (nested != null) add(nested) return rewritten } - fun run(): TransformedApiSpec { + fun run(): ResolvedApiSpec { + val spec = this@resolveInlines.stripDiscriminatorProperties() + val endpoints = spec.endpoints.map { endpoint -> val opName = endpoint.operationId.toPascalCase() - val owned = mutableListOf() + val owned = mutableListOf() // Only JSON bodies become a nested body type; form/multipart bodies stay inline so their // properties expand into individual function parameters. @@ -139,26 +136,22 @@ internal fun ApiSpec.transform(): TransformedApiSpec = object { // object for mu ?: response } - TransformedEndpoint(endpoint.copy(requestBody = newRequestBody, responses = newResponses), owned) + ResolvedEndpoint(endpoint.copy(requestBody = newRequestBody, responses = newResponses), owned) } val schemas = spec.schemas.map { schema -> - val owned = mutableListOf() + val owned = mutableListOf() val newProperties = planProperties(schema.properties, owned) - TransformedSchema(schema.copy(properties = newProperties), owned) + ResolvedSchema(schema.copy(properties = newProperties), owned) } - return TransformedApiSpec(spec.title, spec.version, endpoints, schemas, spec.enums, spec.securitySchemes) + return ResolvedApiSpec(spec.title, spec.version, endpoints, schemas, spec.enums, spec.securitySchemes) } }.run() /** - * Drops the discriminator property from every polymorphic variant schema. - * - * In a sealed (oneOf/anyOf + discriminator) hierarchy the discriminator is emitted via - * `@SerialName`/`@JsonClassDiscriminator` on the subtype, never as a field — the variant's own - * (typically single-value) `type` property is dead weight. Removing it keeps inline-enum lifting - * consistent, so no orphan discriminator enum is nested into a variant. + * Drops the discriminator property from each polymorphic variant: it is emitted via `@SerialName`, + * never as a field, so keeping it would nest an orphan single-value enum into the variant. */ internal fun ApiSpec.stripDiscriminatorProperties(): ApiSpec { val discriminatorProps = schemas diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt index 41302801..a4edc2ea 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt @@ -17,9 +17,9 @@ import com.avsystem.justworks.core.gen.HTTP_SUCCESS import com.avsystem.justworks.core.gen.Hierarchy import com.avsystem.justworks.core.gen.JSON_ELEMENT import com.avsystem.justworks.core.gen.NameRegistry +import com.avsystem.justworks.core.gen.ResolvedApiSpec +import com.avsystem.justworks.core.gen.ResolvedEndpoint import com.avsystem.justworks.core.gen.TOKEN -import com.avsystem.justworks.core.gen.TransformedApiSpec -import com.avsystem.justworks.core.gen.TransformedEndpoint 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 @@ -58,7 +58,7 @@ internal object ClientGenerator { private const val API_SUFFIX = "Api" context(_: Hierarchy, _: ApiPackage, _: NameRegistry) - fun generate(spec: TransformedApiSpec, hasPolymorphicTypes: Boolean): List { + fun generate(spec: ResolvedApiSpec, hasPolymorphicTypes: Boolean): List { val grouped = spec.endpoints.groupBy { it.endpoint.tags.firstOrNull() ?: DEFAULT_TAG } return grouped.map { (tag, endpoints) -> generateClientFile( @@ -74,7 +74,7 @@ internal object ClientGenerator { context(hierarchy: Hierarchy, apiPackage: ApiPackage, nameRegistry: NameRegistry) private fun generateClientFile( tag: String, - endpoints: List, + endpoints: List, hasPolymorphicTypes: Boolean, securitySchemes: List, specTitle: String, @@ -146,14 +146,11 @@ internal object ClientGenerator { classBuilder.addFunction(buildApplyAuth(securitySchemes, isSingleBearer, specTitle)) } - // Nest each operation's inline request/response body types inside the client class, - // registering their reference ids so endpoint signatures resolve to the nested classes. - // Done before generating functions so type resolution sees the registered names. val nestedNames = NameRegistry() endpoints .flatMap { it.inlineTypes } - .forEach { planned -> - classBuilder.addType(ModelGenerator.emitNestedInline(className, planned, nestedNames)) + .forEach { nested -> + classBuilder.addType(ModelGenerator.emitNestedInline(className, nested, nestedNames)) } context(NameRegistry()) { 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 3dc5e15b..22602861 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 @@ -14,20 +14,19 @@ 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.NameRegistry +import com.avsystem.justworks.core.gen.NestedType 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.PlannedInlineType +import com.avsystem.justworks.core.gen.ResolvedApiSpec +import com.avsystem.justworks.core.gen.ResolvedSchema 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.TransformedApiSpec -import com.avsystem.justworks.core.gen.TransformedSchema import com.avsystem.justworks.core.gen.USE_SERIALIZERS import com.avsystem.justworks.core.gen.UUID_SERIALIZER import com.avsystem.justworks.core.gen.UUID_TYPE -import com.avsystem.justworks.core.gen.invoke import com.avsystem.justworks.core.gen.model.ModelGenerator.buildNestedVariant import com.avsystem.justworks.core.gen.model.ModelGenerator.generateDataClass import com.avsystem.justworks.core.gen.resolveSerialName @@ -58,22 +57,21 @@ import com.squareup.kotlinpoet.WildcardTypeName import com.squareup.kotlinpoet.joinToCode import kotlinx.datetime.LocalDate import java.util.Base64 +import kotlin.collections.map +import kotlin.collections.orEmpty import kotlin.time.Instant /** - * Generates KotlinPoet [FileSpec] instances from a [TransformedApiSpec]. + * Generates KotlinPoet [FileSpec] instances from a [ResolvedApiSpec]. * * Produces one file per [SchemaModel] (data class, sealed class hierarchy, or allOf composed class) * and one file per [EnumModel] (enum class), all annotated with kotlinx.serialization annotations. */ internal object ModelGenerator { - data class GenerateResult(val files: List, val resolvedSpec: TransformedApiSpec) - - context(_: Hierarchy, _: NameRegistry) - fun generate(spec: TransformedApiSpec): List = generateWithResolvedSpec(spec).files + data class GenerateResult(val files: List, val resolvedSpec: ResolvedApiSpec) context(hierarchy: Hierarchy, _: NameRegistry) - fun generateWithResolvedSpec(spec: TransformedApiSpec): GenerateResult { + fun generate(spec: ResolvedApiSpec): GenerateResult { val nestedVariantNames = hierarchy.sealedHierarchies .asSequence() .filterNot { (key, _) -> key in hierarchy.anyOfWithoutDiscriminator } @@ -101,14 +99,13 @@ internal object ModelGenerator { } /** - * Recursively builds a `@Serializable data class` [TypeSpec] for a lifted inline type, - * nesting its child inline types and registering each reference id with [hierarchy] so - * properties referencing them resolve to the nested [ClassName]. + * Builds the nested [TypeSpec] for a lifted inline type (data class or enum), recursing into + * children and registering each reference id with [hierarchy] so properties resolve to it. */ context(hierarchy: Hierarchy) internal fun emitNestedInline( parentClass: ClassName, - planned: PlannedInlineType, + planned: NestedType, siblingNames: NameRegistry, ): TypeSpec { val name = siblingNames.register(planned.simpleName) @@ -116,7 +113,7 @@ internal object ModelGenerator { hierarchy.registerInlineRef(planned.id, className) return when (planned) { - is PlannedInlineType.Obj -> { + is NestedType.Obj -> { val childNames = NameRegistry() val childSpecs = planned.children.map { emitNestedInline(className, it, childNames) } @@ -129,7 +126,7 @@ internal object ModelGenerator { builder.build() } - is PlannedInlineType.Enum -> { + is NestedType.Enum -> { buildEnumType(name, planned.model) } } @@ -137,27 +134,27 @@ internal object ModelGenerator { context(hierarchy: Hierarchy) private fun generateSchemaFiles( - transformed: TransformedSchema, - inlineByName: Map>, + resolved: ResolvedSchema, + inlineByName: Map>, ): List = when { - !transformed.schema.anyOf.isNullOrEmpty() || !transformed.schema.oneOf.isNullOrEmpty() -> { - if (transformed.schema.name in hierarchy.anyOfWithoutDiscriminator) { + !resolved.schema.anyOf.isNullOrEmpty() || !resolved.schema.oneOf.isNullOrEmpty() -> { + if (resolved.schema.name in hierarchy.anyOfWithoutDiscriminator) { listOf( - generateSealedInterface(transformed.schema), - generatePolymorphicSerializer(transformed.schema), + generateSealedInterface(resolved.schema), + generatePolymorphicSerializer(resolved.schema), ) } else { - listOf(generateSealedHierarchy(transformed.schema, inlineByName)) + listOf(generateSealedHierarchy(resolved.schema, inlineByName)) } } - transformed.schema.isPrimitiveOnly -> { - val targetType = transformed.schema.underlyingType?.toTypeName() ?: STRING - listOf(generateTypeAlias(transformed.schema, targetType)) + resolved.schema.isPrimitiveOnly -> { + val targetType = resolved.schema.underlyingType?.toTypeName() ?: STRING + listOf(generateTypeAlias(resolved.schema, targetType)) } else -> { - listOf(generateDataClass(transformed.schema, transformed.inlineTypes)) + listOf(generateDataClass(resolved.schema, resolved.inlineTypes)) } } @@ -165,10 +162,7 @@ internal object ModelGenerator { * Generates a sealed interface with nested subtypes for oneOf or anyOf-with-discriminator schemas. */ context(hierarchy: Hierarchy) - private fun generateSealedHierarchy( - schema: SchemaModel, - inlineByName: Map>, - ): FileSpec { + private fun generateSealedHierarchy(schema: SchemaModel, inlineByName: Map>): FileSpec { val className = hierarchy.classNameFor(schema.name) val parentBuilder = TypeSpec.interfaceBuilder(className).addModifiers(KModifier.SEALED) @@ -224,7 +218,7 @@ internal object ModelGenerator { variantName: String, parentClassName: ClassName, serialName: String, - inlineTypes: List, + inlineTypes: List, ): TypeSpec { val variantClassName = parentClassName.nestedClass(variantName.toPascalCase()) @@ -423,7 +417,7 @@ internal object ModelGenerator { * Used for: standalone schemas, allOf composed classes, and anyOf-without-discriminator variants. */ context(hierarchy: Hierarchy) - private fun generateDataClass(schema: SchemaModel, inlineTypes: List): FileSpec { + private fun generateDataClass(schema: SchemaModel, inlineTypes: List): FileSpec { val className = hierarchy.classNameFor(schema.name) // For anyOf-without-discriminator variants: find parent interfaces and serialName @@ -627,10 +621,10 @@ internal object ModelGenerator { is TypeRef.Reference, is TypeRef.InlineEnum, TypeRef.Unknown -> false } - private fun TransformedApiSpec.usesUuid(): Boolean { + private fun ResolvedApiSpec.usesUuid(): Boolean { val schemaRefs = schemas.asSequence().flatMap { schema -> schema.schema.properties.map { it.type } } - val endpointRefs = endpoints.asSequence().flatMap { transformed -> - val endpoint = transformed.endpoint + val endpointRefs = endpoints.asSequence().flatMap { resolved -> + val endpoint = resolved.endpoint val responseRefs = endpoint.responses.values .asSequence() .mapNotNull { it.schema } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt b/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt index ea166a91..056497fb 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt @@ -9,10 +9,8 @@ sealed interface TypeRef { data class Map(val valueType: TypeRef) : TypeRef - /** An anonymous object schema; the generator decides its name and placement. */ data class Inline(val properties: List, val requiredProperties: Set) : TypeRef - /** An anonymous enum schema; the generator decides its name and placement. */ data class InlineEnum(val values: List, val backingType: EnumBackingType) : TypeRef data object Unknown : TypeRef 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 565f5d3a..98ccedb4 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 @@ -484,7 +484,6 @@ object SpecParser { else -> TypeRef.Unknown } - /** An anonymous object schema becomes an [TypeRef.Inline]; the generator decides its name and placement. */ context(_: ComponentSchemaIdentity, _: ComponentSchemas) private fun Schema<*>.toInlineTypeRef(): TypeRef? = takeIf { isInlineObject }?.let { val required = required.orEmpty().toSet() @@ -504,15 +503,7 @@ object SpecParser { private val Schema<*>.isEnumSchema get(): Boolean = !enum.isNullOrEmpty() - /** - * True when a property of this type declares a `default` that should be honored, i.e. the - * property is not optional-null but carries a concrete value and must be generated as a - * non-nullable field initialized to that default rather than as `T? = null`. - * - * Honored for scalar/enum types (primitives, inline enums, references — the latter typically - * a named enum) and for arrays of such element types (emitted as `listOf(...)`/`emptyList()`). - * Object/map defaults are intentionally excluded and keep the previous nullable-null behavior. - */ + /** A scalar/enum/array default is generated as a non-nullable field initialized to it; object/map defaults aren't. */ private fun TypeRef.honorsDefault(default: Any?): Boolean = default != null && when (this) { is TypeRef.Primitive, is TypeRef.InlineEnum, is TypeRef.Reference -> true @@ -520,11 +511,7 @@ object SpecParser { is TypeRef.Inline, is TypeRef.Map, TypeRef.Unknown -> false } - /** - * Normalizes a raw Swagger default into a plain Kotlin value the model layer can format - * without depending on Jackson. Array defaults arrive as a Jackson [ArrayNode]; unwrap them - * into a `List` of plain scalar values. Scalar defaults are already plain and pass through. - */ + /** Unwraps a Jackson default node into a plain Kotlin value so the model layer needn't depend on Jackson. */ private fun normalizeDefault(default: Any?): Any? = when (default) { is ArrayNode -> default.map { normalizeDefault(it) } @@ -546,11 +533,7 @@ object SpecParser { else -> default } - /** - * True when the schema carries no structure at all — no `type`, `$ref`, properties, items, - * combinators, enum, or additionalProperties (e.g. `{}` or `{ "nullable": true }`). As a - * response body this means "no content", which is generated as a `Unit` return type. - */ + /** A structureless schema (e.g. `{}`); as a response body it means "no content" → `Unit`. */ private val Schema<*>.isEmptyContent: Boolean get() = `$ref` == null && type == null && @@ -562,11 +545,7 @@ object SpecParser { additionalProperties == null && items == null - /** - * Builds a [TypeRef.InlineEnum] for an enum schema that is not a named component - * (e.g. an enum declared directly in array `items` or inline on a property). - * Named component enums are resolved to [TypeRef.Reference] before reaching here. - */ + /** An anonymous enum (in array `items` or on a property) becomes [TypeRef.InlineEnum]; named ones are references. */ private fun Schema<*>.inlineEnum(backingType: EnumBackingType): TypeRef.InlineEnum? = enum?.filterNotNull()?.takeIf { it.isNotEmpty() }?.let { values -> TypeRef.InlineEnum(values = values.map { it.toString() }, backingType = backingType) 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 5a6978f8..193655bc 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 @@ -30,15 +30,15 @@ class ClientGeneratorTest { private val modelPackage = "com.example.model" private fun generate(spec: ApiSpec, hasPolymorphicTypes: Boolean = false): List = - spec.transform().let { transformed -> + spec.resolveInlines().let { resolved -> context( Hierarchy(ModelPackage(modelPackage)).apply { - addSchemas(transformed.schemas.map { it.schema }) + addSchemas(resolved.schemas.map { it.schema }) }, ApiPackage(apiPackage), NameRegistry(), ) { - ClientGenerator.generate(transformed, hasPolymorphicTypes) + ClientGenerator.generate(resolved, hasPolymorphicTypes) } } 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 aae83fcc..04bcbee2 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 @@ -40,25 +40,29 @@ class IntegrationTest { } } - private fun hierarchyFor(transformed: TransformedApiSpec) = Hierarchy(ModelPackage(modelPackage)).apply { - addSchemas(transformed.schemas.map { it.schema }) + private fun hierarchyFor(resolved: ResolvedApiSpec) = Hierarchy(ModelPackage(modelPackage)).apply { + addSchemas(resolved.schemas.map { it.schema }) } private fun generateModel(spec: ApiSpec): List { - val transformed = spec.transform() - return context(hierarchyFor(transformed), NameRegistry()) { ModelGenerator.generate(transformed) } + val resolved = spec.resolveInlines() + return context(hierarchyFor(resolved), NameRegistry()) { + val _ = contextOf() + _ + ModelGenerator.generate(resolved).files + } } private fun generateModelWithResolvedSpec(spec: ApiSpec): ModelGenerator.GenerateResult { - val transformed = spec.transform() - return context(hierarchyFor(transformed), NameRegistry()) { - ModelGenerator.generateWithResolvedSpec(transformed) + val resolved = spec.resolveInlines() + return context(hierarchyFor(resolved), NameRegistry()) { + ModelGenerator.generate(resolved) } } - private fun generateClient(transformed: TransformedApiSpec, hasPolymorphicTypes: Boolean = false,): List = - context(hierarchyFor(transformed), ApiPackage(apiPackage), NameRegistry()) { - ClientGenerator.generate(transformed, hasPolymorphicTypes) + private fun generateClient(resolved: ResolvedApiSpec, hasPolymorphicTypes: Boolean = false,): List = + context(hierarchyFor(resolved), ApiPackage(apiPackage), NameRegistry()) { + ClientGenerator.generate(resolved, hasPolymorphicTypes) } @Test diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorInlineEnumTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorInlineEnumTest.kt index efeee957..fc35243f 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorInlineEnumTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorInlineEnumTest.kt @@ -13,18 +13,20 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue /** - * Inline enums are lifted by [ApiSpec.transform] and generated as enum classes nested inside the + * Inline enums are lifted by [ApiSpec.resolveInlines] and generated as enum classes nested inside the * type that owns them (mirroring how inline objects are nested), named after their position. */ class ModelGeneratorInlineEnumTest { private val modelPackage = "com.example.model" - private fun generate(spec: ApiSpec) = spec.transform().let { transformed -> + private fun generate(spec: ApiSpec) = spec.resolveInlines().let { resolved -> context( - Hierarchy(ModelPackage(modelPackage)).apply { addSchemas(transformed.schemas.map { it.schema }) }, + Hierarchy(ModelPackage(modelPackage)).apply { addSchemas(resolved.schemas.map { it.schema }) }, NameRegistry(), ) { - ModelGenerator.generate(transformed) + val _ = contextOf() + _ + ModelGenerator.generate(resolved).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 bc49e699..abdf1093 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,7 +1,6 @@ package com.avsystem.justworks.core.gen import com.avsystem.justworks.core.gen.model.ModelGenerator -import com.avsystem.justworks.core.gen.toPascalCase import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.Discriminator import com.avsystem.justworks.core.model.EnumBackingType @@ -21,14 +20,16 @@ import kotlin.test.assertTrue class ModelGeneratorPolymorphicTest { private val modelPackage = "com.example.model" - private fun generate(spec: ApiSpec) = spec.transform().let { transformed -> + private fun generate(spec: ApiSpec) = spec.resolveInlines().let { resolved -> context( Hierarchy(ModelPackage(modelPackage)).apply { - addSchemas(transformed.schemas.map { it.schema }) + addSchemas(resolved.schemas.map { it.schema }) }, NameRegistry(), ) { - ModelGenerator.generate(transformed) + val _ = contextOf() + _ + ModelGenerator.generate(resolved).files } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorRegressionTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorRegressionTest.kt index 8cfb73db..24a2cd04 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorRegressionTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorRegressionTest.kt @@ -12,11 +12,15 @@ class ModelGeneratorRegressionTest { private val modelPackage = "com.example.model" private fun generate(spec: ApiSpec): List { - val transformed = spec.transform() + val resolved = spec.resolveInlines() val hierarchy = Hierarchy(ModelPackage(modelPackage)).apply { - addSchemas(transformed.schemas.map { it.schema }) + addSchemas(resolved.schemas.map { it.schema }) + } + return context(hierarchy, NameRegistry()) { + val _ = contextOf() + _ + ModelGenerator.generate(resolved).files } - return context(hierarchy, NameRegistry()) { ModelGenerator.generate(transformed) } } private fun spec(schemas: List = emptyList()) = ApiSpec( 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 957f5e0c..22674c3e 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 @@ -24,14 +24,16 @@ import kotlin.test.assertTrue class ModelGeneratorTest { private val modelPackage = "com.example.model" - private fun generate(spec: ApiSpec) = spec.transform().let { transformed -> + private fun generate(spec: ApiSpec) = spec.resolveInlines().let { resolved -> context( Hierarchy(ModelPackage(modelPackage)).apply { - addSchemas(transformed.schemas.map { it.schema }) + addSchemas(resolved.schemas.map { it.schema }) }, NameRegistry(), ) { - ModelGenerator.generate(transformed) + val _ = contextOf() + _ + ModelGenerator.generate(resolved).files } }