Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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,26 @@ 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) }
reserve("SerializersModule")
reserve("UuidSerializer")
}
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,52 @@
package com.avsystem.justworks.core.gen

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

/**
* Key for structural equality of inline schemas.
* Two inline schemas are considered equal if they have the same properties
* (name, type, required status) 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,
val nullable: Boolean,
val defaultValue: Any?,
)

companion object {
fun from(properties: List<PropertyModel>, required: Set<String>): InlineSchemaKey {
val keys = properties.map {
PropertyKey(
name = it.name,
type = normalizeType(it.type),
required = it.name in required,
nullable = it.nullable,
defaultValue = it.defaultValue,
)
}
return InlineSchemaKey(keys.toSet())
}

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

is TypeRef.Array -> TypeRef.Array(normalizeType(type.items))

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

else -> type
}
}
}
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 {
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,41 @@ 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 {
ensureReserved(spec)
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 enumFiles = resolvedSpec.enums.map(::generateEnumClass)

val serializersModuleFile = SerializersModuleGenerator(modelPackage).generate()
val serializersModuleFile = SerializersModuleGenerator(modelPackage).generate()

val uuidSerializerFile = if (spec.usesUuid()) generateUuidSerializer() else null
val uuidSerializerFile = if (resolvedSpec.usesUuid()) generateUuidSerializer() else null

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

return GenerateResult(files, resolvedSpec)
}

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

context(deduplicator: InlineSchemaDeduplicator)
private fun collectAllInlineSchemas(spec: ApiSpec): List<SchemaModel> {
/**
* Ensures all top-level schema/enum names are reserved in [nameRegistry],
* preventing inline schemas from colliding with component types even if
* the caller supplied an empty registry.
*/
private fun ensureReserved(spec: ApiSpec) {
spec.schemas.forEach { nameRegistry.reserve(it.name) }
spec.enums.forEach { nameRegistry.reserve(it.name) }
}

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 +126,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,
properties = ref.properties,
requiredProperties = ref.requiredProperties,
Expand All @@ -116,6 +147,8 @@ class ModelGenerator(private val modelPackage: String) {
discriminator = null,
)
}.toList()

return schemas to nameMap
}

context(hierarchy: HierarchyInfo)
Expand Down Expand Up @@ -429,12 +462,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 +512,19 @@ 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 -> TypeRef.Reference(
nameMap[InlineSchemaKey.from(properties, requiredProperties)]
?: 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
Loading
Loading