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 cc9e76ea..6bd37f7d 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 @@ -21,12 +21,14 @@ object CodeGenerator { outputDir: File, options: OutputOptions = OutputOptions(), ): Result { + val resolvedApiSpec = spec.resolveInlines() + val hierarchy = Hierarchy(ModelPackage(modelPackage)).apply { - addSchemas(spec.schemas) + addSchemas(resolvedApiSpec.schemas.map { it.schema }) } val (modelFiles, resolvedSpec) = context(hierarchy, options, NameRegistry()) { - ModelGenerator.generateWithResolvedSpec(spec) + ModelGenerator.generateWithResolvedSpec(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 b03f363e..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 @@ -10,6 +10,17 @@ internal class Hierarchy(val modelPackage: ModelPackage) { private val schemaModels = mutableSetOf() private val memoScope = MemoScope() + /** + * Resolution overrides for inline body types nested inside client/model classes, + * keyed by the reference id assigned by [resolved]. + */ + private val inlineRefs = mutableMapOf() + + /** Registers the nested [ClassName] an inline 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). @@ -60,20 +71,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 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] ?: classNameFor(name) } private fun SchemaModel.variants() = oneOf ?: anyOf diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineCollector.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineCollector.kt deleted file mode 100644 index 867ab30c..00000000 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineCollector.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.avsystem.justworks.core.gen - -import com.avsystem.justworks.core.model.ApiSpec -import com.avsystem.justworks.core.model.EnumModel -import com.avsystem.justworks.core.model.SchemaModel -import com.avsystem.justworks.core.model.TypeRef - -// Hoists anonymous TypeRef.InlineType nodes into named models, producing the -// structural-key -> name maps consumed by resolveInlineTypes. - -private fun ApiSpec.topLevelTypeRefs(): Sequence { - val endpointRefs = endpoints.asSequence().flatMap { endpoint -> - endpoint.responses.values.map { it.schema } + endpoint.requestBody?.schema - } - val schemaPropertyRefs = schemas.asSequence().flatMap { schema -> schema.properties.map { it.type } } - return (endpointRefs + schemaPropertyRefs).filterNotNull() -} - -/** - * 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 here keeps inline-enum - * collection, resolution, and generation consistent, so no orphan `*_Type` enum is hoisted. - * - * The @SerialName value comes from the parent's `discriminator.mapping` - * (see [com.avsystem.justworks.core.gen.resolveSerialName]), so dropping the field is lossless. - */ -internal fun ApiSpec.stripDiscriminatorProperties(): ApiSpec { - val discriminatorProps = schemas - .asSequence() - .mapNotNull { parent -> parent.discriminator?.propertyName?.let { it to parent } } - .flatMap { (propertyName, parent) -> - (parent.oneOf.orEmpty() + parent.anyOf.orEmpty()) - .asSequence() - .filterIsInstance() - .map { it.schemaName to propertyName } - }.toMap() - - return if (discriminatorProps.isEmpty()) { - this - } else { - copy( - schemas = schemas.map { schema -> - when (val discriminatorProp = discriminatorProps[schema.name]) { - null -> schema - else -> schema.copy(properties = schema.properties.filterNot { it.name == discriminatorProp }) - } - }, - ) - } -} - -private val descendants = DeepRecursiveFunction> { type -> - listOf(type) + when (type) { - is TypeRef.Inline -> type.properties.flatMap { callRecursive(it.type) } - is TypeRef.Array -> callRecursive(type.items) - is TypeRef.Map -> callRecursive(type.valueType) - is TypeRef.Primitive, is TypeRef.Reference, is TypeRef.InlineEnum, TypeRef.Unknown -> emptyList() - } -} - -private inline fun ApiSpec.inlineRefs(): Sequence = - topLevelTypeRefs().flatMap { descendants(it) }.filterIsInstance() - -context(nameRegistry: NameRegistry) -private fun Sequence.toNamedModels( - keyOf: (T) -> K, - modelOf: (T, String) -> M, -): Pair, Map> { - val nameMap = mutableMapOf() - val models = sortedBy { it.contextHint } - .distinctBy(keyOf) - .map { ref -> - val generatedName = nameRegistry.register(ref.contextHint.toInlinedName()) - nameMap[keyOf(ref)] = generatedName - modelOf(ref, generatedName) - }.toList() - return models to nameMap -} - -context(_: NameRegistry) -internal fun collectInlineSchemas(spec: ApiSpec): Pair, Map> = - spec.inlineRefs().toNamedModels( - keyOf = { InlineSchemaKey.from(it.properties, it.requiredProperties) }, - modelOf = { ref, name -> - SchemaModel( - name = name, - description = null, - properties = ref.properties, - requiredProperties = ref.requiredProperties, - allOf = null, - oneOf = null, - anyOf = null, - discriminator = null, - ) - }, - ) - -context(_: NameRegistry) -internal fun collectInlineEnums(spec: ApiSpec): Pair, Map> = - spec.inlineRefs().toNamedModels( - keyOf = InlineEnumKey::from, - modelOf = { ref, name -> - EnumModel( - name = name, - description = null, - type = ref.backingType, - values = ref.values.map { EnumModel.Value(it) }, - ) - }, - ) 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 4448e0c7..00000000 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaKey.kt +++ /dev/null @@ -1,54 +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 = it.type.normalized(), - required = it.name in required, - nullable = it.nullable, - defaultValue = it.defaultValue, - ) - } - return InlineSchemaKey(keys.toSet()) - } - - private fun TypeRef.normalized(): TypeRef = when (this) { - is TypeRef.Inline -> TypeRef.Inline( - properties = properties - .map { it.copy(type = it.type.normalized()) } - .sortedBy { it.name }, - requiredProperties = this.requiredProperties, - contextHint = "", - ) - - is TypeRef.Array -> TypeRef.Array(items.normalized(), unique) - - is TypeRef.Map -> TypeRef.Map(valueType.normalized()) - - is TypeRef.InlineEnum -> copy(contextHint = "") - - else -> this - } - } -} 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 74283e23..00000000 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineTypeResolver.kt +++ /dev/null @@ -1,104 +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.EnumBackingType -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 - -/** - * Structural key for an inline enum, ignoring [TypeRef.InlineEnum.contextHint]. - * Two inline enums are considered the same generated type if they share the same - * ordered values and backing type. - */ -internal data class InlineEnumKey(val values: List, val backingType: EnumBackingType) { - companion object { - fun from(type: TypeRef.InlineEnum) = InlineEnumKey(type.values, type.backingType) - } -} - -/** - * Resolves a single [TypeRef.Inline]/[TypeRef.InlineEnum] to [TypeRef.Reference] using the - * provided maps. Non-inline types are returned as-is; containers ([TypeRef.Array], [TypeRef.Map]) - * are resolved recursively. - */ -internal fun ApiSpec.resolveTypeRef( - type: TypeRef, - nameMap: Map, - enumNameMap: Map = emptyMap(), -): 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.InlineEnum -> { - val className = enumNameMap[InlineEnumKey.from(type)] - ?: error( - "Missing inline enum mapping for key (contextHint=${type.contextHint}). " + - "This indicates a mismatch between inline enum collection and resolution.", - ) - TypeRef.Reference(className) - } - - is TypeRef.Array -> { - TypeRef.Array(resolveTypeRef(type.items, nameMap, enumNameMap), type.unique) - } - - is TypeRef.Map -> { - TypeRef.Map(resolveTypeRef(type.valueType, nameMap, enumNameMap)) - } - - else -> { - type - } -} - -/** - * Rewrites all [TypeRef.Inline]/[TypeRef.InlineEnum] references in an [ApiSpec] to - * [TypeRef.Reference], using the provided maps from structural keys to generated names. - * - * This is applied once after inline collection, so downstream generators - * never encounter inline types and need no special handling. - */ -internal fun ApiSpec.resolveInlineTypes( - nameMap: Map, - enumNameMap: Map = emptyMap(), -): ApiSpec { - if (nameMap.isEmpty() && enumNameMap.isEmpty()) return this - - fun TypeRef.resolve(): TypeRef = resolveTypeRef(this, nameMap, enumNameMap) - - 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/ResolvedApiSpec.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ResolvedApiSpec.kt new file mode 100644 index 00000000..4b97c0fd --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ResolvedApiSpec.kt @@ -0,0 +1,179 @@ +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) 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 NestedType { + val id: String + val simpleName: String + + data class Obj( + override val id: String, + override val simpleName: String, + val schema: SchemaModel, + val children: List, + ) : NestedType + + data class Enum( + override val id: String, + override val simpleName: String, + val model: EnumModel, + ) : 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 ResolvedApiSpec( + val title: String, + val version: String, + val endpoints: List, + val schemas: List, + val enums: List, + val securitySchemes: List, +) + +internal data class ResolvedEndpoint(val endpoint: Endpoint, val inlineTypes: List) + +internal data class ResolvedSchema(val schema: SchemaModel, val inlineTypes: List) + +/** 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) = + 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 schema = SchemaModel( + name = hint, + description = null, + properties = planProperties(type.properties, children), + requiredProperties = type.requiredProperties, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + TypeRef.Reference(id) to NestedType.Obj(id, hint, schema, children) + } + + is TypeRef.InlineEnum -> { + val id = "inline${counter++}" + val model = EnumModel( + name = hint, + description = null, + type = type.backingType, + values = type.values.map { EnumModel.Value(it) }, + ) + TypeRef.Reference(id) to NestedType.Enum(id, hint, model) + } + + is TypeRef.Array -> { + plan(type.items, "${hint}Item").let { (item, child) -> type.copy(items = item) to child } + } + + is TypeRef.Map -> { + plan(type.valueType, "${hint}Value").let { (value, child) -> TypeRef.Map(value) to child } + } + + else -> { + type to null + } + } + + private fun MutableList.collect(type: TypeRef, hint: String): TypeRef { + val (rewritten, nested) = plan(type, hint) + if (nested != null) add(nested) + return rewritten + } + + fun run(): ResolvedApiSpec { + val spec = this@resolveInlines.stripDiscriminatorProperties() + + 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 + } + + ResolvedEndpoint(endpoint.copy(requestBody = newRequestBody, responses = newResponses), owned) + } + + val schemas = spec.schemas.map { schema -> + val owned = mutableListOf() + val newProperties = planProperties(schema.properties, owned) + ResolvedSchema(schema.copy(properties = newProperties), owned) + } + + return ResolvedApiSpec(spec.title, spec.version, endpoints, schemas, spec.enums, spec.securitySchemes) + } +}.run() + +/** + * 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 + .asSequence() + .mapNotNull { parent -> parent.discriminator?.propertyName?.let { it to parent } } + .flatMap { (propertyName, parent) -> + (parent.oneOf.orEmpty() + parent.anyOf.orEmpty()) + .asSequence() + .filterIsInstance() + .map { it.schemaName to propertyName } + }.toMap() + + return if (discriminatorProps.isEmpty()) { + this + } else { + copy( + schemas = schemas.map { schema -> + when (val discriminatorProp = discriminatorProps[schema.name]) { + null -> schema + else -> schema.copy(properties = schema.properties.filterNot { it.name == discriminatorProp }) + } + }, + ) + } +} 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 0b5dd4c4..5211d516 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 @@ -66,11 +66,11 @@ 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 ApiSpec.transform") } is TypeRef.InlineEnum -> { - error("TypeRef.InlineEnum should have been resolved by InlineTypeResolver (contextHint=$contextHint)") + error("TypeRef.InlineEnum 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 4c5cf6f8..849da8eb 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 @@ -18,17 +18,19 @@ 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.OutputOptions +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.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 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 @@ -56,17 +58,23 @@ internal object ClientGenerator { private const val DEFAULT_TAG = "Default" context(_: Hierarchy, _: OutputOptions, _: ApiPackage, _: NameRegistry) - fun generate(spec: ApiSpec, hasPolymorphicTypes: Boolean): List { - val grouped = spec.endpoints.groupBy { it.tags.firstOrNull() ?: DEFAULT_TAG } + fun generate(spec: ResolvedApiSpec, hasPolymorphicTypes: Boolean): List { + val grouped = spec.endpoints.groupBy { it.endpoint.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, + ) } } context(hierarchy: Hierarchy, options: OutputOptions, apiPackage: ApiPackage, nameRegistry: NameRegistry) private fun generateClientFile( tag: String, - endpoints: List, + endpoints: List, hasPolymorphicTypes: Boolean, securitySchemes: List, specTitle: String, @@ -139,8 +147,15 @@ internal object ClientGenerator { classBuilder.addFunction(buildApplyAuth(securitySchemes, isSingleBearer, specTitle)) } + val nestedNames = NameRegistry() + endpoints + .flatMap { it.inlineTypes } + .forEach { nested -> + classBuilder.addType(ModelGenerator.emitNestedInline(className, nested, 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 eb665f31..df9f25c2 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,33 +14,28 @@ 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.OutputOptions import com.avsystem.justworks.core.gen.PRIMITIVE_KIND import com.avsystem.justworks.core.gen.PRIMITIVE_SERIAL_DESCRIPTOR_FUN +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.SERIALIZERS_MODULE import com.avsystem.justworks.core.gen.SERIAL_DESCRIPTOR import com.avsystem.justworks.core.gen.SERIAL_NAME import com.avsystem.justworks.core.gen.USE_SERIALIZERS import com.avsystem.justworks.core.gen.UUID_SERIALIZER import com.avsystem.justworks.core.gen.UUID_TYPE -import com.avsystem.justworks.core.gen.collectInlineEnums -import com.avsystem.justworks.core.gen.collectInlineSchemas -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.stripDiscriminatorProperties import com.avsystem.justworks.core.gen.toCamelCase import com.avsystem.justworks.core.gen.toEnumConstantName 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 @@ -63,96 +58,107 @@ 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 an [ApiSpec]. + * 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: ApiSpec) + data class GenerateResult(val files: List, val resolvedSpec: ResolvedApiSpec) context(_: Hierarchy, _: OutputOptions, _: NameRegistry) - fun generate(spec: ApiSpec): List = generateWithResolvedSpec(spec).files - - context(hierarchy: Hierarchy, _: OutputOptions, nameRegistry: NameRegistry) - fun generateWithResolvedSpec(rawSpec: ApiSpec): GenerateResult { - val spec = rawSpec.stripDiscriminatorProperties() - ensureReserved(spec, nameRegistry) - val (inlineSchemas, nameMap) = collectInlineSchemas(spec) - val (inlineEnums, enumNameMap) = collectInlineEnums(spec) - val resolvedSpec = spec.resolveInlineTypes(nameMap, enumNameMap) - - val resolvedInlineSchemas = inlineSchemas.map { schema -> - schema.copy( - properties = schema.properties.map { prop -> - prop.copy(type = resolvedSpec.resolveTypeRef(prop.type, nameMap, enumNameMap)) - }, - ) - } - - hierarchy.addSchemas(resolvedSpec.schemas + resolvedInlineSchemas) + fun generate(spec: ResolvedApiSpec): List = generateWithResolvedSpec(spec).files + context(hierarchy: Hierarchy, _: OutputOptions, _: NameRegistry) + fun generateWithResolvedSpec(spec: ResolvedApiSpec): GenerateResult { val nestedVariantNames = hierarchy.sealedHierarchies .asSequence() .filterNot { (key, _) -> key in hierarchy.anyOfWithoutDiscriminator } .flatMap { (_, names) -> names } .toSet() - val schemaFiles = resolvedSpec.schemas + // Inline types to nest, keyed by owner schema name — variants need their owner's lookup too. + val inlineByName = spec.schemas.associate { it.schema.name to it.inlineTypes } + + val schemaFiles = spec.schemas .asSequence() - .filterNot { it.name in nestedVariantNames } - .flatMap { generateSchemaFiles(it) } + .filterNot { it.schema.name in nestedVariantNames } + .flatMap { generateSchemaFiles(it, inlineByName) } .toList() - val inlineSchemaFiles = resolvedInlineSchemas.map { generateDataClass(it) } - - val enumFiles = (resolvedSpec.enums + inlineEnums).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. + * 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. */ - 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(hierarchy: Hierarchy, _: OutputOptions) + internal fun emitNestedInline( + parentClass: ClassName, + planned: NestedType, + siblingNames: NameRegistry, + ): TypeSpec { + val name = siblingNames.register(planned.simpleName) + val className = parentClass.nestedClass(name) + hierarchy.registerInlineRef(planned.id, className) + + return when (planned) { + is NestedType.Obj -> { + val childNames = NameRegistry() + val childSpecs = planned.children.map { emitNestedInline(className, it, childNames) } + + val builder = TypeSpec + .classBuilder(name) + .addModifiers(KModifier.DATA) + .addAnnotation(SERIALIZABLE) + buildConstructorAndProperties(planned.schema, builder) + childSpecs.forEach { builder.addType(it) } + builder.build() + } + + is NestedType.Enum -> { + buildEnumType(name, planned.model) + } + } } context(hierarchy: Hierarchy, _: OutputOptions) - private fun generateSchemaFiles(schema: SchemaModel): List = when { - !schema.anyOf.isNullOrEmpty() || !schema.oneOf.isNullOrEmpty() -> { - if (schema.name in hierarchy.anyOfWithoutDiscriminator) { + private fun generateSchemaFiles( + resolved: ResolvedSchema, + inlineByName: Map>, + ): List = when { + !resolved.schema.anyOf.isNullOrEmpty() || !resolved.schema.oneOf.isNullOrEmpty() -> { + if (resolved.schema.name in hierarchy.anyOfWithoutDiscriminator) { listOf( - generateSealedInterface(schema), - generatePolymorphicSerializer(schema), + generateSealedInterface(resolved.schema), + generatePolymorphicSerializer(resolved.schema), ) } else { - listOf(generateSealedHierarchy(schema)) + listOf(generateSealedHierarchy(resolved.schema, inlineByName)) } } - schema.isPrimitiveOnly -> { - val targetType = schema.underlyingType?.toTypeName() ?: STRING - listOf(generateTypeAlias(schema, targetType)) + resolved.schema.isPrimitiveOnly -> { + val targetType = resolved.schema.underlyingType?.toTypeName() ?: STRING + listOf(generateTypeAlias(resolved.schema, targetType)) } else -> { - listOf(generateDataClass(schema)) + listOf(generateDataClass(resolved.schema, resolved.inlineTypes)) } } @@ -160,8 +166,8 @@ internal object ModelGenerator { * Generates a sealed interface with nested subtypes for oneOf or anyOf-with-discriminator schemas. */ context(hierarchy: Hierarchy, options: OutputOptions) - private fun generateSealedHierarchy(schema: SchemaModel): FileSpec { - val className = ClassName(hierarchy.modelPackage, schema.name) + private fun generateSealedHierarchy(schema: SchemaModel, inlineByName: Map>): FileSpec { + val className = hierarchy.classNameFor(schema.name) val parentBuilder = TypeSpec.interfaceBuilder(className).addModifiers(KModifier.SEALED) parentBuilder.addAnnotation(SERIALIZABLE) @@ -187,6 +193,7 @@ internal object ModelGenerator { variantName = variantName, parentClassName = className, serialName = schema.resolveSerialName(variantName), + inlineTypes = inlineByName[variantName].orEmpty(), ) parentBuilder.addType(nestedType) } @@ -215,6 +222,7 @@ internal object ModelGenerator { variantName: String, parentClassName: ClassName, serialName: String, + inlineTypes: List, ): TypeSpec { val variantClassName = parentClassName.nestedClass(variantName.toPascalCase()) @@ -228,9 +236,14 @@ internal object ModelGenerator { builder.addAnnotation(SERIALIZABLE) builder.addAnnotation(AnnotationSpec.builder(SERIAL_NAME).addMember("%S", serialName).build()) + // Nest the variant's own inline types, registering their ids before resolving properties. + val nestedNames = NameRegistry() + val nestedSpecs = inlineTypes.map { emitNestedInline(variantClassName, it, nestedNames) } + if (!variantSchema?.properties.isNullOrEmpty()) { buildConstructorAndProperties(variantSchema, builder) } + nestedSpecs.forEach { builder.addType(it) } return builder.build() } @@ -283,11 +296,11 @@ internal object ModelGenerator { */ context(hierarchy: Hierarchy, options: OutputOptions) 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) @@ -307,8 +320,8 @@ internal object ModelGenerator { */ context(hierarchy: Hierarchy, _: OutputOptions) 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() @@ -408,12 +421,12 @@ internal object ModelGenerator { * Used for: standalone schemas, allOf composed classes, and anyOf-without-discriminator variants. */ context(hierarchy: Hierarchy, _: OutputOptions) - private fun generateDataClass(schema: SchemaModel): FileSpec { - val className = ClassName(hierarchy.modelPackage, schema.name) + private fun generateDataClass(schema: SchemaModel, inlineTypes: List): FileSpec { + 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) } @@ -433,7 +446,13 @@ 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 = inlineTypes.map { emitNestedInline(className, it, nestedNames) } + buildConstructorAndProperties(schema, typeSpec) + nestedSpecs.forEach { typeSpec.addType(it) } val fileBuilder = FileSpec.builder(className).addType(typeSpec.build()) @@ -543,7 +562,7 @@ internal object ModelGenerator { is TypeRef.Reference -> { val constantName = value.toString().toEnumConstantName() - CodeBlock.of("%T.%L", ClassName(hierarchy.modelPackage, type.schemaName), constantName) + CodeBlock.of("%T.%L", hierarchy.classNameFor(type.schemaName), constantName) } is TypeRef.Array -> { @@ -563,9 +582,17 @@ internal object ModelGenerator { context(hierarchy: Hierarchy, options: OutputOptions) private fun generateEnumClass(enum: EnumModel): FileSpec { - val className = ClassName(hierarchy.modelPackage, enum.name) + val className = hierarchy.classNameFor(enum.name) + return FileSpec + .builder(className) + .addType(buildEnumType(className.simpleName, enum)) + .build() + } - val typeSpec = TypeSpec.enumBuilder(className).addAnnotation(SERIALIZABLE) + /** Builds an `@Serializable enum class` [TypeSpec] with the given simple [name]. */ + context(options: OutputOptions) + private fun buildEnumType(name: String, enum: EnumModel): TypeSpec { + val typeSpec = TypeSpec.enumBuilder(name).addAnnotation(SERIALIZABLE) val enumRegistry = NameRegistry() enum.values.forEach { value -> @@ -585,10 +612,7 @@ internal object ModelGenerator { typeSpec.addKdoc("%L", enum.description) } - return FileSpec - .builder(className) - .addType(typeSpec.build()) - .build() + return typeSpec.build() } private val SchemaModel.isPrimitiveOnly: Boolean @@ -602,9 +626,10 @@ internal object ModelGenerator { is TypeRef.Reference, is TypeRef.InlineEnum, 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 ResolvedApiSpec.usesUuid(): Boolean { + val schemaRefs = schemas.asSequence().flatMap { schema -> schema.schema.properties.map { it.type } } + val endpointRefs = endpoints.asSequence().flatMap { resolved -> + val endpoint = resolved.endpoint val responseRefs = endpoint.responses.values .asSequence() .mapNotNull { it.schema } @@ -661,9 +686,9 @@ internal object ModelGenerator { context(hierarchy: Hierarchy, options: OutputOptions) 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 (options.generateKdoc && schema.description != null) { typeAlias.addKdoc("%L", schema.description) 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 a128b2aa..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,27 +9,11 @@ sealed interface TypeRef { data class Map(val valueType: TypeRef) : TypeRef - data class Inline( - val properties: List, - val requiredProperties: Set, - override val contextHint: String, // "request"|"response"|property name for context-aware naming - ) : InlineType - - data class InlineEnum( - val values: List, - val backingType: EnumBackingType, - override val contextHint: String, // property/item name for context-aware naming - ) : InlineType + data class Inline(val properties: List, val requiredProperties: Set) : TypeRef - data object Unknown : TypeRef + data class InlineEnum(val values: List, val backingType: EnumBackingType) : TypeRef - /** - * A schema defined inline (anonymously) that must be hoisted into a named generated - * declaration. [contextHint] is the seed used to derive that name. - */ - sealed interface InlineType : TypeRef { - val contextHint: String - } + data object Unknown : TypeRef } enum class PrimitiveType { STRING, INT, LONG, DOUBLE, FLOAT, BOOLEAN, BYTE_ARRAY, DATE_TIME, DATE, UUID } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt index 292fd894..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 @@ -265,7 +265,7 @@ object SpecParser { val mediaType = content[contentType].bind() val schema = mediaType.schema - ?.toTypeRef("${operationId.replaceFirstChar { it.uppercase() }}Request") + ?.toTypeRef() .bind() RequestBody( @@ -285,7 +285,7 @@ object SpecParser { ?.get(ContentType.JSON_CONTENT_TYPE.value) ?.schema ?.takeUnless { it.isEmptyContent } - ?.toTypeRef("${operationId.replaceFirstChar { it.uppercase() }}Response"), + ?.toTypeRef(), ) } @@ -333,7 +333,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 @@ -387,17 +387,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) } @@ -454,19 +453,19 @@ 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) { - "string" -> inlineEnum(contextName, EnumBackingType.STRING) + private fun Schema<*>.resolveByType(): TypeRef = when (type) { + "string" -> inlineEnum(EnumBackingType.STRING) ?: STRING_FORMAT_MAP[format] ?: TypeRef.Primitive(PrimitiveType.STRING) - "integer" -> inlineEnum(contextName, EnumBackingType.INTEGER) + "integer" -> inlineEnum(EnumBackingType.INTEGER) ?: INTEGER_FORMAT_MAP[format] ?: TypeRef.Primitive(PrimitiveType.INT) @@ -474,13 +473,10 @@ object SpecParser { "boolean" -> TypeRef.Primitive(PrimitiveType.BOOLEAN) - "array" -> TypeRef.Array( - items?.toTypeRef(contextName?.let { "${it}Item" }) ?: TypeRef.Unknown, - unique = uniqueItems == true, - ) + "array" -> TypeRef.Array(items?.toTypeRef() ?: TypeRef.Unknown, unique = uniqueItems == true) "object" -> when (val ap = additionalProperties) { - is Schema<*> -> TypeRef.Map(ap.toTypeRef(contextName?.let { "${it}Value" })) + is Schema<*> -> TypeRef.Map(ap.toTypeRef()) is Boolean -> if (ap) TypeRef.Map(TypeRef.Unknown) else TypeRef.Unknown else -> title?.let(TypeRef::Reference) ?: TypeRef.Unknown } @@ -489,12 +485,11 @@ object SpecParser { } 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, ) } @@ -508,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 @@ -524,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) } @@ -550,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 && @@ -566,34 +545,25 @@ 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. - */ - private fun Schema<*>.inlineEnum(contextName: String?, backingType: EnumBackingType): TypeRef.InlineEnum? = + /** 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, - contextHint = contextName ?: "InlineEnum", - ) + TypeRef.InlineEnum(values = values.map { it.toString() }, backingType = backingType) } context(_: ComponentSchemaIdentity, _: ComponentSchemas) - private fun Schema<*>.propertyModels(required: Set, createContext: (String) -> String? = { null }) = - properties - .orEmpty() - .mapValues { (propName, propSchema) -> - val type = propSchema.toTypeRef(createContext(propName)) - PropertyModel( - name = propName, - type = type, - description = propSchema.description, - nullable = propName !in required && !type.honorsDefault(propSchema.default), - defaultValue = normalizeDefault(propSchema.default), - ) - } + private fun Schema<*>.propertyModels(required: Set) = properties + .orEmpty() + .mapValues { (propName, propSchema) -> + val type = propSchema.toTypeRef() + PropertyModel( + name = propName, + type = type, + description = propSchema.description, + nullable = propName !in required && !type.honorsDefault(propSchema.default), + defaultValue = normalizeDefault(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 dde029e0..0fdf72d1 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 @@ -33,15 +33,15 @@ class ClientGeneratorTest { spec: ApiSpec, hasPolymorphicTypes: Boolean = false, options: OutputOptions = OutputOptions(), - ): List = context( - Hierarchy(ModelPackage(modelPackage)).apply { - addSchemas(spec.schemas) - }, - options, - ApiPackage(apiPackage), - NameRegistry(), - ) { - ClientGenerator.generate(spec, hasPolymorphicTypes) + ): List = spec.resolveInlines().let { resolved -> + context( + Hierarchy(ModelPackage(modelPackage)).apply { addSchemas(resolved.schemas.map { it.schema }) }, + options, + ApiPackage(apiPackage), + NameRegistry(), + ) { + ClientGenerator.generate(resolved, hasPolymorphicTypes) + } } private fun spec(vararg endpoints: Endpoint) = spec(endpoints.toList()) @@ -582,7 +582,6 @@ class ClientGeneratorTest { PropertyModel("description", TypeRef.Primitive(PrimitiveType.STRING), null, false), ), requiredProperties = setOf("file", "description"), - contextHint = "request", ), ), ) @@ -606,7 +605,6 @@ class ClientGeneratorTest { PropertyModel("file", TypeRef.Primitive(PrimitiveType.BYTE_ARRAY), null, false), ), requiredProperties = setOf("file"), - contextHint = "request", ), ), ) @@ -632,7 +630,6 @@ class ClientGeneratorTest { PropertyModel("description", TypeRef.Primitive(PrimitiveType.STRING), null, false), ), requiredProperties = setOf("file", "description"), - contextHint = "request", ), ), ) @@ -655,7 +652,6 @@ class ClientGeneratorTest { PropertyModel("file", TypeRef.Primitive(PrimitiveType.BYTE_ARRAY), null, false), ), requiredProperties = setOf("file"), - contextHint = "request", ), ), ) @@ -732,7 +728,6 @@ class ClientGeneratorTest { PropertyModel("age", TypeRef.Primitive(PrimitiveType.INT), null, false), ), requiredProperties = setOf("username", "age"), - contextHint = "request", ), ), ) @@ -761,7 +756,6 @@ class ClientGeneratorTest { PropertyModel("age", TypeRef.Primitive(PrimitiveType.INT), null, false), ), requiredProperties = setOf("username", "age"), - contextHint = "request", ), ), ) @@ -786,7 +780,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 abd834a4..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 @@ -52,4 +52,159 @@ 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() + } + } + + @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() + } + } + + @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() + } + } } 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 197244d9..00000000 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDedupTest.kt +++ /dev/null @@ -1,184 +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 `array property differing only in uniqueItems produces different keys`() { - val listProps = listOf( - PropertyModel( - "tags", - TypeRef.Array(TypeRef.Primitive(PrimitiveType.STRING), unique = false), - null, - nullable = false, - ), - ) - val setProps = listOf( - PropertyModel( - "tags", - TypeRef.Array(TypeRef.Primitive(PrimitiveType.STRING), unique = true), - null, - nullable = false, - ), - ) - val required = setOf("tags") - - val listKey = InlineSchemaKey.from(listProps, required) - val setKey = InlineSchemaKey.from(setProps, required) - - assertNotEquals(listKey, setKey) - } - - @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 e047a428..c1105c36 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,33 +40,29 @@ class IntegrationTest { } } - private fun generateModel(spec: ApiSpec): List = context( - Hierarchy(ModelPackage(modelPackage)).apply { addSchemas(spec.schemas) }, - OutputOptions(), - NameRegistry(), - ) { - ModelGenerator.generate(spec) + private fun hierarchyFor(resolved: ResolvedApiSpec) = Hierarchy(ModelPackage(modelPackage)).apply { + addSchemas(resolved.schemas.map { it.schema }) } - private fun generateModelWithResolvedSpec(spec: ApiSpec): ModelGenerator.GenerateResult = context( - Hierarchy(ModelPackage(modelPackage)).apply { addSchemas(spec.schemas) }, - OutputOptions(), - NameRegistry(), - ) { - ModelGenerator.generateWithResolvedSpec(spec) + private fun generateModel(spec: ApiSpec): List { + val resolved = spec.resolveInlines() + return context(hierarchyFor(resolved), OutputOptions(), NameRegistry()) { + ModelGenerator.generate(resolved) + } } - private fun generateClient(spec: ApiSpec, hasPolymorphicTypes: Boolean = false): List = context( - Hierarchy(ModelPackage(modelPackage)).apply { - addSchemas(spec.schemas) - }, - OutputOptions(), - ApiPackage(apiPackage), - NameRegistry(), - ) { - ClientGenerator.generate(spec, hasPolymorphicTypes) + private fun generateModelWithResolvedSpec(spec: ApiSpec): ModelGenerator.GenerateResult { + val resolved = spec.resolveInlines() + return context(hierarchyFor(resolved), OutputOptions(), NameRegistry()) { + ModelGenerator.generateWithResolvedSpec(resolved) + } } + private fun generateClient(resolved: ResolvedApiSpec, hasPolymorphicTypes: Boolean = false): List = + context(hierarchyFor(resolved), OutputOptions(), ApiPackage(apiPackage), NameRegistry()) { + ClientGenerator.generate(resolved, hasPolymorphicTypes) + } + @Test fun `real-world specs generate compilable enum code without class body conflicts`() { for (fixture in SPEC_FIXTURES) { 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 82280444..52dd2c8a 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 @@ -12,291 +12,128 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue +/** + * 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) = context( - Hierarchy(ModelPackage(modelPackage)).apply { addSchemas(spec.schemas) }, - OutputOptions(), - NameRegistry(), - ) { - ModelGenerator.generate(spec) + private fun generate(spec: ApiSpec) = spec.resolveInlines().let { resolved -> + context( + Hierarchy(ModelPackage(modelPackage)).apply { addSchemas(resolved.schemas.map { it.schema }) }, + OutputOptions(), + NameRegistry(), + ) { + ModelGenerator.generate(resolved) + } + } + + private fun allTypes(files: List): List { + fun flatten(types: List): List = types.flatMap { listOf(it) + flatten(it.typeSpecs) } + return flatten(files.flatMap { it.members.filterIsInstance() }) } - private val inlineEnumSchema = SchemaModel( - name = "ExecuteSpeedTestRequest", + private fun schema(name: String, vararg properties: PropertyModel) = SchemaModel( + name = name, description = null, - properties = listOf( - PropertyModel( - name = "measurements", - type = TypeRef.Array( - TypeRef.InlineEnum( - values = listOf("DownloadSpeed", "UploadSpeed"), - backingType = EnumBackingType.STRING, - contextHint = "ExecuteSpeedTestRequest.MeasurementsItem", - ), - ), - description = null, - nullable = true, - ), - ), - requiredProperties = emptySet(), + properties = properties.toList(), + requiredProperties = properties.filterNot { it.nullable }.map { it.name }.toSet(), allOf = null, oneOf = null, anyOf = null, discriminator = null, ) - @Test - fun `inline enum in array items generates a typed enum class`() { - val files = generate( - ApiSpec("Test", "1.0", emptyList(), listOf(inlineEnumSchema), emptyList(), emptyList()), - ) - - val enumName = "ExecuteSpeedTestRequest_MeasurementsItem" - val enumType = files - .flatMap { it.members.filterIsInstance() } - .find { it.name == enumName } - assertNotNull(enumType, "Expected generated enum '$enumName'") - assertEquals( - setOf("DOWNLOAD_SPEED", "UPLOAD_SPEED"), - enumType.enumConstants.keys, - ) - } + private fun spec(schema: SchemaModel) = + ApiSpec("Test", "1.0", emptyList(), listOf(schema), emptyList(), emptyList()) @Test - fun `property references the generated enum instead of String`() { - val files = generate( - ApiSpec("Test", "1.0", emptyList(), listOf(inlineEnumSchema), emptyList(), emptyList()), - ) - - val dataClass = files - .flatMap { it.members.filterIsInstance() } - .find { it.name == "ExecuteSpeedTestRequest" } - assertNotNull(dataClass, "Expected data class") - val measurements = dataClass.propertySpecs.first { it.name == "measurements" } - val typeString = measurements.type.toString() - assertTrue( - typeString.contains("ExecuteSpeedTestRequest_MeasurementsItem"), - "Expected property to reference generated enum, got: $typeString", - ) - } - - @Test - fun `two properties with identical inline enums dedup to a single generated enum`() { - val values = listOf("DownloadSpeed", "UploadSpeed") - val schema = SchemaModel( - name = "Request", - description = null, - properties = listOf( - PropertyModel( - name = "primary", - type = TypeRef.Array( - TypeRef.InlineEnum(values, EnumBackingType.STRING, "Request.PrimaryItem"), - ), - description = null, - nullable = true, - ), - PropertyModel( - name = "secondary", - type = TypeRef.Array( - TypeRef.InlineEnum(values, EnumBackingType.STRING, "Request.SecondaryItem"), - ), - description = null, - nullable = true, - ), - ), - requiredProperties = emptySet(), - allOf = null, - oneOf = null, - anyOf = null, - discriminator = null, - ) - - val files = generate( - ApiSpec("Test", "1.0", emptyList(), listOf(schema), emptyList(), emptyList()), - ) - - val enums = files - .flatMap { it.members.filterIsInstance() } - .filter { it.enumConstants.isNotEmpty() } - assertEquals(1, enums.size, "Expected exactly one generated enum, got: ${enums.map { it.name }}") - - // Both properties reference the same generated enum. - val dataClass = files - .flatMap { it.members.filterIsInstance() } - .first { it.name == "Request" } - val enumName = enums.single().name - assertNotNull(enumName) - dataClass.propertySpecs.forEach { prop -> - assertTrue( - prop.type.toString().contains(enumName), - "Expected '${prop.name}' to reference '$enumName', got: ${prop.type}", - ) - } - } - - @Test - fun `inline schemas differing only in a nested enum contextHint dedup to one`() { - fun wrapper(enumHint: String, propHint: String) = TypeRef.Inline( - properties = listOf( - PropertyModel( - name = "mode", - type = TypeRef.InlineEnum( - values = listOf("Fast", "Slow"), - backingType = EnumBackingType.STRING, - contextHint = enumHint, - ), - description = null, - nullable = false, - ), + fun `inline enum in array items generates a typed enum nested in the owner`() { + val schema = schema( + "ExecuteSpeedTestRequest", + PropertyModel( + "measurements", + TypeRef.Array(TypeRef.InlineEnum(listOf("DownloadSpeed", "UploadSpeed"), EnumBackingType.STRING)), + null, + true, ), - requiredProperties = setOf("mode"), - contextHint = propHint, ) - val schema = SchemaModel( - name = "Request", - description = null, - properties = listOf( - PropertyModel("a", wrapper("Request.A.Mode", "Request.A"), null, false), - PropertyModel("b", wrapper("Request.B.Mode", "Request.B"), null, false), - ), - requiredProperties = emptySet(), - allOf = null, - oneOf = null, - anyOf = null, - discriminator = null, - ) + val types = allTypes(generate(spec(schema))) + val enumType = assertNotNull(types.find { it.name == "MeasurementsItem" }, "Expected nested enum") + assertEquals(setOf("DOWNLOAD_SPEED", "UPLOAD_SPEED"), enumType.enumConstants.keys) - val files = generate( - ApiSpec("Test", "1.0", emptyList(), listOf(schema), emptyList(), emptyList()), + val measurements = types + .first { it.name == "ExecuteSpeedTestRequest" } + .propertySpecs + .first { it.name == "measurements" } + assertTrue( + measurements.type.toString().contains("ExecuteSpeedTestRequest.MeasurementsItem"), + "Expected property to reference the nested enum, got: ${measurements.type}", ) - - val types = files.flatMap { it.members.filterIsInstance() } - // One wrapper data class (the nested inline schema) + one enum, despite two properties. - val wrappers = types.filter { it.name != "Request" && it.enumConstants.isEmpty() } - val enums = types.filter { it.enumConstants.isNotEmpty() } - assertEquals(1, wrappers.size, "Expected one deduped inline schema, got: ${wrappers.map { it.name }}") - assertEquals(1, enums.size, "Expected one deduped inline enum, got: ${enums.map { it.name }}") } @Test - fun `integer-backed inline enum generates a typed enum class`() { - val schema = SchemaModel( - name = "Request", - description = null, - properties = listOf( - PropertyModel( - name = "codes", - type = TypeRef.Array( - TypeRef.InlineEnum( - values = listOf("100", "200"), - backingType = EnumBackingType.INTEGER, - contextHint = "Request.CodesItem", - ), - ), - description = null, - nullable = true, - ), + fun `integer-backed inline enum generates a typed enum`() { + val schema = schema( + "Request", + PropertyModel( + "codes", + TypeRef.Array(TypeRef.InlineEnum(listOf("100", "200"), EnumBackingType.INTEGER)), + null, + true, ), - requiredProperties = emptySet(), - allOf = null, - oneOf = null, - anyOf = null, - discriminator = null, ) - val files = generate( - ApiSpec("Test", "1.0", emptyList(), listOf(schema), emptyList(), emptyList()), - ) - - val enumType = files - .flatMap { it.members.filterIsInstance() } - .find { it.name == "Request_CodesItem" } - assertNotNull(enumType, "Expected generated enum 'Request_CodesItem'") + val enumType = allTypes(generate(spec(schema))).find { it.name == "CodesItem" } + assertNotNull(enumType, "Expected nested enum 'CodesItem'") assertEquals(setOf("100", "200"), enumType.enumConstants.keys) } @Test - fun `inline enum directly on a property generates a typed enum class`() { - val schema = SchemaModel( - name = "Request", - description = null, - properties = listOf( - PropertyModel( - name = "status", - type = TypeRef.InlineEnum( - values = listOf("Active", "Inactive"), - backingType = EnumBackingType.STRING, - contextHint = "Request.Status", - ), - description = null, - nullable = false, - ), + fun `inline enum directly on a property generates a typed enum`() { + val schema = schema( + "Request", + PropertyModel( + "status", + TypeRef.InlineEnum(listOf("Active", "Inactive"), EnumBackingType.STRING), + null, + false, ), - requiredProperties = setOf("status"), - allOf = null, - oneOf = null, - anyOf = null, - discriminator = null, ) - val files = generate( - ApiSpec("Test", "1.0", emptyList(), listOf(schema), emptyList(), emptyList()), - ) - - val types = files.flatMap { it.members.filterIsInstance() } - val enumType = types.find { it.name == "Request_Status" } - assertNotNull(enumType, "Expected generated enum 'Request_Status'") + val types = allTypes(generate(spec(schema))) + val enumType = assertNotNull(types.find { it.name == "Status" }, "Expected nested enum 'Status'") assertEquals(setOf("ACTIVE", "INACTIVE"), enumType.enumConstants.keys) val status = types.first { it.name == "Request" }.propertySpecs.first { it.name == "status" } assertTrue( - status.type.toString().contains("Request_Status"), - "Expected property to reference generated enum, got: ${status.type}", + status.type.toString().contains("Request.Status"), + "Expected property to reference the nested enum, got: ${status.type}", ) } @Test - fun `inline enum as a map value generates a typed enum class`() { - val schema = SchemaModel( - name = "Request", - description = null, - properties = listOf( - PropertyModel( - name = "byRegion", - type = TypeRef.Map( - TypeRef.InlineEnum( - values = listOf("Eu", "Us"), - backingType = EnumBackingType.STRING, - contextHint = "Request.ByRegionValue", - ), - ), - description = null, - nullable = true, - ), + fun `inline enum as a map value generates a typed enum`() { + val schema = schema( + "Request", + PropertyModel( + "byRegion", + TypeRef.Map(TypeRef.InlineEnum(listOf("Eu", "Us"), EnumBackingType.STRING)), + null, + true, ), - requiredProperties = emptySet(), - allOf = null, - oneOf = null, - anyOf = null, - discriminator = null, - ) - - val files = generate( - ApiSpec("Test", "1.0", emptyList(), listOf(schema), emptyList(), emptyList()), ) - val types = files.flatMap { it.members.filterIsInstance() } - val enumType = types.find { it.name == "Request_ByRegionValue" } - assertNotNull(enumType, "Expected generated enum 'Request_ByRegionValue'") + val types = allTypes(generate(spec(schema))) + val enumType = assertNotNull(types.find { it.name == "ByRegionValue" }, "Expected nested enum 'ByRegionValue'") assertEquals(setOf("EU", "US"), enumType.enumConstants.keys) val byRegion = types.first { it.name == "Request" }.propertySpecs.first { it.name == "byRegion" } assertTrue( - byRegion.type.toString().contains("Request_ByRegionValue"), - "Expected map value to reference generated enum, got: ${byRegion.type}", + byRegion.type.toString().contains("Request.ByRegionValue"), + "Expected map value to reference the nested enum, got: ${byRegion.type}", ) } } 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 ed7bd524..7bbaa76e 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,14 @@ 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) - }, - OutputOptions(), - NameRegistry(), - ) { - ModelGenerator.generate(spec) + private fun generate(spec: ApiSpec) = spec.resolveInlines().let { resolved -> + context( + Hierarchy(ModelPackage(modelPackage)).apply { addSchemas(resolved.schemas.map { it.schema }) }, + OutputOptions(), + NameRegistry(), + ) { + ModelGenerator.generate(resolved) + } } private fun spec(schemas: List = emptyList(), enums: List = emptyList()) = ApiSpec( @@ -971,13 +970,13 @@ class ModelGeneratorPolymorphicTest { listOf( PropertyModel( "type", - TypeRef.InlineEnum(listOf("Cat"), EnumBackingType.STRING, "Cat.Type"), + TypeRef.InlineEnum(listOf("Cat"), EnumBackingType.STRING), null, false, ), PropertyModel( "sound", - TypeRef.InlineEnum(listOf("MEOW", "PURR"), EnumBackingType.STRING, "Cat.Sound"), + TypeRef.InlineEnum(listOf("MEOW", "PURR"), EnumBackingType.STRING), null, false, ), @@ -985,11 +984,12 @@ class ModelGeneratorPolymorphicTest { ) val files = generate(spec(schemas = listOf(animal, cat))) - val enumNames = files - .flatMap { it.members } - .filterIsInstance() - .filter { it.kind == TypeSpec.Kind.CLASS && KModifier.ENUM in it.modifiers } - .mapNotNull { it.name } + + fun enumsIn(types: List): List = types.flatMap { type -> + val self = if (KModifier.ENUM in type.modifiers) listOfNotNull(type.name) else emptyList() + self + enumsIn(type.typeSpecs) + } + val enumNames = enumsIn(files.flatMap { it.members }.filterIsInstance()) // The single-value `type` discriminator enum must NOT be generated... assertTrue( 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 73f34e8f..15cbdb1b 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,14 +11,14 @@ 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) - }, - OutputOptions(), - NameRegistry(), - ) { - ModelGenerator.generate(spec) + private fun generate(spec: ApiSpec): List { + val resolved = spec.resolveInlines() + val hierarchy = Hierarchy(ModelPackage(modelPackage)).apply { + addSchemas(resolved.schemas.map { it.schema }) + } + return context(hierarchy, OutputOptions(), NameRegistry()) { + ModelGenerator.generate(resolved) + } } private fun spec(schemas: List = emptyList()) = ApiSpec( @@ -62,7 +62,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 9b9ca49f..e6a1588e 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,14 @@ import kotlin.test.assertTrue class ModelGeneratorTest { private val modelPackage = "com.example.model" - private fun generate(spec: ApiSpec, generateKdoc: Boolean = true) = context( - Hierarchy(ModelPackage(modelPackage)).apply { - addSchemas(spec.schemas) - }, - OutputOptions(generateKdoc = generateKdoc), - NameRegistry(), - ) { - ModelGenerator.generate(spec) + private fun generate(spec: ApiSpec, generateKdoc: Boolean = true) = spec.resolveInlines().let { resolved -> + context( + Hierarchy(ModelPackage(modelPackage)).apply { addSchemas(resolved.schemas.map { it.schema }) }, + OutputOptions(generateKdoc = generateKdoc), + NameRegistry(), + ) { + ModelGenerator.generate(resolved) + } } private fun spec(schemas: List = emptyList(), enums: List = emptyList()) = ApiSpec( @@ -1304,47 +1304,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/TypeMappingTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/TypeMappingTest.kt index a1effa9c..6a8963b2 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 @@ -135,7 +135,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) } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserInlineEnumTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserInlineEnumTest.kt index baa30de2..01105005 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserInlineEnumTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserInlineEnumTest.kt @@ -29,7 +29,7 @@ class SpecParserInlineEnumTest : SpecParserTestBase() { } @Test - fun `inline enum as a map value carries a context-derived hint`() { + fun `inline enum as a map value is parsed as InlineEnum`() { val spec = parseSpec(loadResource("inline-enum-spec.yaml")) val schema = assertNotNull( @@ -48,10 +48,10 @@ class SpecParserInlineEnumTest : SpecParserTestBase() { val flags = mapValueEnum("flagsByRegion") assertEquals(listOf("ENABLED", "DISABLED"), flags.values) - assertEquals("SpeedTestResult.FlagsByRegionValue", flags.contextHint) + assertEquals(EnumBackingType.STRING, flags.backingType) val tiers = mapValueEnum("tiersByUser") assertEquals(listOf("FREE", "PRO"), tiers.values) - assertEquals("SpeedTestResult.TiersByUserValue", tiers.contextHint) + assertEquals(EnumBackingType.STRING, tiers.backingType) } }