diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ClientGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ClientGenerator.kt index e182e6f..0c3707b 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ClientGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ClientGenerator.kt @@ -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 { val grouped = spec.endpoints.groupBy { it.tags.firstOrNull() ?: DEFAULT_TAG } return grouped.map { (tag, endpoints) -> generateClientFile(tag, endpoints, hasPolymorphicTypes) } @@ -42,7 +46,7 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: endpoints: List, 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) @@ -73,7 +77,8 @@ 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) @@ -81,8 +86,8 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: .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) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt index 10c6715..76f46d4 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 @@ -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) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDeduplicator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDeduplicator.kt deleted file mode 100644 index eef1664..0000000 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDeduplicator.kt +++ /dev/null @@ -1,50 +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) regardless of property order. - */ -data class InlineSchemaKey(val properties: Set) { - data class PropertyKey( - val name: String, - val type: TypeRef, - val required: Boolean, - ) - - companion object { - fun from(properties: List, required: Set) = InlineSchemaKey( - properties = properties.map { PropertyKey(it.name, it.type, it.name in required) }.toSet(), - ) - } -} - -/** - * Deduplicates inline schemas based on structural equality. - * Ensures that structurally identical inline schemas generate only one class, - * and handles name collisions with component schemas. - */ -class InlineSchemaDeduplicator(private val componentSchemaNames: Set) { - private val namesByKey = mutableMapOf() - - fun getOrGenerateName( - properties: List, - requiredProps: Set, - contextName: String, - ): String = namesByKey.getOrPut(InlineSchemaKey.from(properties, requiredProps)) { - val inlineName = contextName.toInlinedName() - val candidates = sequence { - yield(inlineName) - yield("${inlineName}Inline") - generateSequence(2) { it + 1 }.forEach { - yield("${inlineName}${it}Inline") - } - } - - val existingNames = (componentSchemaNames + namesByKey.values).toSet() - candidates.first { it !in existingNames } - } -} 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 new file mode 100644 index 0000000..a79292d --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaKey.kt @@ -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) { + data class PropertyKey( + val name: String, + val type: TypeRef, + val required: Boolean, + val nullable: Boolean, + val defaultValue: Any?, + ) + + companion object { + fun from(properties: List, required: Set): InlineSchemaKey { + val keys = properties.map { + PropertyKey( + name = it.name, + type = normalizeType(it.type), + required = it.name in required, + nullable = it.nullable, + defaultValue = it.defaultValue, + ) + } + return InlineSchemaKey(keys.toSet()) + } + + private fun normalizeType(type: TypeRef): TypeRef = when (type) { + is TypeRef.Inline -> TypeRef.Inline( + properties = type.properties + .map { it.copy(type = normalizeType(it.type)) } + .sortedBy { it.name }, + requiredProperties = type.requiredProperties, + contextHint = "", + ) + + is TypeRef.Array -> TypeRef.Array(normalizeType(type.items)) + + is TypeRef.Map -> TypeRef.Map(normalizeType(type.valueType)) + + else -> type + } + } +} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineTypeResolver.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineTypeResolver.kt new file mode 100644 index 0000000..8d8c5be --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineTypeResolver.kt @@ -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): 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() }, + ) +} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt index 6f0c950..2063863 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt @@ -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 = 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, val resolvedSpec: ApiSpec) + + fun generate(spec: ApiSpec): List = 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( @@ -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 { + /** + * 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, Map> { val endpointRefs = spec.endpoints.flatMap { endpoint -> val requestRef = endpoint.requestBody?.schema val responseRefs = endpoint.responses.values.map { it.schema } @@ -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() + + 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, @@ -116,6 +147,8 @@ class ModelGenerator(private val modelPackage: String) { discriminator = null, ) }.toList() + + return schemas to nameMap } context(hierarchy: HierarchyInfo) @@ -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) { @@ -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): 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 diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameRegistry.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameRegistry.kt new file mode 100644 index 0000000..975a39e --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameRegistry.kt @@ -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 { + private val registered = mutableSetOf() + + /** + * 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) + } +} diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt index 3f60c32..d66755c 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 @@ -21,7 +21,7 @@ import kotlin.test.assertTrue class ClientGeneratorTest { private val apiPackage = "com.example.api" private val modelPackage = "com.example.model" - private val generator = ClientGenerator(apiPackage, modelPackage) + private val generator = ClientGenerator(apiPackage, modelPackage, NameRegistry()) private fun spec(endpoints: List) = ApiSpec( title = "Test", @@ -352,6 +352,7 @@ class ClientGeneratorTest { val files = ClientGenerator( apiPackage, modelPackage, + NameRegistry(), ).generate(spec(listOf(endpoint())), hasPolymorphicTypes = true) val clientProperty = files .first() @@ -373,6 +374,7 @@ class ClientGeneratorTest { val files = ClientGenerator( apiPackage, modelPackage, + NameRegistry(), ).generate(spec(listOf(endpoint())), hasPolymorphicTypes = false) val clientProperty = files .first() 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 new file mode 100644 index 0000000..bb9e194 --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/CodeGeneratorTest.kt @@ -0,0 +1,51 @@ +package com.avsystem.justworks.core.gen + +import com.avsystem.justworks.core.parser.ParseResult +import com.avsystem.justworks.core.parser.SpecParser +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.test.fail + +class CodeGeneratorTest { + companion object { + private val SPEC_FIXTURES = listOf( + "/fixtures/platform-api.json", + "/fixtures/analytics-api.json", + ) + } + + @Test + fun `generate produces model and client files for real-world specs`() { + for (fixture in SPEC_FIXTURES) { + val specUrl = javaClass.getResource(fixture) + ?: fail("Spec fixture not found: $fixture") + val specFile = File(specUrl.toURI()) + val spec = when (val result = SpecParser.parse(specFile)) { + is ParseResult.Success -> result.apiSpec + is ParseResult.Failure -> fail("Failed to parse $fixture: ${result.error}") + } + + val outputDir = Files.createTempDirectory("codegen-test").toFile() + try { + val result = CodeGenerator.generate( + spec = spec, + modelPackage = "com.example.model", + apiPackage = "com.example.api", + outputDir = outputDir, + ) + + assertTrue(result.modelFiles > 0, "$fixture: should produce model files") + if (spec.endpoints.isNotEmpty()) { + assertTrue(result.clientFiles > 0, "$fixture: should produce client files") + } + + val generatedFiles = outputDir.walkTopDown().filter { it.isFile }.toList() + assertTrue(generatedFiles.isNotEmpty(), "$fixture: output directory should contain files") + } 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 index 385ad99..48a7936 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDedupTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDedupTest.kt @@ -9,9 +9,7 @@ import kotlin.test.assertNotEquals class InlineSchemaDedupTest { @Test - fun `identical schemas return same name`() { - val deduplicator = InlineSchemaDeduplicator(emptySet()) - + 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), @@ -23,17 +21,14 @@ class InlineSchemaDedupTest { PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), ) - val name1 = deduplicator.getOrGenerateName(props1, required, "FirstContext") - val name2 = deduplicator.getOrGenerateName(props2, required, "SecondContext") + val key1 = InlineSchemaKey.from(props1, required) + val key2 = InlineSchemaKey.from(props2, required) - assertEquals("FirstContext", name1) - assertEquals("FirstContext", name2) // Same structure returns same name + assertEquals(key1, key2) } @Test - fun `different schemas return different names`() { - val deduplicator = InlineSchemaDeduplicator(emptySet()) - + fun `different schemas produce different keys`() { val props1 = listOf( PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, false), ) @@ -42,31 +37,25 @@ class InlineSchemaDedupTest { PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), ) - val name1 = deduplicator.getOrGenerateName(props1, setOf("id"), "FirstContext") - val name2 = deduplicator.getOrGenerateName(props2, setOf("name"), "SecondContext") + val key1 = InlineSchemaKey.from(props1, setOf("id")) + val key2 = InlineSchemaKey.from(props2, setOf("name")) - assertEquals("FirstContext", name1) - assertEquals("SecondContext", name2) - assertNotEquals(name1, name2) + assertNotEquals(key1, key2) } @Test - fun `name collision with component schema appends Inline suffix`() { - val deduplicator = InlineSchemaDeduplicator(componentSchemaNames = setOf("Pet")) + fun `name collision with component schema uses numeric suffix`() { + val registry = NameRegistry().apply { + reserve("Pet") + } - val props = listOf( - PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, false), - ) + val name = registry.register("Pet") - val name = deduplicator.getOrGenerateName(props, setOf("id"), "Pet") - - assertEquals("PetInline", name) // Collision with component schema + assertEquals("Pet2", name) } @Test fun `property order does not affect equality`() { - val deduplicator = InlineSchemaDeduplicator(emptySet()) - val props1 = listOf( PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, false), @@ -79,49 +68,61 @@ class InlineSchemaDedupTest { val required = setOf("id", "name") - val name1 = deduplicator.getOrGenerateName(props1, required, "FirstContext") - val name2 = deduplicator.getOrGenerateName(props2, required, "SecondContext") + val key1 = InlineSchemaKey.from(props1, required) + val key2 = InlineSchemaKey.from(props2, required) - // Same structure despite different order - assertEquals("FirstContext", name1) - assertEquals("FirstContext", name2) + assertEquals(key1, key2) } @Test fun `different required sets produce different keys`() { - val deduplicator = InlineSchemaDeduplicator(emptySet()) - val props = listOf( PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, false), PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, true), ) - val name1 = deduplicator.getOrGenerateName(props, setOf("id", "name"), "FirstContext") - val name2 = deduplicator.getOrGenerateName(props, setOf("id"), "SecondContext") + val key1 = InlineSchemaKey.from(props, setOf("id", "name")) + val key2 = InlineSchemaKey.from(props, setOf("id")) - // Different required sets mean different structures - assertEquals("FirstContext", name1) - assertEquals("SecondContext", name2) - assertNotEquals(name1, name2) + assertNotEquals(key1, key2) } @Test - fun `collision with existing inline schema name appends Inline suffix`() { - val deduplicator = InlineSchemaDeduplicator(emptySet()) - + 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("id", TypeRef.Primitive(PrimitiveType.INT), null, false), + PropertyModel( + "address", + TypeRef.Inline(nestedProps, setOf("street"), "RequestAddress"), + null, + nullable = false, + ), ) val props2 = listOf( - PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), + PropertyModel( + "address", + TypeRef.Inline(nestedProps, setOf("street"), "ResponseAddress"), + null, + nullable = false, + ), ) - // First schema gets the base name - val name1 = deduplicator.getOrGenerateName(props1, setOf("id"), "Context") + val key1 = InlineSchemaKey.from(props1, setOf("address")) + val key2 = InlineSchemaKey.from(props2, setOf("address")) + + assertEquals(key1, key2) + } + + @Test + fun `collision with existing inline schema name uses numeric suffix`() { + val registry = NameRegistry() + + val name1 = registry.register("Context") assertEquals("Context", name1) - // Second schema (different structure) wants same name, gets Inline suffix - val name2 = deduplicator.getOrGenerateName(props2, setOf("name"), "Context") - assertEquals("ContextInline", name2) + val name2 = registry.register("Context") + assertEquals("Context2", name2) } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt index cbf97de..a15b889 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt @@ -1,5 +1,6 @@ package com.avsystem.justworks.core.gen +import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.parser.ParseResult import com.avsystem.justworks.core.parser.SpecParser import java.io.File @@ -25,6 +26,11 @@ class IntegrationTest { ) } + private fun buildModelRegistry(spec: ApiSpec) = NameRegistry().apply { + spec.schemas.forEach { reserve(it.name) } + spec.enums.forEach { reserve(it.name) } + } + private fun parseSpec(resourcePath: String): ParseResult.Success { val specUrl = javaClass.getResource(resourcePath) ?: fail("Spec fixture not found: $resourcePath") @@ -41,7 +47,7 @@ class IntegrationTest { val spec = parseSpec(fixture).apiSpec if (spec.enums.isEmpty()) continue - val generator = ModelGenerator(modelPackage) + val generator = ModelGenerator(modelPackage, buildModelRegistry(spec)) val files = generator.generate(spec) assertTrue(files.isNotEmpty(), "$fixture: ModelGenerator should produce output files") @@ -98,12 +104,12 @@ class IntegrationTest { for (fixture in SPEC_FIXTURES) { val spec = parseSpec(fixture).apiSpec - val modelGenerator = ModelGenerator(modelPackage) + val modelGenerator = ModelGenerator(modelPackage, buildModelRegistry(spec)) val modelFiles = modelGenerator.generate(spec) assertTrue(modelFiles.isNotEmpty(), "$fixture: ModelGenerator should produce files") if (spec.endpoints.isNotEmpty()) { - val clientGenerator = ClientGenerator(apiPackage, modelPackage) + val clientGenerator = ClientGenerator(apiPackage, modelPackage, NameRegistry()) val clientFiles = clientGenerator.generate(spec) assertTrue( clientFiles.isNotEmpty(), @@ -123,7 +129,7 @@ class IntegrationTest { for (fixture in SPEC_FIXTURES) { val spec = parseSpec(fixture).apiSpec - val generator = ModelGenerator(modelPackage) + val generator = ModelGenerator(modelPackage, buildModelRegistry(spec)) val files = generator.generate(spec) assertTrue(files.isNotEmpty(), "$fixture: ModelGenerator should produce output files") @@ -154,7 +160,7 @@ class IntegrationTest { for (fixture in SPEC_FIXTURES) { val spec = parseSpec(fixture).apiSpec - val generator = ModelGenerator(modelPackage) + val generator = ModelGenerator(modelPackage, buildModelRegistry(spec)) val files = generator.generate(spec) assertTrue(files.isNotEmpty(), "$fixture: ModelGenerator should produce output files") diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorPolymorphicTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorPolymorphicTest.kt index 649d8f5..a1134d6 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 @@ -16,7 +16,7 @@ import kotlin.test.assertTrue class ModelGeneratorPolymorphicTest { private val modelPackage = "com.example.model" - private val generator = ModelGenerator(modelPackage) + private val generator = ModelGenerator(modelPackage, NameRegistry()) private fun spec(schemas: List = emptyList(), enums: List = emptyList(),) = ApiSpec( title = "Test", diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorTest.kt index 9507b32..77f4beb 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 @@ -21,7 +21,7 @@ import kotlin.test.assertTrue class ModelGeneratorTest { private val modelPackage = "com.example.model" - private val generator = ModelGenerator(modelPackage) + private val generator = ModelGenerator(modelPackage, NameRegistry()) private fun spec(schemas: List = emptyList(), enums: List = emptyList()) = ApiSpec( title = "Test", diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/NameRegistryTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/NameRegistryTest.kt new file mode 100644 index 0000000..e080ced --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/NameRegistryTest.kt @@ -0,0 +1,64 @@ +package com.avsystem.justworks.core.gen + +import kotlin.test.Test +import kotlin.test.assertEquals + +class NameRegistryTest { + @Test + fun `register on empty registry returns desired name`() { + val registry = NameRegistry() + assertEquals("Foo", registry.register("Foo")) + } + + @Test + fun `register same name twice returns numeric suffix`() { + val registry = NameRegistry() + assertEquals("Foo", registry.register("Foo")) + assertEquals("Foo2", registry.register("Foo")) + } + + @Test + fun `register same name three times returns incrementing suffixes`() { + val registry = NameRegistry() + assertEquals("Foo", registry.register("Foo")) + assertEquals("Foo2", registry.register("Foo")) + assertEquals("Foo3", registry.register("Foo")) + } + + @Test + fun `reserve then register returns Foo2`() { + val registry = NameRegistry() + registry.reserve("Foo") + assertEquals("Foo2", registry.register("Foo")) + } + + @Test + fun `reserve Foo and Foo2 then register Foo returns Foo3`() { + val registry = NameRegistry() + registry.reserve("Foo") + registry.reserve("Foo2") + assertEquals("Foo3", registry.register("Foo")) + } + + @Test + fun `register after reserve for component schema collision`() { + val registry = NameRegistry() + registry.reserve("Pet") + assertEquals("Pet2", registry.register("Pet")) + } + + @Test + fun `different names do not interfere`() { + val registry = NameRegistry() + assertEquals("Foo", registry.register("Foo")) + assertEquals("Bar", registry.register("Bar")) + } + + @Test + fun `names differing only by case are treated as distinct`() { + val registry = NameRegistry() + assertEquals("Foo", registry.register("Foo")) + assertEquals("foo", registry.register("foo")) + assertEquals("FOO", registry.register("FOO")) + } +}