Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d0504f1
feat(12-01): add NameRegistry with numeric suffix collision resolution
halotukozak Mar 23, 2026
1e3dbfc
refactor(12-01): extract InlineSchemaKey to standalone file
halotukozak Mar 23, 2026
a2d8cbf
feat(12-02): wire NameRegistry into all generators
halotukozak Mar 23, 2026
fb75f60
refactor(12-02): delete InlineSchemaDeduplicator, update tests to num…
halotukozak Mar 23, 2026
79951e3
Merge branch 'master' into feat/12-unified-name-registry
halotukozak Mar 24, 2026
b7e1d9b
fix: pass NameRegistry to ModelGenerator in IntegrationTest
halotukozak Mar 24, 2026
326f610
fix: resolve inline schema names consistently via InlineTypeResolver
halotukozak Mar 24, 2026
a21d2a2
fix: address Copilot review — recursive normalization, fail-fast reso…
halotukozak Mar 25, 2026
cc2a63c
fmt
halotukozak Mar 25, 2026
2c93ddd
feat: improve inline schema handling and collision prevention
halotukozak Mar 25, 2026
bcd7aa3
Merge branch 'master' into feat/12-unified-name-registry
halotukozak Mar 26, 2026
5d96415
fix(test): update error message field in CodeGeneratorTest
halotukozak Mar 26, 2026
4b4b946
Merge branch 'feat/content-types' into feat/12-unified-name-registry
halotukozak Mar 30, 2026
1bf48a2
fix: use lambda instead of method reference for context receiver comp…
halotukozak Mar 30, 2026
379f669
refactor: clean up NameRegistry integration — remove dead code and du…
halotukozak Mar 30, 2026
46f969f
test(core): extend coverage for inline type resolution and name registry
halotukozak Mar 31, 2026
155afda
refactor(core): centralize constant definitions in `Names` file
halotukozak Mar 31, 2026
1bdb58a
refactor(core): make key functions, objects, and utilities internal a…
halotukozak Mar 31, 2026
7a85338
refactor(core): make `InlineSchemaKey` internal and improve `Property…
halotukozak Mar 31, 2026
304af03
Merge branch 'master' into feat/12-unified-name-registry
halotukozak Mar 31, 2026
662db7d
refactor(core, test): adjust formatting for readability in `NameRegis…
halotukozak Mar 31, 2026
2f1cda1
Merge remote-tracking branch 'origin/feat/12-unified-name-registry' i…
halotukozak Mar 31, 2026
e0f0ce5
refactor(core): remove default values for `hasPolymorphicTypes` and `…
halotukozak Mar 31, 2026
742d603
refactor(core): update `ModelGenerator` and related tests to use `Nam…
halotukozak Mar 31, 2026
2b3c99e
refactor(core): make `CodeGenerator` object public
halotukozak Mar 31, 2026
6dbf0cf
refactor(core): simplify `PropertyKey` by removing private constructo…
halotukozak Mar 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ private const val API_SUFFIX = "Api"
* that extends `ApiClientBase` with suspend functions for every endpoint in that tag group.
*/
@OptIn(ExperimentalKotlinPoetApi::class)
class ClientGenerator(private val apiPackage: String, private val modelPackage: String) {
class ClientGenerator(
private val apiPackage: String,
private val modelPackage: String,
private val nameRegistry: NameRegistry,
) {
fun generate(spec: ApiSpec, hasPolymorphicTypes: Boolean = false): List<FileSpec> {
val grouped = spec.endpoints.groupBy { it.tags.firstOrNull() ?: DEFAULT_TAG }
return grouped.map { (tag, endpoints) -> generateClientFile(tag, endpoints, hasPolymorphicTypes) }
Expand All @@ -42,7 +46,7 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage:
endpoints: List<Endpoint>,
hasPolymorphicTypes: Boolean = false,
): FileSpec {
val className = ClassName(apiPackage, "${tag.toPascalCase()}$API_SUFFIX")
val className = ClassName(apiPackage, nameRegistry.register("${tag.toPascalCase()}$API_SUFFIX"))

val clientInitializer = if (hasPolymorphicTypes) {
val generatedSerializersModule = MemberName(modelPackage, GENERATED_SERIALIZERS_MODULE)
Expand Down Expand Up @@ -73,16 +77,17 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage:
.primaryConstructor(primaryConstructor)
.addProperty(httpClientProperty)

classBuilder.addFunctions(endpoints.map(::generateEndpointFunction))
val methodRegistry = NameRegistry()
classBuilder.addFunctions(endpoints.map { generateEndpointFunction(it, methodRegistry) })

return FileSpec
.builder(className)
.addType(classBuilder.build())
.build()
}

private fun generateEndpointFunction(endpoint: Endpoint): FunSpec {
val functionName = endpoint.operationId.toCamelCase()
private fun generateEndpointFunction(endpoint: Endpoint, methodRegistry: NameRegistry): FunSpec {
val functionName = methodRegistry.register(endpoint.operationId.toCamelCase())
val returnBodyType = resolveReturnType(endpoint)
val returnType = HTTP_SUCCESS.parameterizedBy(returnBodyType)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,24 @@ object CodeGenerator {
spec: ApiSpec,
modelPackage: String,
apiPackage: String,
outputDir: File
outputDir: File,
): Result {
val modelFiles = ModelGenerator(modelPackage).generate(spec)
val modelRegistry = NameRegistry().apply {
spec.schemas.forEach { reserve(it.name) }
spec.enums.forEach { reserve(it.name) }
Comment thread
halotukozak marked this conversation as resolved.
Outdated
}
val apiRegistry = NameRegistry()

val (modelFiles, resolvedSpec) = ModelGenerator(modelPackage, modelRegistry)
.generateWithResolvedSpec(spec)

modelFiles.forEach { it.writeTo(outputDir) }

val hasPolymorphicTypes = modelFiles.any { it.name == SerializersModuleGenerator.FILE_NAME }

val clientFiles = ClientGenerator(apiPackage, modelPackage).generate(spec, hasPolymorphicTypes)
val clientFiles = ClientGenerator(apiPackage, modelPackage, apiRegistry)
.generate(resolvedSpec, hasPolymorphicTypes)

clientFiles.forEach { it.writeTo(outputDir) }

return Result(modelFiles.size, clientFiles.size)
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.avsystem.justworks.core.gen

import com.avsystem.justworks.core.model.PropertyModel
import com.avsystem.justworks.core.model.TypeRef

/**
* Key for structural equality of inline schemas.
* Two inline schemas are considered equal if they have the same properties
* (name, type, required status) regardless of property order.
* Nested [TypeRef.Inline] types are normalized to ignore [TypeRef.Inline.contextHint],
* ensuring purely structural comparison.
*/
data class InlineSchemaKey(val properties: Set<PropertyKey>) {
data class PropertyKey(
val name: String,
val type: TypeRef,
val required: Boolean,
)

companion object {
fun from(properties: List<PropertyModel>, required: Set<String>) = InlineSchemaKey(
properties = properties.map { PropertyKey(it.name, normalizeType(it.type), it.name in required) }.toSet(),
Comment thread
halotukozak marked this conversation as resolved.
Outdated
)

private fun normalizeType(type: TypeRef): TypeRef = when (type) {
is TypeRef.Inline -> TypeRef.Inline(
properties = type.properties.map { it.copy(type = normalizeType(it.type)) },
requiredProperties = type.requiredProperties,
contextHint = "",
)

Comment thread
halotukozak marked this conversation as resolved.
is TypeRef.Array -> TypeRef.Array(normalizeType(type.items))

is TypeRef.Map -> TypeRef.Map(normalizeType(type.valueType))

else -> type
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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

/**
* 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.
*/
fun ApiSpec.resolveInlineTypes(nameMap: Map<InlineSchemaKey, String>): ApiSpec {
Comment thread
halotukozak marked this conversation as resolved.
Outdated
if (nameMap.isEmpty()) return this

fun TypeRef.resolve(): TypeRef = when (this) {
is TypeRef.Inline -> {
val key = InlineSchemaKey.from(properties, requiredProperties)
val className = nameMap[key]
?: error(
"Missing inline schema mapping for key (contextHint=$contextHint). " +
"This indicates a mismatch between inline schema collection and resolution.",
)
TypeRef.Reference(className)
}

is TypeRef.Array -> {
TypeRef.Array(items.resolve())
}

is TypeRef.Map -> {
TypeRef.Map(valueType.resolve())
}

else -> {
this
}
}

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() },
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,24 +30,40 @@ import kotlin.time.Instant
* Produces one file per [SchemaModel] (data class, sealed interface, or allOf composed class)
* and one file per [EnumModel] (enum class), all annotated with kotlinx.serialization annotations.
*/
class ModelGenerator(private val modelPackage: String) {
fun generate(spec: ApiSpec): List<FileSpec> = context(
buildHierarchyInfo(spec.schemas),
InlineSchemaDeduplicator(spec.schemas.map { it.name }.toSet()),
) {
val schemaFiles = spec.schemas.flatMap { generateSchemaFiles(it) }

val inlineSchemaFiles = collectAllInlineSchemas(spec).map {
if (it.isNested) generateNestedInlineClass(it) else generateDataClass(it)
class ModelGenerator(private val modelPackage: String, private val nameRegistry: NameRegistry) {
data class GenerateResult(val files: List<FileSpec>, val resolvedSpec: ApiSpec)

fun generate(spec: ApiSpec): List<FileSpec> = generateWithResolvedSpec(spec).files

fun generateWithResolvedSpec(spec: ApiSpec): GenerateResult {
Comment thread
halotukozak marked this conversation as resolved.
Outdated
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 = prop.type.resolveInline(nameMap))
},
)
}

val enumFiles = spec.enums.map(::generateEnumClass)
val files = context(buildHierarchyInfo(resolvedSpec.schemas)) {
val schemaFiles = resolvedSpec.schemas.flatMap { generateSchemaFiles(it) }

val inlineSchemaFiles = resolvedInlineSchemas.map {
if (it.isNested) generateNestedInlineClass(it) else generateDataClass(it)
}

val serializersModuleFile = SerializersModuleGenerator(modelPackage).generate()
val enumFiles = resolvedSpec.enums.map(::generateEnumClass)

val uuidSerializerFile = if (spec.usesUuid()) generateUuidSerializer() else null
val serializersModuleFile = SerializersModuleGenerator(modelPackage).generate()

schemaFiles + inlineSchemaFiles + enumFiles + listOfNotNull(serializersModuleFile, uuidSerializerFile)
val uuidSerializerFile = if (resolvedSpec.usesUuid()) generateUuidSerializer() else null

schemaFiles + inlineSchemaFiles + enumFiles + listOfNotNull(serializersModuleFile, uuidSerializerFile)
}

return GenerateResult(files, resolvedSpec)
}

data class HierarchyInfo(
Expand Down Expand Up @@ -90,8 +106,7 @@ class ModelGenerator(private val modelPackage: String) {
return HierarchyInfo(sealedHierarchies, variantParents, anyOfWithoutDiscriminator, schemas)
}

context(deduplicator: InlineSchemaDeduplicator)
private fun collectAllInlineSchemas(spec: ApiSpec): List<SchemaModel> {
private fun collectAllInlineSchemas(spec: ApiSpec): Pair<List<SchemaModel>, Map<InlineSchemaKey, String>> {
val endpointRefs = spec.endpoints.flatMap { endpoint ->
val requestRef = endpoint.requestBody?.schema
val responseRefs = endpoint.responses.values.map { it.schema }
Expand All @@ -100,13 +115,18 @@ class ModelGenerator(private val modelPackage: String) {

val schemaPropertyRefs = spec.schemas.flatMap { schema -> schema.properties.map { it.type } }

return collectInlineTypeRefs(endpointRefs + schemaPropertyRefs)
val nameMap = mutableMapOf<InlineSchemaKey, String>()

val schemas = collectInlineTypeRefs(endpointRefs + schemaPropertyRefs)
.asSequence()
.sortedBy { it.contextHint }
.distinctBy { InlineSchemaKey.from(it.properties, it.requiredProperties) }
.map { ref ->
val key = InlineSchemaKey.from(ref.properties, ref.requiredProperties)
val generatedName = nameRegistry.register(ref.contextHint.toInlinedName())
nameMap[key] = generatedName
SchemaModel(
name = deduplicator.getOrGenerateName(ref.properties, ref.requiredProperties, ref.contextHint),
name = generatedName,
description = null,
Comment thread
halotukozak marked this conversation as resolved.
Outdated
properties = ref.properties,
requiredProperties = ref.requiredProperties,
Expand All @@ -116,6 +136,8 @@ class ModelGenerator(private val modelPackage: String) {
discriminator = null,
)
}.toList()

return schemas to nameMap
}

context(hierarchy: HierarchyInfo)
Expand Down Expand Up @@ -429,12 +451,13 @@ class ModelGenerator(private val modelPackage: String) {

val typeSpec = TypeSpec.enumBuilder(className).addAnnotation(SERIALIZABLE)

val enumRegistry = NameRegistry()
enum.values.forEach { value ->
val anonymousClass = TypeSpec
.anonymousClassBuilder()
.addAnnotation(AnnotationSpec.builder(SERIAL_NAME).addMember("%S", value).build())
.build()
typeSpec.addEnumConstant(value.toEnumConstantName(), anonymousClass)
typeSpec.addEnumConstant(enumRegistry.register(value.toEnumConstantName()), anonymousClass)
}

if (enum.description != null) {
Expand Down Expand Up @@ -478,6 +501,28 @@ class ModelGenerator(private val modelPackage: String) {
private fun generateNestedInlineClass(schema: SchemaModel): FileSpec =
generateDataClass(schema.copy(name = schema.name.toInlinedName()))

private fun TypeRef.resolveInline(nameMap: Map<InlineSchemaKey, String>): TypeRef = when (this) {
is TypeRef.Inline -> {
val key = InlineSchemaKey.from(properties, requiredProperties)
TypeRef.Reference(
nameMap[key]
?: error("Missing inline schema mapping for key (contextHint=$contextHint)"),
)
}

is TypeRef.Array -> {
TypeRef.Array(items.resolveInline(nameMap))
}

is TypeRef.Map -> {
TypeRef.Map(valueType.resolveInline(nameMap))
}

else -> {
this
}
}

private val SchemaModel.isPrimitiveOnly: Boolean
get() = properties.isEmpty() && allOf == null && oneOf == null && anyOf == null

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.avsystem.justworks.core.gen

/**
* Registry that tracks used names and resolves collisions with numeric suffixes.
*
* When a desired name is already taken, appends incrementing numeric suffixes
* (e.g. `Foo`, `Foo2`, `Foo3`). Names can be pre-populated via [reserve] to
* block them from being returned by [register].
*/
class NameRegistry {
Comment thread
halotukozak marked this conversation as resolved.
Outdated
private val registered = mutableSetOf<String>()

/**
* Registers [desired] name, returning it if available or appending a numeric suffix
* to resolve collisions (e.g. `Foo2`, `Foo3`).
*/
fun register(desired: String): String = desired.takeIf { registered.add(it) }
?: generateSequence(2) { it + 1 }
.map { "$desired$it" }
.first { registered.add(it) }

/**
* Reserves [name] so that subsequent [register] calls for the same name
* will receive a suffixed variant.
*/
fun reserve(name: String) {
registered.add(name)
}
}
Loading
Loading