From fb1a2028bf056257d4c6da83195a90d4e2a4a757 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 11 Mar 2026 20:49:31 +0100 Subject: [PATCH 1/8] feat: add code generators (model, client, response, serializers) ModelGenerator, ClientGenerator, ApiResponseGenerator, SerializersModuleGenerator, TypeMapping, NameUtils, Names, InlineSchemaDeduplicator with full test coverage. Co-Authored-By: Claude Opus 4.6 --- .../com/avsystem/justworks/core/Generator.kt | 7 + .../core/gen/ApiResponseGenerator.kt | 113 ++ .../justworks/core/gen/ClientGenerator.kt | 360 ++++++ .../core/gen/InlineSchemaDeduplicator.kt | 81 ++ .../justworks/core/gen/ModelGenerator.kt | 712 +++++++++++ .../avsystem/justworks/core/gen/NameUtils.kt | 132 +++ .../com/avsystem/justworks/core/gen/Names.kt | 75 ++ .../core/gen/SerializersModuleGenerator.kt | 67 ++ .../justworks/core/gen/TypeMapping.kt | 63 + .../core/gen/ApiResponseGeneratorTest.kt | 78 ++ .../justworks/core/gen/ClientGeneratorTest.kt | 408 +++++++ .../core/gen/InlineSchemaDedupTest.kt | 144 +++ .../core/gen/ModelGeneratorPolymorphicTest.kt | 642 ++++++++++ .../justworks/core/gen/ModelGeneratorTest.kt | 1055 +++++++++++++++++ .../justworks/core/gen/NameUtilsTest.kt | 156 +++ .../gen/SerializersModuleGeneratorTest.kt | 58 + .../justworks/core/gen/TypeMappingTest.kt | 102 ++ 17 files changed, 4253 insertions(+) create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/Generator.kt create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiResponseGenerator.kt create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/gen/ClientGenerator.kt create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDeduplicator.kt create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/gen/NameUtils.kt create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/gen/TypeMapping.kt create mode 100644 core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiResponseGeneratorTest.kt create mode 100644 core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt create mode 100644 core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDedupTest.kt create mode 100644 core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorPolymorphicTest.kt create mode 100644 core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorTest.kt create mode 100644 core/src/test/kotlin/com/avsystem/justworks/core/gen/NameUtilsTest.kt create mode 100644 core/src/test/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGeneratorTest.kt create mode 100644 core/src/test/kotlin/com/avsystem/justworks/core/gen/TypeMappingTest.kt diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/Generator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/Generator.kt new file mode 100644 index 00000000..f4e39157 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/Generator.kt @@ -0,0 +1,7 @@ +package com.avsystem.justworks.core + +/** + * Code generator entry point. Phase 2+ will add real generation logic. + * Phase 1 uses the task's built-in placeholder generation. + */ +object Generator diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiResponseGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiResponseGenerator.kt new file mode 100644 index 00000000..e7f63f0f --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiResponseGenerator.kt @@ -0,0 +1,113 @@ +package com.avsystem.justworks.core.gen + +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.INT +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.ParameterSpec +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.STRING +import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.TypeVariableName +import java.io.File + +/** + * Generates [FileSpec]s containing: + * - `HttpErrorType` enum class with Client, Server, Network values + * - `HttpError` data class with code, message, type fields + * - `HttpSuccess` data class wrapping successful responses + */ +object ApiResponseGenerator { + fun primaryConstructor(bodyType: TypeName = STRING) = FunSpec + .constructorBuilder() + .addParameter("statusCode", INT) + .addParameter("body", bodyType) + .build() + + val statusCodeProperty = + PropertySpec + .builder("statusCode", INT) + .initializer("statusCode") + .build() + + fun bodyProperty(bodyType: TypeName = STRING) = PropertySpec + .builder("body", bodyType) + .initializer("body") + .build() + + /** + * Generates files and writes them to [outputDir]. + * Returns the number of files written. + */ + fun generateTo(outputDir: File): Int { + val files = listOf(generateHttpError(), generateSuccess()) + for (fileSpec in files) { + fileSpec.writeTo(outputDir) + } + return files.size + } + + fun generateHttpError(): FileSpec { + val enumType = + TypeSpec + .enumBuilder(HTTP_ERROR_TYPE) + .addEnumConstant("Client") + .addEnumConstant("Server") + .addEnumConstant("Network") + .build() + + val dataClassType = + TypeSpec + .classBuilder(HTTP_ERROR) + .addModifiers(KModifier.DATA) + .primaryConstructor( + FunSpec + .constructorBuilder() + .addParameter("code", INT) + .addParameter("message", STRING) + .addParameter("type", HTTP_ERROR_TYPE) + .build(), + ).addProperty( + PropertySpec + .builder("code", INT) + .initializer("code") + .build(), + ).addProperty( + PropertySpec + .builder("message", STRING) + .initializer("message") + .build(), + ).addProperty( + PropertySpec + .builder("type", HTTP_ERROR_TYPE) + .initializer("type") + .build(), + ).build() + + return FileSpec + .builder(HTTP_ERROR) + .addType(enumType) + .addType(dataClassType) + .build() + } + + fun generateSuccess(): FileSpec { + val t = TypeVariableName("T") + + val successType = + TypeSpec + .classBuilder(HTTP_SUCCESS) + .addModifiers(KModifier.DATA) + .addTypeVariable(t) + .primaryConstructor(primaryConstructor(t)) + .addProperty(bodyProperty(t)) + .addProperty(statusCodeProperty) + .build() + + return FileSpec + .builder(HTTP_SUCCESS) + .addType(successType) + .build() + } +} 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 new file mode 100644 index 00000000..6f0a8d76 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ClientGenerator.kt @@ -0,0 +1,360 @@ +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.HttpMethod +import com.avsystem.justworks.core.model.ParameterLocation +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.ContextParameter +import com.squareup.kotlinpoet.ExperimentalKotlinPoetApi +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.LambdaTypeName +import com.squareup.kotlinpoet.MemberName +import com.squareup.kotlinpoet.ParameterSpec +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.STRING +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.UNIT +import com.squareup.kotlinpoet.asTypeName +import java.io.File + +/** + * Generates one KotlinPoet [FileSpec] per API tag, each containing a client class + * with suspend functions for every endpoint in that tag group. + */ +@OptIn(ExperimentalKotlinPoetApi::class) +class ClientGenerator(private val apiPackage: String, private val modelPackage: String,) { + fun generate(spec: ApiSpec, hasPolymorphicTypes: Boolean = false): List { + val grouped = spec.endpoints.groupBy { it.tags.firstOrNull() ?: "Default" } + return grouped.map { (tag, endpoints) -> + generateClientFile(tag, endpoints, hasPolymorphicTypes) + } + } + + /** + * Generates client files from [spec] and writes them to [outputDir]. + * Returns the number of files written. + */ + fun generateTo( + spec: ApiSpec, + outputDir: File, + hasPolymorphicTypes: Boolean = false, + ): Int { + val files = generate(spec, hasPolymorphicTypes) + for (fileSpec in files) { + fileSpec.writeTo(outputDir) + } + return files.size + } + + private fun generateClientFile( + tag: String, + endpoints: List, + hasPolymorphicTypes: Boolean = false, + ): FileSpec { + val className = ClassName(apiPackage, "${tag.toPascalCase()}Api") + + val classBuilder = + TypeSpec + .classBuilder(className) + .addSuperinterface(CLOSEABLE) + .primaryConstructor( + FunSpec + .constructorBuilder() + .addParameter("baseUrl", STRING) + .addParameter("tokenProvider", STRING) + .build(), + ).addProperty( + PropertySpec + .builder("baseUrl", STRING) + .initializer("baseUrl") + .addModifiers(KModifier.PRIVATE) + .build(), + ).addProperty( + PropertySpec + .builder("tokenProvider", STRING) + .initializer("tokenProvider") + .addModifiers(KModifier.PRIVATE) + .build(), + ).addProperty( + PropertySpec + .builder("client", HTTP_CLIENT) + .addModifiers(KModifier.PRIVATE) + .initializer( + buildClientInitializer(hasPolymorphicTypes), + ).build(), + ).addFunction( + FunSpec + .builder("close") + .addModifiers(KModifier.OVERRIDE) + .addStatement("client.close()") + .build(), + ) + + for (endpoint in endpoints) { + classBuilder.addFunction(generateEndpointFunction(endpoint)) + } + + val encodeParamFun = + FunSpec + .builder("encodeParam") + .addModifiers(KModifier.PRIVATE, KModifier.INLINE) + .addTypeVariable( + com.squareup.kotlinpoet + .TypeVariableName("T") + .copy(reified = true), + ).addParameter("value", com.squareup.kotlinpoet.TypeVariableName("T")) + .returns(STRING) + .addStatement("return %T.%M(value).trim('\"')", JSON_CLASS, ENCODE_TO_STRING_FUN) + .build() + + return FileSpec + .builder(className) + .addFunction(encodeParamFun) + .addType(classBuilder.build()) + .build() + } + + private fun buildClientInitializer(hasPolymorphicTypes: Boolean): CodeBlock { + val builder = CodeBlock + .builder() + .add("%T·{\n", HTTP_CLIENT) + .indent() + .add("install(%T)·{\n", CONTENT_NEGOTIATION) + .indent() + + if (hasPolymorphicTypes) { + val generatedSerializersModule = MemberName(modelPackage, "generatedSerializersModule") + builder + .add("%M(%T·{\n", JSON_FUN, JSON_CLASS) + .indent() + .add("serializersModule·=·%M\n", generatedSerializersModule) + .unindent() + .add("})\n") + } else { + builder.add("%M()\n", JSON_FUN) + } + + builder + .unindent() + .add("}\n") + .add("expectSuccess·=·false\n") + .unindent() + .add("}") + + return builder.build() + } + + private fun generateEndpointFunction(endpoint: Endpoint): FunSpec { + val functionName = endpoint.operationId.toCamelCase() + val returnBodyType = resolveReturnType(endpoint) + val returnType = HTTP_SUCCESS.parameterizedBy(returnBodyType) + + val funBuilder = + FunSpec + .builder(functionName) + .addModifiers(KModifier.SUSPEND) + .contextParameters(listOf(ContextParameter(RAISE.parameterizedBy(HTTP_ERROR)))) + .returns(returnType) + + // Add parameters: PATH first, then QUERY, then HEADER, then body + val pathParams = endpoint.parameters.filter { it.location == ParameterLocation.PATH } + val queryParams = endpoint.parameters.filter { it.location == ParameterLocation.QUERY } + val headerParams = endpoint.parameters.filter { it.location == ParameterLocation.HEADER } + + for (param in pathParams) { + val type = TypeMapping.toTypeName(param.schema, modelPackage) + funBuilder.addParameter(param.name.toCamelCase(), type) + } + + for (param in queryParams) { + val baseType = TypeMapping.toTypeName(param.schema, modelPackage) + if (param.required) { + funBuilder.addParameter(param.name.toCamelCase(), baseType) + } else { + funBuilder.addParameter( + ParameterSpec + .builder(param.name.toCamelCase(), baseType.copy(nullable = true)) + .defaultValue("null") + .build(), + ) + } + } + + for (param in headerParams) { + val baseType = TypeMapping.toTypeName(param.schema, modelPackage) + if (param.required) { + funBuilder.addParameter(param.name.toCamelCase(), baseType) + } else { + funBuilder.addParameter( + ParameterSpec + .builder(param.name.toCamelCase(), baseType.copy(nullable = true)) + .defaultValue("null") + .build(), + ) + } + } + + if (endpoint.requestBody != null) { + val bodyType = TypeMapping.toTypeName(endpoint.requestBody.schema, modelPackage) + if (endpoint.requestBody.required) { + funBuilder.addParameter("body", bodyType) + } else { + funBuilder.addParameter( + ParameterSpec + .builder("body", bodyType.copy(nullable = true)) + .defaultValue("null") + .build(), + ) + } + } + + // Build function body + funBuilder.addCode(buildFunctionBody(endpoint, pathParams, queryParams, headerParams, returnBodyType)) + + return funBuilder.build() + } + + private fun buildFunctionBody( + endpoint: Endpoint, + pathParams: List, + queryParams: List, + headerParams: List, + returnBodyType: com.squareup.kotlinpoet.TypeName, + ): CodeBlock { + val httpMethodFun = + when (endpoint.method) { + HttpMethod.GET -> GET_FUN + HttpMethod.POST -> POST_FUN + HttpMethod.PUT -> PUT_FUN + HttpMethod.DELETE -> DELETE_FUN + HttpMethod.PATCH -> PATCH_FUN + } + + // Build the URL string with path parameter substitution + val urlString = buildUrlString(endpoint.path, pathParams) + + val code = CodeBlock.builder() + code.beginControlFlow("val response = try") + + // client.METHOD(url) { ... } + code.add("client.%M(%L)", httpMethodFun, urlString) + code.beginControlFlow("") + + // headers block + code.beginControlFlow("%M", HEADERS_FUN) + code.addStatement( + "append(%T.Authorization, %P)", + HTTP_HEADERS, + CodeBlock.of($$"Bearer ${'$'}{$tokenProvider}"), + ) + for (param in headerParams) { + val paramName = param.name.toCamelCase() + if (param.required) { + code.addStatement("append(%S, encodeParam(%L))", param.name, paramName) + } else { + code.beginControlFlow("if (%L != null)", paramName) + code.addStatement("append(%S, encodeParam(%L))", param.name, paramName) + code.endControlFlow() + } + } + code.endControlFlow() // headers + + // url block for query params + if (queryParams.isNotEmpty()) { + code.beginControlFlow("url") + for (param in queryParams) { + val paramName = param.name.toCamelCase() + if (param.required) { + code.addStatement("this.parameters.append(%S, encodeParam(%L))", param.name, paramName) + } else { + code.beginControlFlow("if (%L != null)", paramName) + code.addStatement("this.parameters.append(%S, encodeParam(%L))", param.name, paramName) + code.endControlFlow() + } + } + code.endControlFlow() // url + } + + // request body + if (endpoint.requestBody != null) { + if (endpoint.requestBody.required) { + code.addStatement("%M(%M.Application.Json)", CONTENT_TYPE_FUN, CONTENT_TYPE_APP_JSON) + code.addStatement("%M(body)", SET_BODY_FUN) + } else { + code.beginControlFlow("if (body != null)") + code.addStatement("%M(%M.Application.Json)", CONTENT_TYPE_FUN, CONTENT_TYPE_APP_JSON) + code.addStatement("%M(body)", SET_BODY_FUN) + code.endControlFlow() + } + } + + code.endControlFlow() // request lambda + + code.nextControlFlow("catch (e: %T)", Exception::class) + code.addStatement( + "%M(%T(0, e.message ?: \"Network error\", %T.Network))", + RAISE_FUN, + HTTP_ERROR, + HTTP_ERROR_TYPE, + ) + code.endControlFlow() // try-catch + + // when block for response mapping + code.beginControlFlow("return when (response.status.value)") + if (returnBodyType == UNIT) { + code.addStatement("in 200..299 -> %T(response.status.value, Unit)", HTTP_SUCCESS) + } else { + code.addStatement("in 200..299 -> %T(response.status.value, response.%M())", HTTP_SUCCESS, BODY_FUN) + } + code.addStatement( + "in 400..499 -> %M(%T(response.status.value, response.%M(), %T.Client))", + RAISE_FUN, + HTTP_ERROR, + BODY_AS_TEXT_FUN, + HTTP_ERROR_TYPE, + ) + code.addStatement( + "else -> %M(%T(response.status.value, response.%M(), %T.Server))", + RAISE_FUN, + HTTP_ERROR, + BODY_AS_TEXT_FUN, + HTTP_ERROR_TYPE, + ) + code.endControlFlow() // when + + return code.build() + } + + private fun buildUrlString( + path: String, + pathParams: List, + ): CodeBlock { + // Replace {paramName} with ${paramName} for Kotlin string template + var template = path + for (param in pathParams) { + val camelName = param.name.toCamelCase() + template = template.replace("{${param.name}}", "\${encodeParam($camelName)}") + } + // Use %P for string template that handles $ correctly + return CodeBlock.of("%P", CodeBlock.of("\${'$'}{baseUrl}$template")) + } + + private fun resolveReturnType(endpoint: Endpoint): com.squareup.kotlinpoet.TypeName { + // Find the first 2xx response with a schema + val successResponse = + endpoint.responses.entries + .filter { it.key.startsWith("2") } + .firstNotNullOfOrNull { it.value.schema } + + return if (successResponse != null) { + TypeMapping.toTypeName(successResponse, modelPackage) + } else { + UNIT + } + } +} 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 new file mode 100644 index 00000000..c90165e2 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDeduplicator.kt @@ -0,0 +1,81 @@ +package com.avsystem.justworks.core.gen + +import com.avsystem.justworks.core.model.PropertyModel +import com.avsystem.justworks.core.model.SchemaModel +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: List, val requiredProperties: Set,) { + data class PropertyKey(val name: String, val type: TypeRef, val required: Boolean,) + + companion object { + /** + * Creates an InlineSchemaKey from properties and required set. + * Properties are sorted by name for deterministic equality. + */ + fun from( + properties: List, + required: Set + ): InlineSchemaKey { + val propKeys = properties + .map { PropertyKey(it.name, it.type, it.name in required) } + .sortedBy { it.name } // Deterministic ordering + return InlineSchemaKey(properties = propKeys, requiredProperties = required) + } + } +} + +/** + * 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 { + // Maps structural key to the first generated name for that structure + private val namesByKey = mutableMapOf() + + // Names of all component schemas (from components/schemas in OpenAPI spec) + private val componentSchemaNames = mutableSetOf() + + /** + * Registers component schema names to detect collisions. + */ + fun registerComponentSchemas(schemas: List) { + componentSchemaNames.addAll(schemas.map { it.name }) + } + + /** + * Gets or generates a name for an inline schema. + * If a structurally identical schema was already seen, returns its name (deduplication). + * If the context name collides with a component schema or another inline schema, + * appends "Inline" suffix. + * + * @param properties The properties of the inline schema + * @param requiredProps The set of required property names + * @param contextName The base name derived from context (e.g., "PostPetRequest") + * @return The final name to use for this inline schema + */ + fun getOrGenerateName( + properties: List, + requiredProps: Set, + contextName: String, + ): String { + val key = InlineSchemaKey.from(properties, requiredProps) + + // Check if we've already generated a name for this structure + namesByKey[key]?.let { return it } + + // Generate new name, handling collisions + var finalName = contextName + if (finalName in componentSchemaNames || finalName in namesByKey.values) { + finalName = "${contextName}Inline" + } + + namesByKey[key] = finalName + return finalName + } +} 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 new file mode 100644 index 00000000..e54b8f1d --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt @@ -0,0 +1,712 @@ +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.PrimitiveType +import com.avsystem.justworks.core.model.PropertyModel +import com.avsystem.justworks.core.model.SchemaModel +import com.avsystem.justworks.core.model.TypeRef +import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.ParameterSpec +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.STRING +import com.squareup.kotlinpoet.TypeAliasSpec +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.asTypeName +import java.io.File +import kotlin.time.Instant + +/** + * Generates KotlinPoet [FileSpec] instances from an [ApiSpec]. + * + * 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) { + // Maps sealed parent name -> list of variant schema names + private val sealedHierarchies = mutableMapOf>() + + // Maps variant schema name -> (parent ClassName, serialName) + private val variantParents = mutableMapOf>>() + + // Set of schema names that are anyOf without discriminator (use JsonContentPolymorphicSerializer) + private val anyOfWithoutDiscriminator = mutableSetOf() + + fun getSealedHierarchies(): Map> = sealedHierarchies.toMap() + + fun generate(spec: ApiSpec): List { + // Reset state + sealedHierarchies.clear() + variantParents.clear() + anyOfWithoutDiscriminator.clear() + + val schemasById = spec.schemas.associateBy { it.name } + + // Initialize deduplicator and register component schemas + val deduplicator = InlineSchemaDeduplicator() + deduplicator.registerComponentSchemas(spec.schemas) + + // Collect all inline TypeRefs from the spec + val inlineTypeRefs = mutableListOf() + + // Scan endpoints for inline schemas in request/response bodies + for (endpoint in spec.endpoints) { + endpoint.requestBody?.schema?.let { collectInlineTypeRefs(it, inlineTypeRefs) } + endpoint.responses.values.forEach { response -> + response.schema?.let { collectInlineTypeRefs(it, inlineTypeRefs) } + } + } + + // Scan component schemas for inline property schemas + for (schema in spec.schemas) { + for (property in schema.properties) { + collectInlineTypeRefs(property.type, inlineTypeRefs) + } + } + + // Generate SchemaModels for inline schemas with deduplication + val inlineSchemas = mutableListOf() + val processedKeys = mutableSetOf() + + for (inlineTypeRef in inlineTypeRefs) { + val key = InlineSchemaKey.from(inlineTypeRef.properties, inlineTypeRef.requiredProperties) + if (key !in processedKeys) { + processedKeys.add(key) + val name = + deduplicator.getOrGenerateName( + inlineTypeRef.properties, + inlineTypeRef.requiredProperties, + inlineTypeRef.contextHint, + ) + + // Check if this is a nested inline (contains dot) + val isNested = name.contains(".") + + inlineSchemas.add( + SchemaModel( + name = name, + description = null, + properties = inlineTypeRef.properties, + requiredProperties = inlineTypeRef.requiredProperties, + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ), + ) + } + } + + // First pass: scan all schemas to build variantParents map and detect anyOf-without-discriminator + for (schema in spec.schemas) { + if (schema.isEnum) continue + val variants = schema.oneOf ?: schema.anyOf + if (!variants.isNullOrEmpty()) { + val parentClassName = ClassName(modelPackage, schema.name) + val variantNames = mutableListOf() + + for (ref in variants) { + if (ref is TypeRef.Reference) { + val variantName = ref.schemaName + variantNames.add(variantName) + + // Determine serial name from discriminator mapping or default to schema name + val serialName = resolveSerialName(schema, variantName) + + variantParents + .getOrPut(variantName) { mutableListOf() } + .add(parentClassName to serialName) + } + } + + sealedHierarchies[schema.name] = variantNames + + // Track anyOf schemas without discriminator for JsonContentPolymorphicSerializer generation + if (!schema.anyOf.isNullOrEmpty() && schema.discriminator == null) { + anyOfWithoutDiscriminator.add(schema.name) + } + } + } + + // Second pass: generate FileSpecs for component schemas + val schemaFiles = + spec.schemas + .filter { !it.isEnum } + .flatMap { schema -> + when { + !schema.oneOf.isNullOrEmpty() || !schema.anyOf.isNullOrEmpty() -> { + val sealedFile = generateSealedInterface(schema) + if (schema.name in anyOfWithoutDiscriminator) { + // Also generate the JsonContentPolymorphicSerializer companion object + val serializerFile = generatePolymorphicSerializer(schema, schemasById) + listOf(sealedFile, serializerFile) + } else { + listOf(sealedFile) + } + } + + !schema.allOf.isNullOrEmpty() -> { + listOf(generateAllOfDataClass(schema, schemasById)) + } + + isPrimitiveOnly(schema) -> { + // For primitive-only schemas, generate type alias + // TODO: Extend SchemaModel to include primitiveType field for primitive-only schemas + // For now, defaulting to String as the most common case + listOf(generateTypeAlias(schema, STRING)) + } + + else -> { + listOf(generateDataClass(schema)) + } + } + } + + // Generate FileSpecs for inline schemas (non-nested first, nested later) + val nonNestedInlineSchemas = inlineSchemas.filter { !it.name.contains(".") } + val nestedInlineSchemas = inlineSchemas.filter { it.name.contains(".") } + + val inlineSchemaFiles = + nonNestedInlineSchemas.map { generateDataClass(it) } + + nestedInlineSchemas.map { generateNestedInlineClass(it) } + + val enumFiles = spec.enums.map { generateEnumClass(it) } + + // Generate SerializersModule if any sealed hierarchies exist + val serializersModuleFile = SerializersModuleGenerator(modelPackage).generate(sealedHierarchies) + + return schemaFiles + inlineSchemaFiles + enumFiles + listOfNotNull(serializersModuleFile) + } + + /** + * Generates model files from [spec] and writes them to [outputDir]. + * Returns the number of files written. + */ + fun generateTo( + spec: ApiSpec, + outputDir: File, + ): Int { + val files = generate(spec) + for (fileSpec in files) { + fileSpec.writeTo(outputDir) + } + return files.size + } + + /** + * Generates a sealed interface for a oneOf/anyOf schema. + * - anyOf without discriminator: @Serializable(with = XxxSerializer::class) + * - oneOf or anyOf with discriminator: plain @Serializable + @JsonClassDiscriminator + */ + private fun generateSealedInterface(schema: SchemaModel): FileSpec { + val className = ClassName(modelPackage, schema.name) + + val typeSpec = TypeSpec.interfaceBuilder(className).addModifiers(KModifier.SEALED) + + if (schema.name in anyOfWithoutDiscriminator) { + // anyOf without discriminator: use JsonContentPolymorphicSerializer + val serializerClassName = ClassName(modelPackage, "${schema.name}Serializer") + typeSpec.addAnnotation( + AnnotationSpec + .builder(SERIALIZABLE) + .addMember("with = %T::class", serializerClassName) + .build(), + ) + } else { + typeSpec.addAnnotation(SERIALIZABLE) + } + + if (schema.discriminator != null) { + typeSpec.addAnnotation( + AnnotationSpec + .builder(JSON_CLASS_DISCRIMINATOR) + .addMember("%S", schema.discriminator.propertyName) + .build(), + ) + } + + if (schema.description != null) { + typeSpec.addKdoc("%L", schema.description) + } + + val fileBuilder = + FileSpec + .builder(className) + .addType(typeSpec.build()) + + // Add @OptIn for ExperimentalSerializationApi when discriminator is used + if (schema.discriminator != null) { + fileBuilder.addAnnotation( + AnnotationSpec + .builder(OPT_IN) + .addMember("%T::class", EXPERIMENTAL_SERIALIZATION_API) + .build(), + ) + } + + return fileBuilder.build() + } + + /** + * Generates a JsonContentPolymorphicSerializer object for an anyOf schema without discriminator. + * + * The serializer uses field-presence heuristic: for each variant, finds a field unique to that variant + * (not shared with any other variant) and uses it as a discriminating condition in selectDeserializer. + * + * If no unique field is found for a variant, a TODO() placeholder is emitted. + */ + private fun generatePolymorphicSerializer( + schema: SchemaModel, + schemasById: Map, + ): FileSpec { + val sealedClassName = ClassName(modelPackage, schema.name) + val serializerClassName = ClassName(modelPackage, "${schema.name}Serializer") + + // Collect property names per variant + val variantProperties: List>> = schema.anyOf + .orEmpty() + .filterIsInstance() + .map { ref -> + val variantSchema = schemasById[ref.schemaName] + val propNames = variantSchema?.properties?.map { it.name }?.toSet() ?: emptySet() + ref.schemaName to propNames + } + + // Find unique fields per variant: a field is unique if no other variant has it + val allFieldSets = variantProperties.map { it.second } + val uniqueFieldsPerVariant: List> = variantProperties.map { (variantName, fields) -> + val otherFields = allFieldSets.filter { it !== fields }.flatten().toSet() + val uniqueField = fields.firstOrNull { it !in otherFields } + variantName to uniqueField + } + + // Build selectDeserializer function body + val selectDeserializerBody = buildSelectDeserializerBody(schema.name, sealedClassName, uniqueFieldsPerVariant) + + val deserializationStrategy = ClassName("kotlinx.serialization", "DeserializationStrategy") + .parameterizedBy(com.squareup.kotlinpoet.STAR) + + val selectFun = FunSpec + .builder("selectDeserializer") + .addModifiers(KModifier.OVERRIDE) + .addParameter(ParameterSpec.builder("element", JSON_ELEMENT).build()) + .returns(deserializationStrategy) + .addCode(selectDeserializerBody) + .build() + + val objectSpec = TypeSpec + .objectBuilder(serializerClassName) + .superclass( + JSON_CONTENT_POLYMORPHIC_SERIALIZER.parameterizedBy(sealedClassName), + ).addSuperclassConstructorParameter("%T::class", sealedClassName) + .addFunction(selectFun) + .build() + + return FileSpec + .builder(serializerClassName) + .addType(objectSpec) + .build() + } + + /** + * Builds the body code for selectDeserializer using field-presence heuristics. + * For each variant with a unique field: when-clause checking field presence. + * For variants with no unique fields: TODO() with descriptive message. + */ + private fun buildSelectDeserializerBody( + parentName: String, + sealedClassName: ClassName, + uniqueFieldsPerVariant: List>, + ): CodeBlock { + val builder = CodeBlock.builder() + builder.beginControlFlow("return when") + + for ((variantName, uniqueField) in uniqueFieldsPerVariant) { + val variantClassName = ClassName(modelPackage, variantName) + if (uniqueField != null) { + builder.addStatement( + "%S·in·element.%M -> %T.serializer()", + uniqueField, + JSON_OBJECT_EXT, + variantClassName, + ) + } else { + builder.addStatement( + "// No unique discriminating fields found for variant '$variantName'", + ) + builder.addStatement( + "else -> TODO(%S)", + "No unique discriminating fields found for variant '$variantName' of anyOf '$parentName' - manual selectDeserializer required", + ) + } + } + + // Add a final else clause with SerializationException (only if all variants had unique fields) + val allHaveUniqueFields = uniqueFieldsPerVariant.all { it.second != null } + if (allHaveUniqueFields) { + builder.addStatement( + "else -> throw %T(%S + element)", + SERIALIZATION_EXCEPTION, + "Unknown $parentName variant: ", + ) + } + + builder.endControlFlow() + return builder.build() + } + + /** + * Generates a data class for an allOf schema with merged properties. + * If any allOf ref target is a oneOf sealed interface, adds it as a superinterface. + */ + private fun generateAllOfDataClass( + schema: SchemaModel, + schemasById: Map, + ): FileSpec { + // Determine superinterfaces from allOf refs that point to sealed interfaces + val superinterfaces = mutableListOf() + for (ref in schema.allOf.orEmpty()) { + if (ref is TypeRef.Reference) { + val refSchema = schemasById[ref.schemaName] + if (refSchema != null && !refSchema.oneOf.isNullOrEmpty()) { + superinterfaces.add(ClassName(modelPackage, ref.schemaName)) + } + } + } + + // Check if this schema is a variant of a sealed parent (via variantParents map) + val parentEntries = variantParents[schema.name] + val serialName = parentEntries?.firstOrNull()?.second + + // Merge superinterfaces from allOf refs and variantParents + val allSuperinterfaces = superinterfaces.toMutableList() + parentEntries?.forEach { (parentClass, _) -> + if (parentClass !in allSuperinterfaces) { + allSuperinterfaces.add(parentClass) + } + } + + return generateDataClass(schema, allSuperinterfaces, serialName) + } + + /** + * Generates a data class FileSpec, optionally with superinterfaces and @SerialName. + */ + private fun generateDataClass( + schema: SchemaModel, + superinterfaces: List = emptyList(), + serialName: String? = null, + ): FileSpec { + val className = ClassName(modelPackage, schema.name) + + // Check if this variant has parent info from oneOf scanning + val effectiveSuperinterfaces = superinterfaces.toMutableList() + val effectiveSerialName = serialName ?: variantParents[schema.name]?.firstOrNull()?.second + + variantParents[schema.name]?.forEach { (parentClass, _) -> + if (parentClass !in effectiveSuperinterfaces) { + effectiveSuperinterfaces.add(parentClass) + } + } + + // Sort properties: required without default, properties with defaults, nullable/optional + val requiredWithoutDefault = + schema.properties.filter { + it.name in schema.requiredProperties && it.defaultValue == null + } + val propertiesWithDefaults = + schema.properties.filter { + it.defaultValue != null + } + val nullableWithoutDefault = + schema.properties.filter { + it.name !in schema.requiredProperties && it.defaultValue == null + } + val sortedProps = requiredWithoutDefault + propertiesWithDefaults + nullableWithoutDefault + + val constructorBuilder = FunSpec.constructorBuilder() + val propertySpecs = mutableListOf() + + for (prop in sortedProps) { + val baseType = TypeMapping.toTypeName(prop.type, modelPackage) + val kotlinName = prop.name.toKotlinIdentifier() + + // Determine final type and default value + val (type, defaultValue) = + when { + // Nullable with default -> honor nullable, ignore OpenAPI default + prop.nullable && prop.defaultValue != null -> { + baseType.copy(nullable = true) to "null" + } + + // Non-nullable with default -> use OpenAPI default + !prop.nullable && prop.defaultValue != null -> { + baseType to formatDefaultValue(prop) + } + + // Nullable without default -> nullable with null default + prop.nullable && prop.defaultValue == null -> { + baseType.copy(nullable = true) to "null" + } + + // Required without default -> no default value + else -> { + baseType to null + } + } + + val paramBuilder = ParameterSpec.builder(kotlinName, type) + if (defaultValue != null) { + paramBuilder.defaultValue(defaultValue) + } + constructorBuilder.addParameter(paramBuilder.build()) + + val propSpec = + PropertySpec + .builder(kotlinName, type) + .initializer(kotlinName) + .addAnnotation( + AnnotationSpec + .builder(SERIAL_NAME) + .addMember("%S", prop.name) + .build(), + ) + propertySpecs.add(propSpec.build()) + } + + val typeSpec = + TypeSpec + .classBuilder(className) + .addModifiers(KModifier.DATA) + .primaryConstructor(constructorBuilder.build()) + .addProperties(propertySpecs) + .addAnnotation(SERIALIZABLE) + + // Add superinterfaces + for (si in effectiveSuperinterfaces) { + typeSpec.addSuperinterface(si) + } + + // Add @SerialName for variants + if (effectiveSerialName != null) { + typeSpec.addAnnotation( + AnnotationSpec + .builder(SERIAL_NAME) + .addMember("%S", effectiveSerialName) + .build(), + ) + } + + if (schema.description != null) { + typeSpec.addKdoc("%L", schema.description) + } + + return FileSpec + .builder(className) + .addType(typeSpec.build()) + .build() + } + + /** + * Formats a default value from a PropertyModel for use in KotlinPoet ParameterSpec.defaultValue(). + * Handles primitives (string, number, boolean) and date/time types. + * Validates date/time defaults at generation time. + */ + private fun formatDefaultValue(prop: PropertyModel): String = when (prop.type) { + is TypeRef.Primitive -> { + when (prop.type.type) { + PrimitiveType.STRING -> { + // Return the string with quotes for KotlinPoet + "\"${prop.defaultValue}\"" + } + + PrimitiveType.INT, + PrimitiveType.LONG, + PrimitiveType.DOUBLE, + PrimitiveType.FLOAT, + PrimitiveType.BOOLEAN, + -> { + prop.defaultValue.toString() + } + + PrimitiveType.DATE_TIME -> { + // Validate at generation time + try { + Instant.parse(prop.defaultValue as String) + "kotlin.time.Instant.parse(\"${prop.defaultValue}\")" + } catch (e: Exception) { + throw IllegalArgumentException( + "Invalid ISO-8601 date-time default '${prop.defaultValue}' " + + "for property ${prop.name}: ${e.message}", + ) + } + } + + PrimitiveType.DATE -> { + try { + kotlinx.datetime.LocalDate.parse(prop.defaultValue as String) + "kotlinx.datetime.LocalDate.parse(\"${prop.defaultValue}\")" + } catch (e: Exception) { + throw IllegalArgumentException( + "Invalid ISO-8601 date default '${prop.defaultValue}' " + + "for property ${prop.name}: ${e.message}", + ) + } + } + + else -> { + throw IllegalArgumentException( + "Unsupported default value type: ${prop.type}", + ) + } + } + } + + is TypeRef.Reference -> { + // Enum default: use constant name conversion + val constantName = prop.defaultValue.toString().toEnumConstantName() + "${prop.type.schemaName}.$constantName" + } + + else -> { + throw IllegalArgumentException( + "Unsupported default value type: ${prop.type}", + ) + } + } + + /** + * Resolves the @SerialName value for a variant within a oneOf schema. + * Uses discriminator mapping if available, otherwise defaults to the schema name. + */ + private fun resolveSerialName( + parentSchema: SchemaModel, + variantSchemaName: String, + ): String { + val mapping = parentSchema.discriminator?.mapping + if (!mapping.isNullOrEmpty()) { + // mapping is: serialName -> ref path (e.g., "circle" -> "#/components/schemas/Circle") + for ((serialName, refPath) in mapping) { + val refName = refPath.removePrefix("#/components/schemas/") + if (refName == variantSchemaName) { + return serialName + } + } + } + // Default: use schema name as serial name + return variantSchemaName + } + + private fun generateEnumClass(enum: EnumModel): FileSpec { + val className = ClassName(modelPackage, enum.name) + + val typeSpec = + TypeSpec + .enumBuilder(className) + .addAnnotation(SERIALIZABLE) + + for (value in enum.values) { + val constantName = value.toEnumConstantName() + val anonymousClass = + TypeSpec + .anonymousClassBuilder() + .addAnnotation( + AnnotationSpec + .builder(SERIAL_NAME) + .addMember("%S", value) + .build(), + ).build() + typeSpec.addEnumConstant(constantName, anonymousClass) + } + + if (enum.description != null) { + typeSpec.addKdoc("%L", enum.description) + } + + return FileSpec + .builder(className) + .addType(typeSpec.build()) + .build() + } + + /** + * Recursively collects all TypeRef.Inline instances from a TypeRef tree. + * The [visited] set guards against infinite recursion in circular schemas. + */ + private fun collectInlineTypeRefs( + typeRef: TypeRef, + result: MutableList, + visited: MutableSet = mutableSetOf(), + ) { + when (typeRef) { + is TypeRef.Inline -> { + if (!visited.add(typeRef)) return + result.add(typeRef) + // Recursively collect from properties + typeRef.properties.forEach { prop -> + collectInlineTypeRefs(prop.type, result, visited) + } + } + + is TypeRef.Array -> { + collectInlineTypeRefs(typeRef.items, result, visited) + } + + is TypeRef.Map -> { + collectInlineTypeRefs(typeRef.valueType, result, visited) + } + + is TypeRef.Primitive, is TypeRef.Reference, is TypeRef.Unknown -> {} + } + } + + /** + * Generates a nested inline class (e.g., Pet.Address). + * The name format is "Parent.Child" which we split and generate as a nested class. + * For now, we generate it as a top-level class with the full name. + * TODO: In the future, this could use TypeSpec.addType() for true nested classes. + */ + private fun generateNestedInlineClass(schema: SchemaModel): FileSpec { + // For nested classes like "Pet.Address", generate as top-level for now + // Replace dot with underscore to create valid class name + val sanitizedName = schema.name.replace(".", "") + val modifiedSchema = schema.copy(name = sanitizedName) + return generateDataClass(modifiedSchema) + } + + /** + * Checks if a schema is primitive-only (has no properties and no composite types). + * Primitive-only schemas should be generated as type aliases instead of data classes. + */ + private fun isPrimitiveOnly(schema: SchemaModel): Boolean = schema.properties.isEmpty() && + !schema.isEnum && schema.allOf == null && schema.oneOf == null && schema.anyOf == null + + /** + * Generates a type alias FileSpec for primitive-only schemas. + * Example: typealias GroupId = String + */ + private fun generateTypeAlias( + schema: SchemaModel, + primitiveType: com.squareup.kotlinpoet.TypeName, + ): FileSpec { + val className = ClassName(modelPackage, schema.name) + + val typeAlias = TypeAliasSpec.builder(schema.name, primitiveType) + + if (schema.description != null) { + typeAlias.addKdoc("%L", schema.description) + } + + return FileSpec + .builder(className) + .addTypeAlias(typeAlias.build()) + .build() + } +} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameUtils.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameUtils.kt new file mode 100644 index 00000000..2c53df36 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameUtils.kt @@ -0,0 +1,132 @@ +package com.avsystem.justworks.core.gen + +private val DELIMITERS = Regex("[_\\-.]+") +private val CAMEL_BOUNDARY = Regex("(?<=[a-z0-9])(?=[A-Z])") + +/** + * The set of Kotlin hard keywords that are reserved identifiers and require backtick escaping + * when used as property names in generated code. + */ +val KOTLIN_HARD_KEYWORDS = setOf( + "as", + "break", + "class", + "continue", + "do", + "else", + "false", + "for", + "fun", + "if", + "in", + "interface", + "is", + "null", + "object", + "package", + "return", + "super", + "this", + "throw", + "true", + "try", + "typealias", + "typeof", + "val", + "var", + "when", + "while", +) + +/** + * Converts a string to a valid Kotlin identifier for use as a property name. + * Calls [toCamelCase] first, then backtick-escapes the result if it is a Kotlin hard keyword. + * + * Examples: + * - "object" -> "`object`" + * - "in" -> "`in`" + * - "normalName" -> "normalName" + * - "my_object" -> "myObject" + */ +fun String.toKotlinIdentifier(): String { + val camelCased = toCamelCase() + return if (camelCased in KOTLIN_HARD_KEYWORDS) "`$camelCased`" else camelCased +} + +/** + * Converts a string to camelCase. + * Splits on `_`, `-`, `.` delimiters, lowercases first segment, + * capitalizes subsequent segments, and joins. + */ +fun String.toCamelCase(): String { + if (isBlank()) return this + val parts = + split(DELIMITERS).filter { it.isNotEmpty() }.flatMap { it.split(CAMEL_BOUNDARY) }.filter { it.isNotEmpty() } + if (parts.isEmpty()) return this + return buildString { + append(parts.first().lowercase()) + for (i in 1 until parts.size) { + append(parts[i].replaceFirstChar { it.uppercaseChar() }) + } + } +} + +/** + * Converts a string to PascalCase. + * Like [toCamelCase] but capitalizes the first segment too. + */ +fun String.toPascalCase(): String { + if (isBlank()) return this + val parts = + split(DELIMITERS).filter { it.isNotEmpty() }.flatMap { it.split(CAMEL_BOUNDARY) }.filter { it.isNotEmpty() } + if (parts.isEmpty()) return this + return parts.joinToString("") { it.replaceFirstChar { c -> c.uppercaseChar() } } +} + +/** + * Converts any string to UPPER_SNAKE_CASE for use as an enum constant name. + * Inserts `_` before uppercase letters in camelCase, replaces non-alphanumeric + * with `_`, uppercases, deduplicates `_`, trims leading/trailing `_`. + * Prefixes with `VALUE_` if the first character is a digit. + */ +fun String.toEnumConstantName(): String { + if (isBlank()) return this + val snaked = replace(CAMEL_BOUNDARY, "_") + val cleaned = snaked.map { c -> if (c.isLetterOrDigit()) c.uppercaseChar() else '_' }.joinToString("") + val deduped = cleaned.replace(Regex("_+"), "_").trim('_') + if (deduped.isEmpty()) return this + return if (deduped.first().isDigit()) "VALUE_$deduped" else deduped +} + +/** + * Generates a PascalCase operation name from HTTP method and path. + * Path parameters like {id} become "ById", {userId} becomes "ByUserId". + * Handles hyphens, underscores, and dots in path segments. + * + * Examples: + * - ("POST", "/pets") -> "PostPets" + * - ("GET", "/pets/{id}") -> "GetPetsById" + * - ("PUT", "/users/{userId}/orders/{orderId}") -> "PutUsersByUserIdOrdersByOrderId" + * - ("GET", "/api-tokens") -> "GetApiTokens" + */ +fun operationNameFromPath( + method: String, + path: String +): String { + val methodPart = method.lowercase().replaceFirstChar { it.uppercase() } + + val pathPart = path + .split("/") + .filter { it.isNotEmpty() } + .joinToString("") { segment -> + if (segment.startsWith("{") && segment.endsWith("}")) { + // Path parameter: {id} -> ById, {userId} -> ByUserId + val paramName = segment.removeSurrounding("{", "}") + "By" + paramName.toPascalCase() + } else { + segment.toPascalCase() + } + } + + return methodPart + pathPart +} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt new file mode 100644 index 00000000..7710b5b5 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt @@ -0,0 +1,75 @@ +package com.avsystem.justworks.core.gen + +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.MemberName + +/** + * Centralized repository of type and function references used across code generators. + * Organized by domain (HTTP client, serialization, dates, error handling). + */ + +// ============================================================================ +// Ktor HTTP Client +// ============================================================================ + +val HTTP_CLIENT = ClassName("io.ktor.client", "HttpClient") +val CONTENT_NEGOTIATION = ClassName("io.ktor.client.plugins.contentnegotiation", "ContentNegotiation") +val HTTP_HEADERS = ClassName("io.ktor.http", "HttpHeaders") + +val JSON_FUN = MemberName("io.ktor.serialization.kotlinx.json", "json") +val BODY_FUN = MemberName("io.ktor.client.call", "body") +val BODY_AS_TEXT_FUN = MemberName("io.ktor.client.statement", "bodyAsText") +val SET_BODY_FUN = MemberName("io.ktor.client.request", "setBody") +val CONTENT_TYPE_FUN = MemberName("io.ktor.http", "contentType") +val CONTENT_TYPE_APP_JSON = MemberName("io.ktor.http", "ContentType") +val HEADERS_FUN = MemberName("io.ktor.client.request", "headers") + +val GET_FUN = MemberName("io.ktor.client.request", "get") +val POST_FUN = MemberName("io.ktor.client.request", "post") +val PUT_FUN = MemberName("io.ktor.client.request", "put") +val DELETE_FUN = MemberName("io.ktor.client.request", "delete") +val PATCH_FUN = MemberName("io.ktor.client.request", "patch") + +// ============================================================================ +// kotlinx.serialization +// ============================================================================ + +val SERIALIZABLE = ClassName("kotlinx.serialization", "Serializable") +val SERIAL_NAME = ClassName("kotlinx.serialization", "SerialName") +val EXPERIMENTAL_SERIALIZATION_API = ClassName("kotlinx.serialization", "ExperimentalSerializationApi") +val SERIALIZATION_EXCEPTION = ClassName("kotlinx.serialization", "SerializationException") +val JSON_CLASS_DISCRIMINATOR = ClassName("kotlinx.serialization.json", "JsonClassDiscriminator") +val JSON_CLASS = ClassName("kotlinx.serialization.json", "Json") +val JSON_CONTENT_POLYMORPHIC_SERIALIZER = ClassName("kotlinx.serialization.json", "JsonContentPolymorphicSerializer") +val JSON_ELEMENT = ClassName("kotlinx.serialization.json", "JsonElement") +val SERIALIZERS_MODULE = ClassName("kotlinx.serialization.modules", "SerializersModule") + +val JSON_OBJECT_EXT = MemberName("kotlinx.serialization.json", "jsonObject") + +val ENCODE_TO_STRING_FUN = MemberName("kotlinx.serialization", "encodeToString") +val POLYMORPHIC_FUN = MemberName("kotlinx.serialization.modules", "polymorphic") +val SUBCLASS_FUN = MemberName("kotlinx.serialization.modules", "subclass") + +// ============================================================================ +// Date/Time (kotlinx.datetime) +// ============================================================================ + +val INSTANT = ClassName("kotlinx.datetime", "Instant") +val LOCAL_DATE = ClassName("kotlinx.datetime", "LocalDate") + +// ============================================================================ +// Error Handling (Arrow + Kotlin stdlib) +// ============================================================================ + +val RAISE = ClassName("arrow.core.raise", "Raise") +val RAISE_FUN = MemberName("arrow.core.raise.context", "raise") +val HTTP_ERROR = ClassName("com.avsystem.justworks", "HttpError") +val HTTP_ERROR_TYPE = ClassName("com.avsystem.justworks", "HttpErrorType") +val HTTP_SUCCESS = ClassName("com.avsystem.justworks", "HttpSuccess") + +// ============================================================================ +// Kotlin stdlib +// ============================================================================ + +val CLOSEABLE = ClassName("java.io", "Closeable") +val OPT_IN = ClassName("kotlin", "OptIn") diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt new file mode 100644 index 00000000..59f7d256 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt @@ -0,0 +1,67 @@ +package com.avsystem.justworks.core.gen + +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.MemberName +import com.squareup.kotlinpoet.PropertySpec +import java.io.File + +/** + * Generates a `SerializersModule` registration file for all polymorphic sealed hierarchies. + * + * Produces a top-level `val generatedSerializersModule: SerializersModule` property + * that registers each sealed interface with its subclass variants. + */ +class SerializersModuleGenerator(private val modelPackage: String,) { + /** + * Generates a [FileSpec] containing the SerializersModule registration. + * Returns null if [sealedHierarchies] is empty (no polymorphic types to register). + * + * @param sealedHierarchies map of sealed parent name to list of variant schema names + */ + fun generate(sealedHierarchies: Map>): FileSpec? { + if (sealedHierarchies.isEmpty()) return null + + val code = + CodeBlock + .builder() + .beginControlFlow("%T", SERIALIZERS_MODULE) + + for ((parent, variants) in sealedHierarchies) { + val parentClass = ClassName(modelPackage, parent) + code.beginControlFlow("%M(%T::class)", POLYMORPHIC_FUN, parentClass) + for (variant in variants) { + val variantClass = ClassName(modelPackage, variant) + code.addStatement("%M(%T::class)", SUBCLASS_FUN, variantClass) + } + code.endControlFlow() + } + + code.endControlFlow() + + val prop = + PropertySpec + .builder("generatedSerializersModule", SERIALIZERS_MODULE) + .initializer(code.build()) + .build() + + return FileSpec + .builder(modelPackage, "SerializersModule") + .addProperty(prop) + .build() + } + + /** + * Generates the SerializersModule file and writes it to [outputDir]. + * Returns 0 if no polymorphic types exist, 1 if a file was written. + */ + fun generateTo( + sealedHierarchies: Map>, + outputDir: File, + ): Int { + val fileSpec = generate(sealedHierarchies) ?: return 0 + fileSpec.writeTo(outputDir) + return 1 + } +} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/TypeMapping.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/TypeMapping.kt new file mode 100644 index 00000000..2a7f7942 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/TypeMapping.kt @@ -0,0 +1,63 @@ +package com.avsystem.justworks.core.gen + +import com.avsystem.justworks.core.model.PrimitiveType +import com.avsystem.justworks.core.model.TypeRef +import com.squareup.kotlinpoet.ANY +import com.squareup.kotlinpoet.BOOLEAN +import com.squareup.kotlinpoet.BYTE_ARRAY +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.DOUBLE +import com.squareup.kotlinpoet.FLOAT +import com.squareup.kotlinpoet.INT +import com.squareup.kotlinpoet.LIST +import com.squareup.kotlinpoet.LONG +import com.squareup.kotlinpoet.MAP +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.STRING +import com.squareup.kotlinpoet.TypeName + +/** + * Maps [TypeRef] sealed variants to KotlinPoet [TypeName] instances. + */ +object TypeMapping { + fun toTypeName( + typeRef: TypeRef, + modelPackage: String, + ): TypeName = when (typeRef) { + is TypeRef.Primitive -> { + primitiveTypeName(typeRef.type) + } + + is TypeRef.Array -> { + LIST.parameterizedBy(toTypeName(typeRef.items, modelPackage)) + } + + is TypeRef.Map -> { + MAP.parameterizedBy(STRING, toTypeName(typeRef.valueType, modelPackage)) + } + + is TypeRef.Reference -> { + ClassName(modelPackage, typeRef.schemaName) + } + + is TypeRef.Inline -> { + // For nested inline classes (e.g., "Pet.Address"), sanitize to "PetAddress" + val sanitizedName = typeRef.contextHint.replace(".", "") + ClassName(modelPackage, sanitizedName) + } + + is TypeRef.Unknown -> ANY + } + + private fun primitiveTypeName(type: PrimitiveType): TypeName = when (type) { + PrimitiveType.STRING -> STRING + PrimitiveType.INT -> INT + PrimitiveType.LONG -> LONG + PrimitiveType.DOUBLE -> DOUBLE + PrimitiveType.FLOAT -> FLOAT + PrimitiveType.BOOLEAN -> BOOLEAN + PrimitiveType.BYTE_ARRAY -> BYTE_ARRAY + PrimitiveType.DATE_TIME -> INSTANT + PrimitiveType.DATE -> LOCAL_DATE + } +} diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiResponseGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiResponseGeneratorTest.kt new file mode 100644 index 00000000..5f9942ac --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiResponseGeneratorTest.kt @@ -0,0 +1,78 @@ +package com.avsystem.justworks.core.gen + +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.TypeVariableName +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ApiResponseGeneratorTest { + private fun httpErrorClass(): TypeSpec { + val files = listOf(ApiResponseGenerator.generateHttpError(), ApiResponseGenerator.generateSuccess()) + val httpErrorFile = files.first { it.name == "HttpError" } + return httpErrorFile.members.filterIsInstance().first { it.name == "HttpError" } + } + + private fun httpErrorTypeEnum(): TypeSpec { + val files = listOf(ApiResponseGenerator.generateHttpError(), ApiResponseGenerator.generateSuccess()) + val httpErrorFile = files.first { it.name == "HttpError" } + return httpErrorFile.members.filterIsInstance().first { it.name == "HttpErrorType" } + } + + private fun successClass(): TypeSpec { + val files = listOf(ApiResponseGenerator.generateHttpError(), ApiResponseGenerator.generateSuccess()) + val successFile = files.first { it.name == "HttpSuccess" } + return successFile.members.filterIsInstance().first() + } + + @Test + fun `generates data class HttpError`() { + val typeSpec = httpErrorClass() + assertEquals("HttpError", typeSpec.name) + assertTrue(KModifier.DATA in typeSpec.modifiers, "Expected DATA modifier") + } + + @Test + fun `HttpError data class has code message and type fields`() { + val typeSpec = httpErrorClass() + val constructor = assertNotNull(typeSpec.primaryConstructor) + assertEquals(3, constructor.parameters.size) + val codeParam = constructor.parameters.first { it.name == "code" } + assertEquals("kotlin.Int", codeParam.type.toString()) + val messageParam = constructor.parameters.first { it.name == "message" } + assertEquals("kotlin.String", messageParam.type.toString()) + val typeParam = constructor.parameters.first { it.name == "type" } + assertEquals("com.avsystem.justworks.HttpErrorType", typeParam.type.toString()) + } + + @Test + fun `generates HttpErrorType enum with three values`() { + val typeSpec = httpErrorTypeEnum() + assertEquals("HttpErrorType", typeSpec.name) + val constantNames = typeSpec.enumConstants.keys.sorted() + assertEquals(listOf("Client", "Network", "Server"), constantNames) + } + + @Test + fun `Success is a data class with body and statusCode`() { + val success = successClass() + assertEquals("HttpSuccess", success.name) + assertTrue(KModifier.DATA in success.modifiers, "Expected DATA modifier on Success") + val constructor = assertNotNull(success.primaryConstructor) + val paramNames = constructor.parameters.map { it.name } + assertTrue("body" in paramNames, "Expected 'body' parameter") + assertTrue("statusCode" in paramNames, "Expected 'statusCode' parameter") + val bodyParam = constructor.parameters.first { it.name == "body" } + assertTrue(bodyParam.type is TypeVariableName, "body should be type variable T") + } + + @Test + fun `generates two files`() { + val files = listOf(ApiResponseGenerator.generateHttpError(), ApiResponseGenerator.generateSuccess()) + assertEquals(2, files.size) + val fileNames = files.map { it.name }.sorted() + assertEquals(listOf("HttpError", "HttpSuccess"), fileNames) + } +} 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 new file mode 100644 index 00000000..ecc8cbba --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt @@ -0,0 +1,408 @@ +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.HttpMethod +import com.avsystem.justworks.core.model.Parameter +import com.avsystem.justworks.core.model.ParameterLocation +import com.avsystem.justworks.core.model.PrimitiveType +import com.avsystem.justworks.core.model.RequestBody +import com.avsystem.justworks.core.model.Response +import com.avsystem.justworks.core.model.TypeRef +import com.squareup.kotlinpoet.ExperimentalKotlinPoetApi +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.ParameterizedTypeName +import com.squareup.kotlinpoet.TypeSpec +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +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 fun spec(endpoints: List) = ApiSpec( + title = "Test", + version = "1.0", + endpoints = endpoints, + schemas = emptyList(), + enums = emptyList(), + ) + + private fun endpoint( + path: String = "/pets", + method: HttpMethod = HttpMethod.GET, + operationId: String = "listPets", + tags: List = listOf("Pets"), + parameters: List = emptyList(), + requestBody: RequestBody? = null, + responses: Map = + mapOf( + "200" to Response("200", "OK", TypeRef.Reference("Pet")), + ), + ) = Endpoint( + path = path, + method = method, + operationId = operationId, + summary = null, + tags = tags, + parameters = parameters, + requestBody = requestBody, + responses = responses, + ) + + private fun clientClass(endpoints: List): TypeSpec { + val files = generator.generate(spec(endpoints)) + return files + .first() + .members + .filterIsInstance() + .first() + } + + // -- CLNT-01: One client class per tag -- + + @Test + fun `generates one client class per tag`() { + val endpoints = + listOf( + endpoint(operationId = "listPets", tags = listOf("Pets")), + endpoint(path = "/store", operationId = "getInventory", tags = listOf("Store")), + ) + val files = generator.generate(spec(endpoints)) + assertEquals(2, files.size) + val classNames = + files + .map { + it.members + .filterIsInstance() + .first() + .name!! + }.sorted() + assertEquals(listOf("PetsApi", "StoreApi"), classNames) + } + + // -- CLNT-02: Endpoint functions are suspend -- + + @Test + fun `endpoint functions are suspend`() { + val cls = clientClass(listOf(endpoint())) + val funSpec = cls.funSpecs.first { it.name == "listPets" } + assertTrue(KModifier.SUSPEND in funSpec.modifiers, "Expected SUSPEND modifier") + } + + // -- CLNT-03: All HTTP methods -- + + @Test + fun `supports all HTTP methods`() { + val methods = + listOf( + HttpMethod.GET to "getPet", + HttpMethod.POST to "createPet", + HttpMethod.PUT to "updatePet", + HttpMethod.DELETE to "deletePet", + HttpMethod.PATCH to "patchPet", + ) + val endpoints = + methods.map { (method, opId) -> + endpoint(method = method, operationId = opId) + } + val cls = clientClass(endpoints) + val funBodies = + cls.funSpecs.filter { it.name != "close" }.associate { + it.name to it.body.toString() + } + // MemberName body.toString() renders fully-qualified names (e.g., io.ktor.client.request.`get`) + // Check that each body references the correct Ktor request method path + assertTrue( + funBodies["getPet"]!!.contains("request.get(") || funBodies["getPet"]!!.contains("request.`get`("), + "GET method expected", + ) + assertTrue( + funBodies["createPet"]!!.contains("request.post(") || funBodies["createPet"]!!.contains("request.`post`("), + "POST method expected", + ) + assertTrue( + funBodies["updatePet"]!!.contains("request.put(") || funBodies["updatePet"]!!.contains("request.`put`("), + "PUT method expected", + ) + assertTrue( + funBodies["deletePet"]!!.contains("request.delete(") || + funBodies["deletePet"]!!.contains("request.`delete`("), + "DELETE method expected", + ) + assertTrue( + funBodies["patchPet"]!!.contains("request.patch(") || funBodies["patchPet"]!!.contains("request.`patch`("), + "PATCH method expected", + ) + } + + // -- CLNT-04: Path parameters become function parameters -- + + @Test + fun `path parameters become function parameters`() { + val ep = + endpoint( + path = "/pets/{petId}", + operationId = "getPet", + parameters = + listOf( + Parameter("petId", ParameterLocation.PATH, true, TypeRef.Primitive(PrimitiveType.LONG), null), + ), + ) + val cls = clientClass(listOf(ep)) + val funSpec = cls.funSpecs.first { it.name == "getPet" } + val param = funSpec.parameters.first { it.name == "petId" } + assertEquals("kotlin.Long", param.type.toString()) + } + + // -- CLNT-05: Query parameters become function parameters -- + + @Test + fun `query parameters become function parameters`() { + val ep = + endpoint( + operationId = "listPets", + parameters = + listOf( + Parameter("limit", ParameterLocation.QUERY, true, TypeRef.Primitive(PrimitiveType.INT), null), + ), + ) + val cls = clientClass(listOf(ep)) + val funSpec = cls.funSpecs.first { it.name == "listPets" } + val param = funSpec.parameters.first { it.name == "limit" } + assertEquals("kotlin.Int", param.type.toString()) + } + + // -- CLNT-06: Optional query parameters default to null -- + + @Test + fun `optional query parameters default to null`() { + val ep = + endpoint( + operationId = "listPets", + parameters = + listOf( + Parameter("limit", ParameterLocation.QUERY, false, TypeRef.Primitive(PrimitiveType.INT), null), + ), + ) + val cls = clientClass(listOf(ep)) + val funSpec = cls.funSpecs.first { it.name == "listPets" } + val param = funSpec.parameters.first { it.name == "limit" } + assertTrue(param.type.isNullable, "Optional query param should be nullable") + assertEquals("null", param.defaultValue.toString()) + } + + // -- CLNT-07: Request body becomes function parameter -- + + @Test + fun `request body becomes function parameter`() { + val ep = + endpoint( + method = HttpMethod.POST, + operationId = "createPet", + requestBody = RequestBody(true, "application/json", TypeRef.Reference("Pet")), + ) + val cls = clientClass(listOf(ep)) + val funSpec = cls.funSpecs.first { it.name == "createPet" } + val bodyParam = funSpec.parameters.first { it.name == "body" } + assertEquals("com.example.model.Pet", bodyParam.type.toString()) + } + + // -- CLNT-08: Return type is Success parameterized -- + + @Test + fun `return type is Success parameterized`() { + val cls = clientClass(listOf(endpoint())) + val funSpec = cls.funSpecs.first { it.name == "listPets" } + val returnType = funSpec.returnType + assertNotNull(returnType) + assertTrue(returnType is ParameterizedTypeName, "Expected ParameterizedTypeName") + assertEquals("com.avsystem.justworks.HttpSuccess", returnType.rawType.toString()) + assertEquals("com.example.model.Pet", returnType.typeArguments.first().toString()) + } + + // -- Context receiver: Raise -- + + @OptIn(ExperimentalKotlinPoetApi::class) + @Test + fun `endpoint functions have Raise HttpError context parameter`() { + val cls = clientClass(listOf(endpoint())) + val funSpec = cls.funSpecs.first { it.name == "listPets" } + val contextParameters = funSpec.contextParameters + assertTrue(contextParameters.isNotEmpty(), "Expected context parameter") + val contextType = contextParameters.first().type + assertTrue(contextType is ParameterizedTypeName, "Expected parameterized Raise type") + assertEquals("arrow.core.raise.Raise", contextType.rawType.toString()) + assertEquals("com.avsystem.justworks.HttpError", contextType.typeArguments.first().toString()) + } + + // -- CLNT-09: Header parameters become function parameters -- + + @Test + fun `header parameters become function parameters`() { + val ep = + endpoint( + operationId = "listPets", + parameters = + listOf( + Parameter( + "X-Request-Id", + ParameterLocation.HEADER, + true, + TypeRef.Primitive(PrimitiveType.STRING), + null, + ), + ), + ) + val cls = clientClass(listOf(ep)) + val funSpec = cls.funSpecs.first { it.name == "listPets" } + val param = funSpec.parameters.first { it.name == "xRequestId" } + assertEquals("kotlin.String", param.type.toString()) + } + + // -- CLNT-10: Client constructor has baseUrl parameter -- + + @Test + fun `client constructor has baseUrl parameter`() { + val cls = clientClass(listOf(endpoint())) + val constructor = assertNotNull(cls.primaryConstructor) + val baseUrl = constructor.parameters.first { it.name == "baseUrl" } + assertEquals("kotlin.String", baseUrl.type.toString()) + } + + // -- AUTH-01: Client constructor has tokenProvider parameter -- + + @Test + fun `client constructor has tokenProvider parameter`() { + val cls = clientClass(listOf(endpoint())) + val constructor = assertNotNull(cls.primaryConstructor) + val tokenProvider = constructor.parameters.first { it.name == "tokenProvider" } + assertTrue(tokenProvider.type.toString().contains("String"), "tokenProvider should return String") + } + + // -- Pitfall 3: Untagged endpoints go to DefaultClient -- + + @Test + fun `untagged endpoints go to DefaultApi`() { + val ep = endpoint(operationId = "healthCheck", tags = emptyList()) + val files = generator.generate(spec(listOf(ep))) + val className = + files + .first() + .members + .filterIsInstance() + .first() + .name + assertEquals("DefaultApi", className) + } + + // -- Pitfall 5: Void response uses Unit type parameter -- + + @Test + fun `void response uses Unit type parameter`() { + val ep = + endpoint( + method = HttpMethod.DELETE, + operationId = "deletePet", + responses = mapOf("204" to Response("204", "No content", null)), + ) + val cls = clientClass(listOf(ep)) + val funSpec = cls.funSpecs.first { it.name == "deletePet" } + val returnType = funSpec.returnType as ParameterizedTypeName + assertEquals("com.avsystem.justworks.HttpSuccess", returnType.rawType.toString()) + assertEquals("kotlin.Unit", returnType.typeArguments.first().toString()) + } + + // -- Client class implements Closeable -- + + @Test + fun `client class implements Closeable`() { + val cls = clientClass(listOf(endpoint())) + val superinterfaces = cls.superinterfaces.keys.map { it.toString() } + assertTrue("java.io.Closeable" in superinterfaces, "Expected Closeable superinterface") + } + + // -- SER-01: Polymorphic spec wires SerializersModule -- + + @Test + fun `polymorphic spec wires serializersModule in json block`() { + val files = ClientGenerator(apiPackage, modelPackage).generate(spec(listOf(endpoint())), hasPolymorphicTypes = true) + val clientProperty = files + .first() + .members + .filterIsInstance() + .first() + .propertySpecs + .first { it.name == "client" } + val clientInitializer = clientProperty.initializer.toString() + assertTrue(clientInitializer.contains("serializersModule"), "Expected serializersModule in client initializer") + assertTrue(clientInitializer.contains("generatedSerializersModule"), "Expected generatedSerializersModule reference") + } + + @Test + fun `non-polymorphic spec has plain json() with no serializersModule`() { + val files = ClientGenerator(apiPackage, modelPackage).generate(spec(listOf(endpoint())), hasPolymorphicTypes = false) + val clientProperty = files + .first() + .members + .filterIsInstance() + .first() + .propertySpecs + .first { it.name == "client" } + val clientInitializer = clientProperty.initializer.toString() + assertTrue(!clientInitializer.contains("serializersModule"), "Expected no serializersModule for non-polymorphic spec") + } + + // -- AUTH-02, AUTH-03: Generated code contains bearer auth header -- + + @Test + fun `generated code contains bearer auth header`() { + val cls = clientClass(listOf(endpoint())) + val funSpec = cls.funSpecs.first { it.name == "listPets" } + val body = funSpec.body.toString() + assertTrue(body.contains("Authorization"), "Expected Authorization header") + assertTrue(body.contains("Bearer"), "Expected Bearer prefix") + assertTrue(body.contains("tokenProvider"), "Expected tokenProvider call") + } + + // -- ERRH-05: Generated code contains try-catch with raise -- + + @Test + fun `generated code contains try-catch with raise NetworkError`() { + val cls = clientClass(listOf(endpoint())) + val funSpec = cls.funSpecs.first { it.name == "listPets" } + val body = funSpec.body.toString() + assertTrue(body.contains("catch"), "Expected catch block") + assertTrue(body.contains("HttpError("), "Expected HttpError constructor call in catch") + assertTrue( + body.contains("HttpErrorType.Network") || body.contains(".Network"), + "Expected HttpErrorType.Network in catch", + ) + assertTrue(body.contains("raise"), "Expected raise call") + } + + // -- ERRH-02, ERRH-03, ERRH-04: Generated code branches on status code with raise -- + + @Test + fun `generated code branches on status code with raise`() { + val cls = clientClass(listOf(endpoint())) + val funSpec = cls.funSpecs.first { it.name == "listPets" } + val body = funSpec.body.toString() + assertTrue(body.contains("200..299"), "Expected 2xx range check") + assertTrue(body.contains("400..499"), "Expected 4xx range check") + assertTrue(body.contains("Success"), "Expected Success for 2xx") + assertTrue(body.contains("HttpError("), "Expected HttpError constructor call") + assertTrue( + body.contains("HttpErrorType.Client") || body.contains(".Client"), + "Expected HttpErrorType.Client for 4xx", + ) + assertTrue( + body.contains("HttpErrorType.Server") || body.contains(".Server"), + "Expected HttpErrorType.Server for 5xx", + ) + assertTrue(body.contains("raise"), "Expected raise call") + } +} 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 new file mode 100644 index 00000000..726db7dd --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDedupTest.kt @@ -0,0 +1,144 @@ +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.SchemaModel +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`() { + val deduplicator = InlineSchemaDeduplicator() + + 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 name1 = deduplicator.getOrGenerateName(props1, required, "FirstContext") + val name2 = deduplicator.getOrGenerateName(props2, required, "SecondContext") + + // First occurrence wins + assertEquals("FirstContext", name1) + assertEquals("FirstContext", name2) // Same structure returns same name + } + + @Test + fun `different schemas return different names`() { + val deduplicator = InlineSchemaDeduplicator() + + val props1 = listOf( + PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, false), + ) + + val props2 = listOf( + PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), + ) + + val name1 = deduplicator.getOrGenerateName(props1, setOf("id"), "FirstContext") + val name2 = deduplicator.getOrGenerateName(props2, setOf("name"), "SecondContext") + + assertEquals("FirstContext", name1) + assertEquals("SecondContext", name2) + assertNotEquals(name1, name2) + } + + @Test + fun `name collision with component schema appends Inline suffix`() { + val deduplicator = InlineSchemaDeduplicator() + + val componentSchemas = listOf( + SchemaModel( + name = "Pet", + description = null, + properties = emptyList(), + requiredProperties = emptySet(), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ), + ) + deduplicator.registerComponentSchemas(componentSchemas) + + val props = listOf( + PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, false), + ) + + val name = deduplicator.getOrGenerateName(props, setOf("id"), "Pet") + + assertEquals("PetInline", name) // Collision with component schema + } + + @Test + fun `property order does not affect equality`() { + val deduplicator = InlineSchemaDeduplicator() + + 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 name1 = deduplicator.getOrGenerateName(props1, required, "FirstContext") + val name2 = deduplicator.getOrGenerateName(props2, required, "SecondContext") + + // Same structure despite different order + assertEquals("FirstContext", name1) + assertEquals("FirstContext", name2) + } + + @Test + fun `different required sets produce different keys`() { + val deduplicator = InlineSchemaDeduplicator() + + 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") + + // Different required sets mean different structures + assertEquals("FirstContext", name1) + assertEquals("SecondContext", name2) + assertNotEquals(name1, name2) + } + + @Test + fun `collision with existing inline schema name appends Inline suffix`() { + val deduplicator = InlineSchemaDeduplicator() + + val props1 = listOf( + PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, false), + ) + val props2 = listOf( + PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), + ) + + // First schema gets the base name + val name1 = deduplicator.getOrGenerateName(props1, setOf("id"), "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) + } +} 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 new file mode 100644 index 00000000..bfc4cee4 --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorPolymorphicTest.kt @@ -0,0 +1,642 @@ +package com.avsystem.justworks.core.gen + +import com.avsystem.justworks.core.model.ApiSpec +import com.avsystem.justworks.core.model.Discriminator +import com.avsystem.justworks.core.model.EnumModel +import com.avsystem.justworks.core.model.PrimitiveType +import com.avsystem.justworks.core.model.PropertyModel +import com.avsystem.justworks.core.model.SchemaModel +import com.avsystem.justworks.core.model.TypeRef +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.TypeSpec +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ModelGeneratorPolymorphicTest { + private val modelPackage = "com.example.model" + private val generator = ModelGenerator(modelPackage) + + private fun spec( + schemas: List = emptyList(), + enums: List = emptyList(), + ) = ApiSpec( + title = "Test", + version = "1.0", + endpoints = emptyList(), + schemas = schemas, + enums = enums, + ) + + private fun schema( + name: String, + properties: List = emptyList(), + requiredProperties: Set = emptySet(), + oneOf: List? = null, + anyOf: List? = null, + allOf: List? = null, + discriminator: Discriminator? = null, + ) = SchemaModel( + name = name, + description = null, + properties = properties, + requiredProperties = requiredProperties, + isEnum = false, + allOf = allOf, + oneOf = oneOf, + anyOf = anyOf, + discriminator = discriminator, + ) + + private fun findType( + files: List, + name: String, + ): TypeSpec { + for (file in files) { + val found = file.members.filterIsInstance().find { it.name == name } + if (found != null) return found + } + throw AssertionError("TypeSpec '$name' not found in generated files") + } + + private fun findFile( + files: List, + typeName: String, + ): com.squareup.kotlinpoet.FileSpec { + for (file in files) { + if (file.members.filterIsInstance().any { it.name == typeName }) return file + } + throw AssertionError("FileSpec containing '$typeName' not found") + } + + // -- POLY-01: Sealed interface from oneOf -- + + @Test + fun `oneOf schema generates sealed interface with SEALED modifier`() { + val shapeSchema = + schema( + name = "Shape", + oneOf = listOf(TypeRef.Reference("Circle"), TypeRef.Reference("Square")), + ) + val circleSchema = + schema( + name = "Circle", + properties = listOf(PropertyModel("radius", TypeRef.Primitive(PrimitiveType.DOUBLE), null, false)), + requiredProperties = setOf("radius"), + ) + val squareSchema = + schema( + name = "Square", + properties = listOf(PropertyModel("sideLength", TypeRef.Primitive(PrimitiveType.DOUBLE), null, false)), + requiredProperties = setOf("sideLength"), + ) + + val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema, squareSchema))) + val shapeType = findType(files, "Shape") + + assertTrue(KModifier.SEALED in shapeType.modifiers, "Expected SEALED modifier on Shape") + assertEquals(TypeSpec.Kind.INTERFACE, shapeType.kind, "Expected INTERFACE kind") + } + + @Test + fun `oneOf schema has Serializable annotation`() { + val shapeSchema = + schema( + name = "Shape", + oneOf = listOf(TypeRef.Reference("Circle"), TypeRef.Reference("Square")), + ) + val circleSchema = schema(name = "Circle") + val squareSchema = schema(name = "Square") + + val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema, squareSchema))) + val shapeType = findType(files, "Shape") + + val annotations = shapeType.annotations.map { it.typeName.toString() } + assertTrue("kotlinx.serialization.Serializable" in annotations, "Expected @Serializable on sealed interface") + } + + // -- POLY-02: Variant data classes implement sealed interface -- + + @Test + fun `variant data class implements sealed interface`() { + val shapeSchema = + schema( + name = "Shape", + oneOf = listOf(TypeRef.Reference("Circle")), + ) + val circleSchema = + schema( + name = "Circle", + properties = listOf(PropertyModel("radius", TypeRef.Primitive(PrimitiveType.DOUBLE), null, false)), + requiredProperties = setOf("radius"), + ) + + val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema))) + val circleType = findType(files, "Circle") + + val superinterfaces = circleType.superinterfaces.keys.map { it.toString() } + assertTrue( + "$modelPackage.Shape" in superinterfaces, + "Circle should implement Shape. Superinterfaces: $superinterfaces", + ) + } + + @Test + fun `variant data class has SerialName annotation`() { + val shapeSchema = + schema( + name = "Shape", + oneOf = listOf(TypeRef.Reference("Circle")), + ) + val circleSchema = + schema( + name = "Circle", + properties = listOf(PropertyModel("radius", TypeRef.Primitive(PrimitiveType.DOUBLE), null, false)), + requiredProperties = setOf("radius"), + ) + + val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema))) + val circleType = findType(files, "Circle") + + val serialNameAnnotation = + circleType.annotations.find { + it.typeName.toString() == "kotlinx.serialization.SerialName" + } + assertNotNull(serialNameAnnotation, "Circle should have @SerialName annotation") + assertTrue( + serialNameAnnotation.members.any { it.toString().contains("\"Circle\"") }, + "Expected @SerialName(\"Circle\") (default, no discriminator mapping)", + ) + } + + // -- POLY-03: Discriminator -- + + @Test + fun `discriminated oneOf has JsonClassDiscriminator annotation`() { + val shapeSchema = + schema( + name = "Shape", + oneOf = listOf(TypeRef.Reference("Circle"), TypeRef.Reference("Square")), + discriminator = + Discriminator( + propertyName = "shapeType", + mapping = mapOf("circle" to "#/components/schemas/Circle", "square" to "#/components/schemas/Square"), + ), + ) + val circleSchema = schema(name = "Circle") + val squareSchema = schema(name = "Square") + + val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema, squareSchema))) + val shapeType = findType(files, "Shape") + + val discriminatorAnnotation = + shapeType.annotations.find { + it.typeName.toString() == "kotlinx.serialization.json.JsonClassDiscriminator" + } + assertNotNull(discriminatorAnnotation, "Shape should have @JsonClassDiscriminator") + assertTrue( + discriminatorAnnotation.members.any { it.toString().contains("\"shapeType\"") }, + "Expected @JsonClassDiscriminator(\"shapeType\")", + ) + } + + @Test + fun `discriminated variant has SerialName matching mapping key`() { + val shapeSchema = + schema( + name = "Shape", + oneOf = listOf(TypeRef.Reference("Circle")), + discriminator = + Discriminator( + propertyName = "shapeType", + mapping = mapOf("circle" to "#/components/schemas/Circle"), + ), + ) + val circleSchema = + schema( + name = "Circle", + properties = listOf(PropertyModel("radius", TypeRef.Primitive(PrimitiveType.DOUBLE), null, false)), + requiredProperties = setOf("radius"), + ) + + val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema))) + val circleType = findType(files, "Circle") + + val serialNameAnnotation = + circleType.annotations.find { + it.typeName.toString() == "kotlinx.serialization.SerialName" + } + assertNotNull(serialNameAnnotation, "Circle should have @SerialName") + assertTrue( + serialNameAnnotation.members.any { it.toString().contains("\"circle\"") }, + "Expected @SerialName(\"circle\") from discriminator mapping", + ) + } + + @Test + fun `file has OptIn for ExperimentalSerializationApi`() { + val shapeSchema = + schema( + name = "Shape", + oneOf = listOf(TypeRef.Reference("Circle")), + discriminator = + Discriminator( + propertyName = "shapeType", + mapping = mapOf("circle" to "#/components/schemas/Circle"), + ), + ) + val circleSchema = schema(name = "Circle") + + val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema))) + val shapeFile = findFile(files, "Shape") + + val optInAnnotation = + shapeFile.annotations.find { + it.typeName.toString() == "kotlin.OptIn" + } + assertNotNull(optInAnnotation, "Shape file should have @OptIn annotation") + assertTrue( + optInAnnotation.members.any { it.toString().contains("ExperimentalSerializationApi") }, + "Expected @OptIn(ExperimentalSerializationApi::class)", + ) + } + + // -- POLY-05: allOf property merging -- + + @Test + fun `allOf schema produces data class with merged properties`() { + // SpecParser merges allOf properties before ModelGenerator sees them. + // So ExtendedDog already has all properties (from Dog + inline) in its SchemaModel. + val dogSchema = + schema( + name = "Dog", + properties = + listOf( + PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), + PropertyModel("breed", TypeRef.Primitive(PrimitiveType.STRING), null, false), + ), + requiredProperties = setOf("name", "breed"), + ) + val extendedDogSchema = + schema( + name = "ExtendedDog", + properties = + listOf( + PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), + PropertyModel("breed", TypeRef.Primitive(PrimitiveType.STRING), null, false), + PropertyModel("tricks", TypeRef.Array(TypeRef.Primitive(PrimitiveType.STRING)), null, false), + ), + requiredProperties = setOf("name", "breed", "tricks"), + allOf = listOf(TypeRef.Reference("Dog")), + ) + + val files = generator.generate(spec(schemas = listOf(dogSchema, extendedDogSchema))) + val extendedDogType = findType(files, "ExtendedDog") + val constructor = assertNotNull(extendedDogType.primaryConstructor, "Expected primary constructor") + + // Should have all merged properties: name, breed from Dog + tricks from inline + val paramNames = constructor.parameters.map { it.name } + assertTrue("name" in paramNames, "Expected 'name' from Dog. Params: $paramNames") + assertTrue("breed" in paramNames, "Expected 'breed' from Dog. Params: $paramNames") + assertTrue("tricks" in paramNames, "Expected 'tricks' from inline. Params: $paramNames") + } + + @Test + fun `allOf required properties are non-nullable`() { + val dogSchema = + schema( + name = "Dog", + properties = + listOf( + PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), + PropertyModel("breed", TypeRef.Primitive(PrimitiveType.STRING), null, false), + ), + requiredProperties = setOf("name", "breed"), + ) + val extendedDogSchema = + schema( + name = "ExtendedDog", + properties = + listOf( + PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), + PropertyModel("breed", TypeRef.Primitive(PrimitiveType.STRING), null, false), + PropertyModel("tricks", TypeRef.Array(TypeRef.Primitive(PrimitiveType.STRING)), null, false), + ), + requiredProperties = setOf("name", "breed", "tricks"), + allOf = listOf(TypeRef.Reference("Dog")), + ) + + val files = generator.generate(spec(schemas = listOf(dogSchema, extendedDogSchema))) + val extendedDogType = findType(files, "ExtendedDog") + val constructor = assertNotNull(extendedDogType.primaryConstructor) + + for (param in constructor.parameters) { + assertTrue( + !param.type.isNullable, + "Required property '${param.name}' should be non-nullable", + ) + } + } + + // -- POLY-07: oneOf with wrapper objects -- + + @Test + fun `oneOf with wrapper objects generates sealed interface with JsonClassDiscriminator`() { + // Create wrapper schema like AWS CloudControl's NetworkMeshDevice + val extenderPropsSchema = + schema( + name = "ExtenderDeviceProperties", + properties = listOf(PropertyModel("deviceId", TypeRef.Primitive(PrimitiveType.STRING), null, false)), + requiredProperties = setOf("deviceId"), + ) + val ethernetPropsSchema = + schema( + name = "EthernetDeviceProperties", + properties = listOf(PropertyModel("macAddress", TypeRef.Primitive(PrimitiveType.STRING), null, false)), + requiredProperties = setOf("macAddress"), + ) + + // Parent schema with oneOf pointing to wrapper variants + // Note: This test verifies the SpecParser has already unwrapped, so we pass the unwrapped form + val networkMeshSchema = + schema( + name = "NetworkMeshDevice", + oneOf = + listOf( + TypeRef.Reference("ExtenderDeviceProperties"), + TypeRef.Reference("EthernetDeviceProperties"), + ), + discriminator = + Discriminator( + propertyName = "type", + mapping = + mapOf( + "ExtenderDevice" to "#/components/schemas/ExtenderDeviceProperties", + "EthernetDevice" to "#/components/schemas/EthernetDeviceProperties", + ), + ), + ) + + val files = generator.generate(spec(schemas = listOf(networkMeshSchema, extenderPropsSchema, ethernetPropsSchema))) + val networkMeshType = findType(files, "NetworkMeshDevice") + + // Verify sealed interface with discriminator + assertTrue(KModifier.SEALED in networkMeshType.modifiers) + val discriminatorAnnotation = + networkMeshType.annotations.find { + it.typeName.toString() == "kotlinx.serialization.json.JsonClassDiscriminator" + } + assertNotNull(discriminatorAnnotation) + assertTrue(discriminatorAnnotation.members.any { it.toString().contains("\"type\"") }) + } + + @Test + fun `oneOf with wrapper objects generates correct SerialName on variants`() { + // Same setup as above + val extenderPropsSchema = + schema( + name = "ExtenderDeviceProperties", + properties = listOf(PropertyModel("deviceId", TypeRef.Primitive(PrimitiveType.STRING), null, false)), + requiredProperties = setOf("deviceId"), + ) + + val networkMeshSchema = + schema( + name = "NetworkMeshDevice", + oneOf = listOf(TypeRef.Reference("ExtenderDeviceProperties")), + discriminator = + Discriminator( + propertyName = "type", + mapping = mapOf("ExtenderDevice" to "#/components/schemas/ExtenderDeviceProperties"), + ), + ) + + val files = generator.generate(spec(schemas = listOf(networkMeshSchema, extenderPropsSchema))) + val extenderType = findType(files, "ExtenderDeviceProperties") + + // Verify @SerialName uses wrapper property name + val serialNameAnnotation = + extenderType.annotations.find { + it.typeName.toString() == "kotlinx.serialization.SerialName" + } + assertNotNull(serialNameAnnotation, "Variant should have @SerialName") + assertTrue( + serialNameAnnotation.members.any { it.toString().contains("\"ExtenderDevice\"") }, + "Expected @SerialName(\"ExtenderDevice\") from wrapper property name", + ) + } + + // -- POLY-08: anyOf without discriminator -> JsonContentPolymorphicSerializer -- + + @Test + fun `anyOf without discriminator generates sealed interface with Serializable(with) annotation`() { + val unionSchema = schema( + name = "Payment", + anyOf = listOf(TypeRef.Reference("CreditCard"), TypeRef.Reference("BankTransfer")), + ) + val creditCardSchema = schema( + name = "CreditCard", + properties = listOf(PropertyModel("cardNumber", TypeRef.Primitive(PrimitiveType.STRING), null, false)), + requiredProperties = setOf("cardNumber"), + ) + val bankTransferSchema = schema( + name = "BankTransfer", + properties = listOf(PropertyModel("accountNumber", TypeRef.Primitive(PrimitiveType.STRING), null, false)), + requiredProperties = setOf("accountNumber"), + ) + + val files = generator.generate(spec(schemas = listOf(unionSchema, creditCardSchema, bankTransferSchema))) + val paymentType = findType(files, "Payment") + + val serializableAnnotation = paymentType.annotations.find { + it.typeName.toString() == "kotlinx.serialization.Serializable" + } + assertNotNull(serializableAnnotation, "Payment should have @Serializable annotation") + assertTrue( + serializableAnnotation.members.any { it.toString().contains("PaymentSerializer") }, + "Expected @Serializable(with = PaymentSerializer::class), got: ${serializableAnnotation.members}", + ) + } + + @Test + fun `anyOf without discriminator generates JsonContentPolymorphicSerializer object`() { + val unionSchema = schema( + name = "Payment", + anyOf = listOf(TypeRef.Reference("CreditCard"), TypeRef.Reference("BankTransfer")), + ) + val creditCardSchema = schema( + name = "CreditCard", + properties = listOf(PropertyModel("cardNumber", TypeRef.Primitive(PrimitiveType.STRING), null, false)), + requiredProperties = setOf("cardNumber"), + ) + val bankTransferSchema = schema( + name = "BankTransfer", + properties = listOf(PropertyModel("accountNumber", TypeRef.Primitive(PrimitiveType.STRING), null, false)), + requiredProperties = setOf("accountNumber"), + ) + + val files = generator.generate(spec(schemas = listOf(unionSchema, creditCardSchema, bankTransferSchema))) + val serializerType = findType(files, "PaymentSerializer") + + assertEquals(TypeSpec.Kind.OBJECT, serializerType.kind, "PaymentSerializer should be an object") + val superclass = serializerType.superclass.toString() + assertTrue( + "JsonContentPolymorphicSerializer" in superclass, + "PaymentSerializer should extend JsonContentPolymorphicSerializer. Superclass: $superclass", + ) + } + + @Test + fun `anyOf without discriminator serializer selectDeserializer uses unique fields`() { + val unionSchema = schema( + name = "Payment", + anyOf = listOf(TypeRef.Reference("CreditCard"), TypeRef.Reference("BankTransfer")), + ) + val creditCardSchema = schema( + name = "CreditCard", + properties = listOf(PropertyModel("cardNumber", TypeRef.Primitive(PrimitiveType.STRING), null, false)), + requiredProperties = setOf("cardNumber"), + ) + val bankTransferSchema = schema( + name = "BankTransfer", + properties = listOf(PropertyModel("accountNumber", TypeRef.Primitive(PrimitiveType.STRING), null, false)), + requiredProperties = setOf("accountNumber"), + ) + + val files = generator.generate(spec(schemas = listOf(unionSchema, creditCardSchema, bankTransferSchema))) + val serializerType = findType(files, "PaymentSerializer") + val selectDeserializer = serializerType.funSpecs.find { it.name == "selectDeserializer" } + assertNotNull(selectDeserializer, "PaymentSerializer should have selectDeserializer function") + + val bodyCode = selectDeserializer.body.toString() + assertTrue( + "cardNumber" in bodyCode, + "selectDeserializer should check for 'cardNumber' unique field. Body: $bodyCode", + ) + assertTrue( + "accountNumber" in bodyCode, + "selectDeserializer should check for 'accountNumber' unique field. Body: $bodyCode", + ) + } + + @Test + fun `anyOf without discriminator with overlapping fields generates TODO body`() { + val unionSchema = schema( + name = "Payment", + anyOf = listOf(TypeRef.Reference("TypeA"), TypeRef.Reference("TypeB")), + ) + // Both variants share the same field "amount" - no unique fields + val typeASchema = schema( + name = "TypeA", + properties = listOf(PropertyModel("amount", TypeRef.Primitive(PrimitiveType.DOUBLE), null, false)), + requiredProperties = setOf("amount"), + ) + val typeBSchema = schema( + name = "TypeB", + properties = listOf(PropertyModel("amount", TypeRef.Primitive(PrimitiveType.DOUBLE), null, false)), + requiredProperties = setOf("amount"), + ) + + val files = generator.generate(spec(schemas = listOf(unionSchema, typeASchema, typeBSchema))) + val serializerType = findType(files, "PaymentSerializer") + val selectDeserializer = serializerType.funSpecs.find { it.name == "selectDeserializer" } + assertNotNull(selectDeserializer, "PaymentSerializer should have selectDeserializer function") + + val bodyCode = selectDeserializer.body.toString() + assertTrue( + "TODO" in bodyCode || "manual" in bodyCode.lowercase(), + "selectDeserializer should contain TODO for overlapping fields. Body: $bodyCode", + ) + } + + @Test + fun `anyOf without discriminator variant subclasses retain their own Serializable annotation`() { + val unionSchema = schema( + name = "Payment", + anyOf = listOf(TypeRef.Reference("CreditCard"), TypeRef.Reference("BankTransfer")), + ) + val creditCardSchema = schema( + name = "CreditCard", + properties = listOf(PropertyModel("cardNumber", TypeRef.Primitive(PrimitiveType.STRING), null, false)), + requiredProperties = setOf("cardNumber"), + ) + val bankTransferSchema = schema( + name = "BankTransfer", + properties = listOf(PropertyModel("accountNumber", TypeRef.Primitive(PrimitiveType.STRING), null, false)), + requiredProperties = setOf("accountNumber"), + ) + + val files = generator.generate(spec(schemas = listOf(unionSchema, creditCardSchema, bankTransferSchema))) + val creditCardType = findType(files, "CreditCard") + + val annotations = creditCardType.annotations.map { it.typeName.toString() } + assertTrue( + "kotlinx.serialization.Serializable" in annotations, + "CreditCard variant should still have @Serializable. Annotations: $annotations", + ) + } + + @Test + fun `anyOf with discriminator NOT affected by JsonContentPolymorphicSerializer path`() { + // Ensure the discriminator-present anyOf still uses the old SerializersModule path + val shapeSchema = schema( + name = "Shape", + anyOf = listOf(TypeRef.Reference("Circle"), TypeRef.Reference("Square")), + discriminator = Discriminator( + propertyName = "shapeType", + mapping = mapOf("circle" to "#/components/schemas/Circle", "square" to "#/components/schemas/Square"), + ), + ) + val circleSchema = schema(name = "Circle") + val squareSchema = schema(name = "Square") + + val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema, squareSchema))) + val shapeType = findType(files, "Shape") + + // Should have plain @Serializable, NOT @Serializable(with = ...) + val serializableAnnotation = shapeType.annotations.find { + it.typeName.toString() == "kotlinx.serialization.Serializable" + } + assertNotNull(serializableAnnotation, "Shape should have @Serializable") + assertTrue( + serializableAnnotation.members.isEmpty(), + "Discriminated anyOf should use plain @Serializable, not @Serializable(with = ...). Members: ${serializableAnnotation.members}", + ) + + // ShapeSerializer should NOT be generated + val serializerTypes = files.flatMap { it.members.filterIsInstance() } + val shapeSerializerType = serializerTypes.find { it.name == "ShapeSerializer" } + assertEquals(null, shapeSerializerType, "Discriminated anyOf should NOT generate a JsonContentPolymorphicSerializer") + } + + // -- POLY-06: allOf with sealed parent -- + + @Test + fun `allOf referencing oneOf parent adds superinterface`() { + val petSchema = + schema( + name = "Pet", + oneOf = listOf(TypeRef.Reference("Dog")), + ) + val dogSchema = + schema( + name = "Dog", + properties = + listOf( + PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), + ), + requiredProperties = setOf("name"), + allOf = listOf(TypeRef.Reference("Pet")), + ) + + val files = generator.generate(spec(schemas = listOf(petSchema, dogSchema))) + val dogType = findType(files, "Dog") + + val superinterfaces = dogType.superinterfaces.keys.map { it.toString() } + assertTrue( + "$modelPackage.Pet" in superinterfaces, + "Dog should have Pet as superinterface. Superinterfaces: $superinterfaces", + ) + } +} 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 new file mode 100644 index 00000000..238d15cb --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorTest.kt @@ -0,0 +1,1055 @@ +package com.avsystem.justworks.core.gen + +import com.avsystem.justworks.core.model.ApiSpec +import com.avsystem.justworks.core.model.EnumBackingType +import com.avsystem.justworks.core.model.EnumModel +import com.avsystem.justworks.core.model.PrimitiveType +import com.avsystem.justworks.core.model.PropertyModel +import com.avsystem.justworks.core.model.SchemaModel +import com.avsystem.justworks.core.model.TypeRef +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.ParameterizedTypeName +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ModelGeneratorTest { + private val modelPackage = "com.example.model" + private val generator = ModelGenerator(modelPackage) + + private fun spec( + schemas: List = emptyList(), + enums: List = emptyList(), + ) = ApiSpec( + title = "Test", + version = "1.0", + endpoints = emptyList(), + schemas = schemas, + enums = enums, + ) + + private val petSchema = + SchemaModel( + name = "Pet", + description = "A pet in the store", + properties = + listOf( + PropertyModel("id", TypeRef.Primitive(PrimitiveType.LONG), null, false), + PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), + PropertyModel("tag", TypeRef.Primitive(PrimitiveType.STRING), null, true), + ), + requiredProperties = setOf("id", "name"), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + + // -- Data class generation (MODL-01 through MODL-06) -- + + @Test + fun `generates data class with DATA modifier`() { + val files = generator.generate(spec(schemas = listOf(petSchema))) + assertEquals(1, files.size) + val typeSpec = + files + .first() + .members + .filterIsInstance() + .first() + assertTrue(KModifier.DATA in typeSpec.modifiers, "Expected DATA modifier") + } + + @Test + fun `generates class with Serializable annotation`() { + val files = generator.generate(spec(schemas = listOf(petSchema))) + val typeSpec = + files + .first() + .members + .filterIsInstance() + .first() + val annotations = typeSpec.annotations.map { it.typeName.toString() } + assertTrue("kotlinx.serialization.Serializable" in annotations, "Expected @Serializable") + } + + @Test + fun `required property is non-nullable in constructor`() { + val files = generator.generate(spec(schemas = listOf(petSchema))) + val typeSpec = + files + .first() + .members + .filterIsInstance() + .first() + val constructor = assertNotNull(typeSpec.primaryConstructor, "Expected primary constructor") + val idParam = constructor.parameters.first { it.name == "id" } + assertTrue(!idParam.type.isNullable, "Required property 'id' should be non-nullable") + } + + @Test + fun `optional property is nullable with default null`() { + val files = generator.generate(spec(schemas = listOf(petSchema))) + val typeSpec = + files + .first() + .members + .filterIsInstance() + .first() + val constructor = assertNotNull(typeSpec.primaryConstructor) + val tagParam = constructor.parameters.first { it.name == "tag" } + assertTrue(tagParam.type.isNullable, "Optional property 'tag' should be nullable") + assertEquals("null", tagParam.defaultValue.toString()) + } + + @Test + fun `every property has SerialName annotation with wire name`() { + val files = generator.generate(spec(schemas = listOf(petSchema))) + val typeSpec = + files + .first() + .members + .filterIsInstance() + .first() + for (prop in typeSpec.propertySpecs) { + val serialName = + prop.annotations.firstOrNull { + it.typeName.toString() == "kotlinx.serialization.SerialName" + } + assertNotNull(serialName, "Property '${prop.name}' should have @SerialName") + } + } + + @Test + fun `snake_case property becomes camelCase with SerialName preserving wire name`() { + val schema = + SchemaModel( + name = "Item", + description = null, + properties = + listOf( + PropertyModel("created_at", TypeRef.Primitive(PrimitiveType.STRING), null, false), + ), + requiredProperties = setOf("created_at"), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generator.generate(spec(schemas = listOf(schema))) + val typeSpec = + files + .first() + .members + .filterIsInstance() + .first() + val prop = typeSpec.propertySpecs.first() + assertEquals("createdAt", prop.name) + val serialNameAnnotation = + prop.annotations.first { + it.typeName.toString() == "kotlinx.serialization.SerialName" + } + assertTrue( + serialNameAnnotation.members.any { it.toString().contains("\"created_at\"") }, + "Expected @SerialName(\"created_at\")", + ) + } + + @Test + fun `schema with description produces KDoc`() { + val files = generator.generate(spec(schemas = listOf(petSchema))) + val typeSpec = + files + .first() + .members + .filterIsInstance() + .first() + assertTrue(typeSpec.kdoc.toString().contains("A pet in the store"), "Expected KDoc from description") + } + + @Test + fun `property with Reference type produces ClassName in correct package`() { + val schema = + SchemaModel( + name = "Order", + description = null, + properties = + listOf( + PropertyModel("pet", TypeRef.Reference("Pet"), null, false), + ), + requiredProperties = setOf("pet"), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generator.generate(spec(schemas = listOf(schema))) + val typeSpec = + files + .first() + .members + .filterIsInstance() + .first() + val petProp = typeSpec.propertySpecs.first { it.name == "pet" } + assertEquals("com.example.model.Pet", petProp.type.toString()) + } + + @Test + fun `generate produces one FileSpec per schema`() { + val schema2 = petSchema.copy(name = "Category", description = null) + val files = generator.generate(spec(schemas = listOf(petSchema, schema2))) + assertEquals(2, files.size) + } + + @Test + fun `required properties ordered before optional in constructor`() { + val files = generator.generate(spec(schemas = listOf(petSchema))) + val typeSpec = + files + .first() + .members + .filterIsInstance() + .first() + val constructor = assertNotNull(typeSpec.primaryConstructor) + val paramNames = constructor.parameters.map { it.name } + // id and name are required, tag is optional -> tag should be last + assertEquals(listOf("id", "name", "tag"), paramNames) + } + + // -- Enum generation (MODL-07 through MODL-09) -- + + private val statusEnum = + EnumModel( + name = "PetStatus", + description = null, + type = EnumBackingType.STRING, + values = listOf("available", "pending", "sold"), + ) + + @Test + fun `string enum has Serializable annotation`() { + val files = generator.generate(spec(enums = listOf(statusEnum))) + val typeSpec = + files + .first() + .members + .filterIsInstance() + .first() + val annotations = typeSpec.annotations.map { it.typeName.toString() } + assertTrue("kotlinx.serialization.Serializable" in annotations, "Expected @Serializable on enum") + } + + @Test + fun `string enum constants have SerialName with wire value`() { + val files = generator.generate(spec(enums = listOf(statusEnum))) + val typeSpec = + files + .first() + .members + .filterIsInstance() + .first() + assertEquals(3, typeSpec.enumConstants.size) + for ((name, spec) in typeSpec.enumConstants) { + val serialName = + spec.annotations.firstOrNull { + it.typeName.toString() == "kotlinx.serialization.SerialName" + } + assertNotNull(serialName, "Enum constant '$name' should have @SerialName") + } + } + + @Test + fun `enum constant names are UPPER_SNAKE_CASE`() { + val files = generator.generate(spec(enums = listOf(statusEnum))) + val typeSpec = + files + .first() + .members + .filterIsInstance() + .first() + val names = typeSpec.enumConstants.keys.toList() + assertEquals(listOf("AVAILABLE", "PENDING", "SOLD"), names) + } + + @Test + fun `integer enum values have SerialName with numeric string`() { + val intEnum = + EnumModel( + name = "Priority", + description = null, + type = EnumBackingType.INTEGER, + values = listOf("1", "2", "3"), + ) + val files = generator.generate(spec(enums = listOf(intEnum))) + val typeSpec = + files + .first() + .members + .filterIsInstance() + .first() + val constants = typeSpec.enumConstants.entries.toList() + assertEquals("VALUE_1", constants[0].key) + assertEquals("VALUE_2", constants[1].key) + assertEquals("VALUE_3", constants[2].key) + // Check @SerialName values + for ((i, entry) in constants.withIndex()) { + val serialName = + entry.value.annotations.first { + it.typeName.toString() == "kotlinx.serialization.SerialName" + } + assertTrue( + serialName.members.any { it.toString().contains("\"${i + 1}\"") }, + "Expected @SerialName(\"${i + 1}\") on ${entry.key}", + ) + } + } + + // -- Integration -- + + @Test + fun `generate returns FileSpecs for schemas and enums combined`() { + val schema2 = petSchema.copy(name = "Category", description = null) + val files = generator.generate(spec(schemas = listOf(petSchema, schema2), enums = listOf(statusEnum))) + assertEquals(3, files.size) + } + + @Test + fun `all FileSpecs have correct package name`() { + val files = generator.generate(spec(schemas = listOf(petSchema), enums = listOf(statusEnum))) + for (file in files) { + assertEquals(modelPackage, file.packageName) + } + } + + // -- ANYF-01 through ANYF-05: anyOf support -- + + @Test + fun `anyOf schema generates sealed interface with SEALED modifier`() { + val paymentSchema = + SchemaModel( + name = "Payment", + description = null, + properties = emptyList(), + requiredProperties = emptySet(), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = listOf(TypeRef.Reference("CreditCard"), TypeRef.Reference("BankTransfer")), + discriminator = null, + ) + val creditCardSchema = + SchemaModel( + name = "CreditCard", + description = null, + properties = listOf(PropertyModel("cardNumber", TypeRef.Primitive(PrimitiveType.STRING), null, false)), + requiredProperties = setOf("cardNumber"), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + + val files = generator.generate(spec(schemas = listOf(paymentSchema, creditCardSchema))) + val paymentType = + files + .first { it.name == "Payment" } + .members + .filterIsInstance() + .first() + + assertTrue(KModifier.SEALED in paymentType.modifiers, "Expected SEALED modifier on Payment") + assertEquals(com.squareup.kotlinpoet.TypeSpec.Kind.INTERFACE, paymentType.kind, "Expected INTERFACE kind") + } + + @Test + fun `anyOf variants have SerialName annotation`() { + val paymentSchema = + SchemaModel( + name = "Payment", + description = null, + properties = emptyList(), + requiredProperties = emptySet(), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = listOf(TypeRef.Reference("CreditCard")), + discriminator = null, + ) + val creditCardSchema = + SchemaModel( + name = "CreditCard", + description = null, + properties = listOf(PropertyModel("cardNumber", TypeRef.Primitive(PrimitiveType.STRING), null, false)), + requiredProperties = setOf("cardNumber"), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + + val files = generator.generate(spec(schemas = listOf(paymentSchema, creditCardSchema))) + val creditCardType = + files + .first { it.name == "CreditCard" } + .members + .filterIsInstance() + .first() + + val serialNameAnnotation = + creditCardType.annotations.find { + it.typeName.toString() == "kotlinx.serialization.SerialName" + } + assertNotNull(serialNameAnnotation, "CreditCard should have @SerialName annotation") + } + + @Test + fun `anyOf with discriminator has JsonClassDiscriminator annotation`() { + val paymentSchema = + SchemaModel( + name = "Payment", + description = null, + properties = emptyList(), + requiredProperties = emptySet(), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = listOf(TypeRef.Reference("CreditCard")), + discriminator = + com.avsystem.justworks.core.model.Discriminator( + propertyName = "paymentType", + mapping = mapOf("card" to "#/components/schemas/CreditCard"), + ), + ) + val creditCardSchema = + SchemaModel( + name = "CreditCard", + description = null, + properties = listOf(PropertyModel("cardNumber", TypeRef.Primitive(PrimitiveType.STRING), null, false)), + requiredProperties = setOf("cardNumber"), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + + val files = generator.generate(spec(schemas = listOf(paymentSchema, creditCardSchema))) + val paymentType = + files + .first { it.name == "Payment" } + .members + .filterIsInstance() + .first() + + val discriminatorAnnotation = + paymentType.annotations.find { + it.typeName.toString() == "kotlinx.serialization.json.JsonClassDiscriminator" + } + assertNotNull(discriminatorAnnotation, "Payment should have @JsonClassDiscriminator") + assertTrue( + discriminatorAnnotation.members.any { it.toString().contains("\"paymentType\"") }, + "Expected @JsonClassDiscriminator(\"paymentType\")", + ) + } + + @Test + fun `anyOf variants registered in sealedHierarchies map`() { + val paymentSchema = + SchemaModel( + name = "Payment", + description = null, + properties = emptyList(), + requiredProperties = emptySet(), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = listOf(TypeRef.Reference("CreditCard"), TypeRef.Reference("BankTransfer")), + discriminator = null, + ) + val creditCardSchema = + SchemaModel( + name = "CreditCard", + description = null, + properties = listOf(PropertyModel("cardNumber", TypeRef.Primitive(PrimitiveType.STRING), null, false)), + requiredProperties = setOf("cardNumber"), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val bankTransferSchema = + SchemaModel( + name = "BankTransfer", + description = null, + properties = listOf( + PropertyModel("accountNumber", TypeRef.Primitive(PrimitiveType.STRING), null, false), + ), + requiredProperties = setOf("accountNumber"), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + + generator.generate(spec(schemas = listOf(paymentSchema, creditCardSchema, bankTransferSchema))) + val hierarchies = generator.getSealedHierarchies() + + assertTrue("Payment" in hierarchies, "Payment should be in sealedHierarchies map") + assertEquals( + listOf("CreditCard", "BankTransfer"), + hierarchies["Payment"], + "Payment should have CreditCard and BankTransfer as variants", + ) + } + + // -- Default value tests (DFLT-01 through DFLT-05) -- + + @Test + fun `string default generates default parameter with quoted string`() { + val schema = + SchemaModel( + name = "Config", + description = null, + properties = + listOf( + PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false, "default-name"), + ), + requiredProperties = setOf("name"), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generator.generate(spec(schemas = listOf(schema))) + val typeSpec = + files + .first() + .members + .filterIsInstance() + .first() + val constructor = assertNotNull(typeSpec.primaryConstructor) + val param = constructor.parameters.first { it.name == "name" } + assertEquals("\"default-name\"", param.defaultValue.toString()) + } + + @Test + fun `numeric default generates default parameter with literal`() { + val schema = + SchemaModel( + name = "Config", + description = null, + properties = + listOf( + PropertyModel("age", TypeRef.Primitive(PrimitiveType.INT), null, false, 42), + PropertyModel("price", TypeRef.Primitive(PrimitiveType.DOUBLE), null, false, 19.99), + ), + requiredProperties = setOf("age", "price"), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generator.generate(spec(schemas = listOf(schema))) + val typeSpec = + files + .first() + .members + .filterIsInstance() + .first() + val constructor = assertNotNull(typeSpec.primaryConstructor) + val ageParam = constructor.parameters.first { it.name == "age" } + assertEquals("42", ageParam.defaultValue.toString()) + val priceParam = constructor.parameters.first { it.name == "price" } + assertEquals("19.99", priceParam.defaultValue.toString()) + } + + @Test + fun `boolean default generates default parameter with literal`() { + val schema = + SchemaModel( + name = "Config", + description = null, + properties = + listOf( + PropertyModel("active", TypeRef.Primitive(PrimitiveType.BOOLEAN), null, false, true), + ), + requiredProperties = setOf("active"), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generator.generate(spec(schemas = listOf(schema))) + val typeSpec = + files + .first() + .members + .filterIsInstance() + .first() + val constructor = assertNotNull(typeSpec.primaryConstructor) + val param = constructor.parameters.first { it.name == "active" } + assertEquals("true", param.defaultValue.toString()) + } + + @Test + fun `date-time default generates Instant parse call`() { + val schema = + SchemaModel( + name = "Event", + description = null, + properties = + listOf( + PropertyModel( + "createdAt", + TypeRef.Primitive(PrimitiveType.DATE_TIME), + null, + false, + "2024-01-01T00:00:00Z", + ), + ), + requiredProperties = setOf("createdAt"), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generator.generate(spec(schemas = listOf(schema))) + val typeSpec = + files + .first() + .members + .filterIsInstance() + .first() + val constructor = assertNotNull(typeSpec.primaryConstructor) + val param = constructor.parameters.first { it.name == "createdAt" } + assertTrue( + param.defaultValue.toString().contains("Instant.parse"), + "Expected Instant.parse() call, got: ${param.defaultValue}", + ) + assertTrue( + param.defaultValue.toString().contains("2024-01-01T00:00:00Z"), + "Expected date-time string in default value", + ) + } + + @Test + fun `date default generates LocalDate parse call`() { + val schema = + SchemaModel( + name = "Event", + description = null, + properties = + listOf( + PropertyModel("eventDate", TypeRef.Primitive(PrimitiveType.DATE), null, false, "2024-01-01"), + ), + requiredProperties = setOf("eventDate"), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generator.generate(spec(schemas = listOf(schema))) + val typeSpec = + files + .first() + .members + .filterIsInstance() + .first() + val constructor = assertNotNull(typeSpec.primaryConstructor) + val param = constructor.parameters.first { it.name == "eventDate" } + assertTrue( + param.defaultValue.toString().contains("kotlinx.datetime.LocalDate.parse"), + "Expected LocalDate.parse() call, got: ${param.defaultValue}", + ) + assertTrue(param.defaultValue.toString().contains("2024-01-01"), "Expected date string in default value") + } + + @Test + fun `properties with defaults ordered after required properties without defaults`() { + val schema = + SchemaModel( + name = "Config", + description = null, + properties = + listOf( + PropertyModel("optional", TypeRef.Primitive(PrimitiveType.STRING), null, true, null), + PropertyModel("withDefault", TypeRef.Primitive(PrimitiveType.STRING), null, false, "default"), + PropertyModel("required", TypeRef.Primitive(PrimitiveType.STRING), null, false, null), + ), + requiredProperties = setOf("required", "withDefault"), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generator.generate(spec(schemas = listOf(schema))) + val typeSpec = + files + .first() + .members + .filterIsInstance() + .first() + val constructor = assertNotNull(typeSpec.primaryConstructor) + val paramNames = constructor.parameters.map { it.name } + // Expected order: required (no default), withDefault (has default), optional (nullable) + assertEquals(listOf("required", "withDefault", "optional"), paramNames) + } + + @Test + fun `nullable property with default uses null default ignoring OpenAPI default`() { + val schema = + SchemaModel( + name = "Config", + description = null, + properties = + listOf( + PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, true, "ignored-default"), + ), + requiredProperties = emptySet(), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generator.generate(spec(schemas = listOf(schema))) + val typeSpec = + files + .first() + .members + .filterIsInstance() + .first() + val constructor = assertNotNull(typeSpec.primaryConstructor) + val param = constructor.parameters.first { it.name == "name" } + assertTrue(param.type.isNullable, "Property should be nullable") + assertEquals("null", param.defaultValue.toString(), "Nullable property should use null default") + } + + @Test + fun `enum default generates enum constant reference`() { + val statusEnum = + EnumModel( + name = "Status", + description = null, + type = EnumBackingType.STRING, + values = listOf("active", "pending", "closed"), + ) + val schema = + SchemaModel( + name = "Task", + description = null, + properties = + listOf( + PropertyModel("status", TypeRef.Reference("Status"), null, false, "active"), + ), + requiredProperties = setOf("status"), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generator.generate(spec(schemas = listOf(schema), enums = listOf(statusEnum))) + val typeSpec = + files + .first { it.name == "Task" } + .members + .filterIsInstance() + .first() + val constructor = assertNotNull(typeSpec.primaryConstructor) + val param = constructor.parameters.first { it.name == "status" } + assertTrue( + param.defaultValue.toString().contains("Status.ACTIVE"), + "Expected Status.ACTIVE, got: ${param.defaultValue}", + ) + } + + // -- Primitive-only type alias tests -- + + @Test + fun `primitive only schema generates type alias`() { + val groupIdSchema = SchemaModel( + name = "GroupId", + description = null, + properties = emptyList(), + requiredProperties = emptySet(), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generator.generate(spec(schemas = listOf(groupIdSchema))) + assertEquals(1, files.size) + + val file = files.first() + assertEquals("GroupId", file.name) + + // Verify it contains a TypeAliasSpec, not a TypeSpec + val typeAliases = file.members.filterIsInstance() + assertEquals(1, typeAliases.size, "Expected one type alias") + + val typeAlias = typeAliases.first() + assertEquals("GroupId", typeAlias.name) + } + + @Test + fun `primitive only schema with description generates type alias with kdoc`() { + val userIdSchema = SchemaModel( + name = "UserId", + description = "Unique identifier for a user", + properties = emptyList(), + requiredProperties = emptySet(), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generator.generate(spec(schemas = listOf(userIdSchema))) + val typeAlias = files + .first() + .members + .filterIsInstance() + .first() + + assertTrue( + typeAlias.kdoc.toString().contains("Unique identifier for a user"), + "Expected KDoc from description", + ) + } + + @Test + fun `schema with properties generates data class not type alias`() { + // Verify existing behavior unchanged - schemas with properties still get data classes + val files = generator.generate(spec(schemas = listOf(petSchema))) + + val typeSpecs = files.first().members.filterIsInstance() + val typeAliases = files.first().members.filterIsInstance() + + assertEquals(1, typeSpecs.size, "Schema with properties should generate TypeSpec") + assertEquals(0, typeAliases.size, "Schema with properties should not generate TypeAliasSpec") + } + + // -- SER-03: Kotlin keyword escaping -- + + @Test + fun `property named with Kotlin keyword generates backtick-escaped name with correct SerialName`() { + val schema = SchemaModel( + name = "Item", + description = null, + properties = listOf( + PropertyModel("object", TypeRef.Primitive(PrimitiveType.STRING), null, false), + ), + requiredProperties = setOf("object"), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generator.generate(spec(schemas = listOf(schema))) + val typeSpec = files + .first() + .members + .filterIsInstance() + .first() + + // Property should be backtick-escaped + val prop = typeSpec.propertySpecs.first() + assertEquals("`object`", prop.name, "Property named 'object' should be backtick-escaped") + + // @SerialName should still use the original wire name + val serialName = prop.annotations.first { it.typeName.toString() == "kotlinx.serialization.SerialName" } + assertTrue( + serialName.members.any { it.toString().contains("\"object\"") }, + "Expected @SerialName(\"object\") for wire name", + ) + } + + @Test + fun `property named with Kotlin keyword 'in' generates backtick-escaped name`() { + val schema = SchemaModel( + name = "Filter", + description = null, + properties = listOf( + PropertyModel("in", TypeRef.Primitive(PrimitiveType.STRING), null, true), + ), + requiredProperties = emptySet(), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generator.generate(spec(schemas = listOf(schema))) + val typeSpec = files + .first() + .members + .filterIsInstance() + .first() + val prop = typeSpec.propertySpecs.first() + assertEquals("`in`", prop.name, "Property named 'in' should be backtick-escaped") + } + + // -- ROB-01: Circular schema visited-set guard -- + + @Test + fun `collectInlineTypeRefs with circular TypeRef does not stack overflow`() { + // Create a circular inline TypeRef: TreeNode with a property 'children' of type Array + // We use a self-referential arrangement via a property list + val treeNodeInline = TypeRef.Inline( + properties = emptyList(), // We'll override with a property referencing itself below + requiredProperties = emptySet(), + contextHint = "treeNode", + ) + + // Build a schema model containing a property that uses a nested Inline + // (circular structure: inline -> property -> same inline structure) + val selfReferencingInline = TypeRef.Inline( + properties = listOf( + PropertyModel("children", TypeRef.Array(treeNodeInline), 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"), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + + // Should complete without StackOverflowError + val files = generator.generate(spec(schemas = listOf(schema))) + assertNotNull(files, "generate should return results without StackOverflowError") + } + + // -- SER-02: Nullable/optional property defaults regression tests -- + + @Test + fun `non-required property without default generates as nullable with null default`() { + val schema = SchemaModel( + name = "User", + description = null, + properties = listOf( + PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), + PropertyModel("nickname", TypeRef.Primitive(PrimitiveType.STRING), null, true), + ), + requiredProperties = setOf("name"), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generator.generate(spec(schemas = listOf(schema))) + val typeSpec = files + .first() + .members + .filterIsInstance() + .first() + val constructor = assertNotNull(typeSpec.primaryConstructor) + + val nicknameParam = constructor.parameters.first { it.name == "nickname" } + assertTrue(nicknameParam.type.isNullable, "Non-required property should be nullable") + assertEquals("null", nicknameParam.defaultValue.toString(), "Non-required property should have = null default") + + val nameParam = constructor.parameters.first { it.name == "name" } + assertTrue(!nameParam.type.isNullable, "Required property should be non-nullable") + assertEquals(null, nameParam.defaultValue, "Required property should have no default") + } + + @Test + fun `allOf merged non-required property generates as nullable with null default`() { + val baseSchema = SchemaModel( + name = "Base", + description = null, + properties = listOf( + PropertyModel("id", TypeRef.Primitive(PrimitiveType.LONG), null, false), + ), + requiredProperties = setOf("id"), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val composedSchema = SchemaModel( + name = "Child", + description = null, + properties = listOf( + PropertyModel("id", TypeRef.Primitive(PrimitiveType.LONG), null, false), + PropertyModel("optionalField", TypeRef.Primitive(PrimitiveType.STRING), null, true), + ), + requiredProperties = setOf("id"), + isEnum = false, + allOf = listOf(TypeRef.Reference("Base")), + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generator.generate(spec(schemas = listOf(baseSchema, composedSchema))) + val childFile = files.first { it.name == "Child" } + val typeSpec = childFile.members.filterIsInstance().first() + val constructor = assertNotNull(typeSpec.primaryConstructor) + + val optionalParam = constructor.parameters.first { it.name == "optionalField" } + assertTrue(optionalParam.type.isNullable, "Non-required allOf property should be nullable") + assertEquals( + "null", + optionalParam.defaultValue.toString(), + "Non-required allOf property should have = null default", + ) + } + + @Test + fun `required property in allOf schema generates as non-nullable without default`() { + val composedSchema = SchemaModel( + name = "Child", + description = null, + properties = listOf( + PropertyModel("id", TypeRef.Primitive(PrimitiveType.LONG), null, false), + PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), + ), + requiredProperties = setOf("id", "name"), + isEnum = false, + allOf = listOf(TypeRef.Reference("Base")), + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generator.generate(spec(schemas = listOf(composedSchema))) + val childFile = files.first { it.name == "Child" } + val typeSpec = childFile.members.filterIsInstance().first() + val constructor = assertNotNull(typeSpec.primaryConstructor) + + val idParam = constructor.parameters.first { it.name == "id" } + assertTrue(!idParam.type.isNullable, "Required property should be non-nullable") + assertEquals(null, idParam.defaultValue, "Required property should have no default") + } +} diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/NameUtilsTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/NameUtilsTest.kt new file mode 100644 index 00000000..e9ba08ea --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/NameUtilsTest.kt @@ -0,0 +1,156 @@ +package com.avsystem.justworks.core.gen + +import kotlin.test.Test +import kotlin.test.assertEquals + +class NameUtilsTest { + // -- toCamelCase -- + + @Test + fun `toCamelCase converts snake_case`() { + assertEquals("snakeCase", "snake_case".toCamelCase()) + } + + @Test + fun `toCamelCase converts kebab-case`() { + assertEquals("kebabCase", "kebab-case".toCamelCase()) + } + + @Test + fun `toCamelCase converts UPPER_CASE preserving segment casing`() { + // Splits on _, gets ["UPPER", "CASE"]. No camel boundaries in all-caps segments. + // First segment lowercased -> "upper", second replaceFirstChar -> "CASE" (already upper). + assertEquals("upperCASE", "UPPER_CASE".toCamelCase()) + } + + @Test + fun `toCamelCase lowercases PascalCase`() { + assertEquals("pascalCase", "PascalCase".toCamelCase()) + } + + @Test + fun `toCamelCase preserves single word`() { + assertEquals("single", "single".toCamelCase()) + } + + @Test + fun `toCamelCase converts dot-delimited`() { + assertEquals("withDots", "with.dots".toCamelCase()) + } + + @Test + fun `toCamelCase converts already_camel style`() { + assertEquals("alreadyCamel", "already_camel".toCamelCase()) + } + + // -- toEnumConstantName -- + + @Test + fun `toEnumConstantName uppercases simple word`() { + assertEquals("AVAILABLE", "available".toEnumConstantName()) + } + + @Test + fun `toEnumConstantName preserves snake_case underscores`() { + assertEquals("PENDING_REVIEW", "pending_review".toEnumConstantName()) + } + + @Test + fun `toEnumConstantName splits camelCase`() { + assertEquals("CAMEL_CASE", "camelCase".toEnumConstantName()) + } + + @Test + fun `toEnumConstantName prefixes digit-starting values`() { + assertEquals("VALUE_123", "123".toEnumConstantName()) + } + + @Test + fun `toEnumConstantName converts hyphens`() { + assertEquals("WITH_HYPHENS", "with-hyphens".toEnumConstantName()) + } + + @Test + fun `toEnumConstantName converts spaces`() { + assertEquals("WITH_SPACES", "with spaces".toEnumConstantName()) + } + + // -- operationNameFromPath -- + + @Test + fun `operationNameFromPath converts simple path`() { + assertEquals("PostPets", operationNameFromPath("POST", "/pets")) + } + + @Test + fun `operationNameFromPath handles path parameter`() { + assertEquals("GetPetsById", operationNameFromPath("GET", "/pets/{id}")) + } + + @Test + fun `operationNameFromPath handles multiple path parameters`() { + assertEquals("PutUsersByUserIdOrdersByOrderId", operationNameFromPath("PUT", "/users/{userId}/orders/{orderId}")) + } + + @Test + fun `operationNameFromPath handles hyphens in path`() { + assertEquals("GetApiTokens", operationNameFromPath("GET", "/api-tokens")) + } + + @Test + fun `operationNameFromPath handles underscores in path`() { + assertEquals("GetApiTokens", operationNameFromPath("GET", "/api_tokens")) + } + + @Test + fun `operationNameFromPath handles mixed case method`() { + assertEquals("DeletePets", operationNameFromPath("DELETE", "/pets")) + } + + @Test + fun `operationNameFromPath handles camelCase path parameter`() { + assertEquals("GetUsersByUserId", operationNameFromPath("GET", "/users/{userId}")) + } + + // -- toKotlinIdentifier -- + + @Test + fun `toKotlinIdentifier backtick-escapes object keyword`() { + assertEquals("`object`", "object".toKotlinIdentifier()) + } + + @Test + fun `toKotlinIdentifier backtick-escapes in keyword`() { + assertEquals("`in`", "in".toKotlinIdentifier()) + } + + @Test + fun `toKotlinIdentifier backtick-escapes class keyword`() { + assertEquals("`class`", "class".toKotlinIdentifier()) + } + + @Test + fun `toKotlinIdentifier leaves non-keyword unchanged`() { + assertEquals("normalName", "normalName".toKotlinIdentifier()) + } + + @Test + fun `toKotlinIdentifier camelCases non-keyword with underscore`() { + assertEquals("myObject", "my_object".toKotlinIdentifier()) + } + + @Test + fun `toKotlinIdentifier backtick-escapes val keyword`() { + assertEquals("`val`", "val".toKotlinIdentifier()) + } + + @Test + fun `toKotlinIdentifier backtick-escapes fun keyword`() { + assertEquals("`fun`", "fun".toKotlinIdentifier()) + } + + @Test + fun `toKotlinIdentifier backtick-escapes return keyword`() { + assertEquals("`return`", "return".toKotlinIdentifier()) + } +} diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGeneratorTest.kt new file mode 100644 index 00000000..2a733a39 --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGeneratorTest.kt @@ -0,0 +1,58 @@ +package com.avsystem.justworks.core.gen + +import com.squareup.kotlinpoet.PropertySpec +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SerializersModuleGeneratorTest { + private val modelPackage = "com.example.model" + private val generator = SerializersModuleGenerator(modelPackage) + + @Test + fun `generates SerializersModule with polymorphic registration`() { + val hierarchies = mapOf("Shape" to listOf("Circle", "Square")) + val fileSpec = generator.generate(hierarchies) + + assertNotNull(fileSpec, "Should generate a FileSpec for non-empty hierarchies") + + val prop = fileSpec.members.filterIsInstance().find { it.name == "generatedSerializersModule" } + assertNotNull(prop, "Should contain generatedSerializersModule property") + + val initializer = prop.initializer.toString() + assertTrue(initializer.contains("polymorphic"), "Initializer should contain 'polymorphic' call") + assertTrue(initializer.contains("subclass"), "Initializer should contain 'subclass' call") + } + + @Test + fun `generates module with multiple hierarchies`() { + val hierarchies = + mapOf( + "Shape" to listOf("Circle", "Square"), + "Animal" to listOf("Cat", "Dog"), + ) + val fileSpec = generator.generate(hierarchies) + assertNotNull(fileSpec) + + val initializer = + fileSpec.members + .filterIsInstance() + .first { it.name == "generatedSerializersModule" } + .initializer + .toString() + + // Both hierarchies should appear + assertTrue(initializer.contains("Shape"), "Should contain Shape hierarchy") + assertTrue(initializer.contains("Animal"), "Should contain Animal hierarchy") + assertTrue(initializer.contains("Circle"), "Should contain Circle subclass") + assertTrue(initializer.contains("Dog"), "Should contain Dog subclass") + } + + @Test + fun `returns null for empty hierarchies`() { + val result = generator.generate(emptyMap()) + assertNull(result, "Should return null for empty hierarchies") + } +} 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 new file mode 100644 index 00000000..e46f1cbe --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/TypeMappingTest.kt @@ -0,0 +1,102 @@ +package com.avsystem.justworks.core.gen + +import com.avsystem.justworks.core.model.PrimitiveType +import com.avsystem.justworks.core.model.TypeRef +import kotlin.test.Test +import kotlin.test.assertEquals + +class TypeMappingTest { + private val pkg = "com.example.model" + + // -- Primitive types -- + + @Test + fun `maps STRING to kotlin String`() { + val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.STRING), pkg) + assertEquals("kotlin.String", result.toString()) + } + + @Test + fun `maps INT to kotlin Int`() { + val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.INT), pkg) + assertEquals("kotlin.Int", result.toString()) + } + + @Test + fun `maps LONG to kotlin Long`() { + val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.LONG), pkg) + assertEquals("kotlin.Long", result.toString()) + } + + @Test + fun `maps DOUBLE to kotlin Double`() { + val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.DOUBLE), pkg) + assertEquals("kotlin.Double", result.toString()) + } + + @Test + fun `maps FLOAT to kotlin Float`() { + val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.FLOAT), pkg) + assertEquals("kotlin.Float", result.toString()) + } + + @Test + fun `maps BOOLEAN to kotlin Boolean`() { + val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.BOOLEAN), pkg) + assertEquals("kotlin.Boolean", result.toString()) + } + + @Test + fun `maps BYTE_ARRAY to kotlin ByteArray`() { + val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.BYTE_ARRAY), pkg) + assertEquals("kotlin.ByteArray", result.toString()) + } + + @Test + fun `maps DATE_TIME to kotlinx datetime Instant`() { + val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.DATE_TIME), pkg) + assertEquals("kotlinx.datetime.Instant", result.toString()) + } + + @Test + fun `maps DATE to kotlinx datetime LocalDate`() { + val result = TypeMapping.toTypeName(TypeRef.Primitive(PrimitiveType.DATE), pkg) + assertEquals("kotlinx.datetime.LocalDate", result.toString()) + } + + // -- Array -- + + @Test + fun `maps Array of String to List of String`() { + val ref = TypeRef.Array(TypeRef.Primitive(PrimitiveType.STRING)) + val result = TypeMapping.toTypeName(ref, pkg) + assertEquals("kotlin.collections.List", result.toString()) + } + + // -- Map -- + + @Test + fun `maps Map of String to Map with String key and String value`() { + val ref = TypeRef.Map(TypeRef.Primitive(PrimitiveType.STRING)) + val result = TypeMapping.toTypeName(ref, pkg) + assertEquals("kotlin.collections.Map", result.toString()) + } + + // -- Reference -- + + @Test + fun `maps Reference to ClassName in model package`() { + val ref = TypeRef.Reference("Pet") + val result = TypeMapping.toTypeName(ref, pkg) + assertEquals("com.example.model.Pet", result.toString()) + } + + // -- Nested generics -- + + @Test + fun `maps Array of Reference to List of model class`() { + val ref = TypeRef.Array(TypeRef.Reference("Pet")) + val result = TypeMapping.toTypeName(ref, pkg) + assertEquals("kotlin.collections.List", result.toString()) + } +} From e09701382bb12eda3d53d872b4cd761ad1f5e12d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 12 Mar 2026 15:24:56 +0100 Subject: [PATCH 2/8] fix: resolve ktlint no-consecutive-comments violation in Names.kt --- .../kotlin/com/avsystem/justworks/core/gen/Names.kt | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt index 7710b5b5..ba35a25e 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt @@ -3,14 +3,10 @@ package com.avsystem.justworks.core.gen import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.MemberName -/** - * Centralized repository of type and function references used across code generators. - * Organized by domain (HTTP client, serialization, dates, error handling). - */ +// Centralized repository of type and function references used across code generators. +// Organized by domain (HTTP client, serialization, dates, error handling). -// ============================================================================ -// Ktor HTTP Client -// ============================================================================ +// -- Ktor HTTP Client -- val HTTP_CLIENT = ClassName("io.ktor.client", "HttpClient") val CONTENT_NEGOTIATION = ClassName("io.ktor.client.plugins.contentnegotiation", "ContentNegotiation") From b425b37b7bda429ffa54b4f87b87627c528cbd40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Tue, 17 Mar 2026 12:21:44 +0100 Subject: [PATCH 3/8] refactor: update ClientGenerator for shared base class and safer API usage - Replace `Closeable` with `ApiClientBase` to streamline client structure. - Switch to `safeCall` for standardized error handling. - Implement `toResult` and `toEmptyResult` for consistent response processing. - Refactor tests to reflect APIClient changes and shared helpers. --- .../justworks/core/gen/ClientGenerator.kt | 183 +++++------------- .../com/avsystem/justworks/core/gen/Names.kt | 11 ++ .../justworks/core/gen/ClientGeneratorTest.kt | 87 ++++----- 3 files changed, 102 insertions(+), 179 deletions(-) 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 6f0a8d76..61aec8c5 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 @@ -4,14 +4,12 @@ import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.Endpoint import com.avsystem.justworks.core.model.HttpMethod import com.avsystem.justworks.core.model.ParameterLocation -import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.ContextParameter import com.squareup.kotlinpoet.ExperimentalKotlinPoetApi import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.KModifier -import com.squareup.kotlinpoet.LambdaTypeName import com.squareup.kotlinpoet.MemberName import com.squareup.kotlinpoet.ParameterSpec import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy @@ -19,15 +17,14 @@ import com.squareup.kotlinpoet.PropertySpec import com.squareup.kotlinpoet.STRING import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.UNIT -import com.squareup.kotlinpoet.asTypeName import java.io.File /** * Generates one KotlinPoet [FileSpec] per API tag, each containing a client class - * with suspend functions for every endpoint in that tag group. + * 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) { fun generate(spec: ApiSpec, hasPolymorphicTypes: Boolean = false): List { val grouped = spec.endpoints.groupBy { it.tags.firstOrNull() ?: "Default" } return grouped.map { (tag, endpoints) -> @@ -56,42 +53,32 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: endpoints: List, hasPolymorphicTypes: Boolean = false, ): FileSpec { - val className = ClassName(apiPackage, "${tag.toPascalCase()}Api") + val className = com.squareup.kotlinpoet.ClassName(apiPackage, "${tag.toPascalCase()}Api") + + val clientInitializer = if (hasPolymorphicTypes) { + val generatedSerializersModule = MemberName(modelPackage, "generatedSerializersModule") + CodeBlock.of("createHttpClient(%M)", generatedSerializersModule) + } else { + CodeBlock.of("createHttpClient()") + } val classBuilder = TypeSpec .classBuilder(className) - .addSuperinterface(CLOSEABLE) + .superclass(API_CLIENT_BASE) + .addSuperclassConstructorParameter("baseUrl") + .addSuperclassConstructorParameter("tokenProvider") .primaryConstructor( FunSpec .constructorBuilder() .addParameter("baseUrl", STRING) .addParameter("tokenProvider", STRING) .build(), - ).addProperty( - PropertySpec - .builder("baseUrl", STRING) - .initializer("baseUrl") - .addModifiers(KModifier.PRIVATE) - .build(), - ).addProperty( - PropertySpec - .builder("tokenProvider", STRING) - .initializer("tokenProvider") - .addModifiers(KModifier.PRIVATE) - .build(), ).addProperty( PropertySpec .builder("client", HTTP_CLIENT) - .addModifiers(KModifier.PRIVATE) - .initializer( - buildClientInitializer(hasPolymorphicTypes), - ).build(), - ).addFunction( - FunSpec - .builder("close") - .addModifiers(KModifier.OVERRIDE) - .addStatement("client.close()") + .addModifiers(KModifier.OVERRIDE, KModifier.PROTECTED) + .initializer(clientInitializer) .build(), ) @@ -99,56 +86,13 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: classBuilder.addFunction(generateEndpointFunction(endpoint)) } - val encodeParamFun = - FunSpec - .builder("encodeParam") - .addModifiers(KModifier.PRIVATE, KModifier.INLINE) - .addTypeVariable( - com.squareup.kotlinpoet - .TypeVariableName("T") - .copy(reified = true), - ).addParameter("value", com.squareup.kotlinpoet.TypeVariableName("T")) - .returns(STRING) - .addStatement("return %T.%M(value).trim('\"')", JSON_CLASS, ENCODE_TO_STRING_FUN) - .build() - return FileSpec .builder(className) - .addFunction(encodeParamFun) + .addImport("com.avsystem.justworks", "encodeParam") .addType(classBuilder.build()) .build() } - private fun buildClientInitializer(hasPolymorphicTypes: Boolean): CodeBlock { - val builder = CodeBlock - .builder() - .add("%T·{\n", HTTP_CLIENT) - .indent() - .add("install(%T)·{\n", CONTENT_NEGOTIATION) - .indent() - - if (hasPolymorphicTypes) { - val generatedSerializersModule = MemberName(modelPackage, "generatedSerializersModule") - builder - .add("%M(%T·{\n", JSON_FUN, JSON_CLASS) - .indent() - .add("serializersModule·=·%M\n", generatedSerializersModule) - .unindent() - .add("})\n") - } else { - builder.add("%M()\n", JSON_FUN) - } - - builder - .unindent() - .add("}\n") - .add("expectSuccess·=·false\n") - .unindent() - .add("}") - - return builder.build() - } - private fun generateEndpointFunction(endpoint: Endpoint): FunSpec { val functionName = endpoint.operationId.toCamelCase() val returnBodyType = resolveReturnType(endpoint) @@ -213,7 +157,6 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: } } - // Build function body funBuilder.addCode(buildFunctionBody(endpoint, pathParams, queryParams, headerParams, returnBodyType)) return funBuilder.build() @@ -235,36 +178,39 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: HttpMethod.PATCH -> PATCH_FUN } - // Build the URL string with path parameter substitution val urlString = buildUrlString(endpoint.path, pathParams) + val resultFun = if (returnBodyType == UNIT) TO_EMPTY_RESULT_FUN else TO_RESULT_FUN val code = CodeBlock.builder() - code.beginControlFlow("val response = try") - - // client.METHOD(url) { ... } - code.add("client.%M(%L)", httpMethodFun, urlString) - code.beginControlFlow("") - - // headers block - code.beginControlFlow("%M", HEADERS_FUN) - code.addStatement( - "append(%T.Authorization, %P)", - HTTP_HEADERS, - CodeBlock.of($$"Bearer ${'$'}{$tokenProvider}"), - ) - for (param in headerParams) { - val paramName = param.name.toCamelCase() - if (param.required) { - code.addStatement("append(%S, encodeParam(%L))", param.name, paramName) - } else { - code.beginControlFlow("if (%L != null)", paramName) - code.addStatement("append(%S, encodeParam(%L))", param.name, paramName) - code.endControlFlow() + + // safeCall { + code.add("return safeCall {\n") + code.indent() + + // client.METHOD(url) { + code.add("client.%M(%L) {\n", httpMethodFun, urlString) + code.indent() + + // applyAuth() + code.addStatement("applyAuth()") + + // custom headers (if any) + if (headerParams.isNotEmpty()) { + code.beginControlFlow("%M", HEADERS_FUN) + for (param in headerParams) { + val paramName = param.name.toCamelCase() + if (param.required) { + code.addStatement("append(%S, encodeParam(%L))", param.name, paramName) + } else { + code.beginControlFlow("if (%L != null)", paramName) + code.addStatement("append(%S, encodeParam(%L))", param.name, paramName) + code.endControlFlow() + } } + code.endControlFlow() } - code.endControlFlow() // headers - // url block for query params + // query params (if any) if (queryParams.isNotEmpty()) { code.beginControlFlow("url") for (param in queryParams) { @@ -277,7 +223,7 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: code.endControlFlow() } } - code.endControlFlow() // url + code.endControlFlow() } // request body @@ -293,39 +239,11 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: } } - code.endControlFlow() // request lambda - - code.nextControlFlow("catch (e: %T)", Exception::class) - code.addStatement( - "%M(%T(0, e.message ?: \"Network error\", %T.Network))", - RAISE_FUN, - HTTP_ERROR, - HTTP_ERROR_TYPE, - ) - code.endControlFlow() // try-catch - - // when block for response mapping - code.beginControlFlow("return when (response.status.value)") - if (returnBodyType == UNIT) { - code.addStatement("in 200..299 -> %T(response.status.value, Unit)", HTTP_SUCCESS) - } else { - code.addStatement("in 200..299 -> %T(response.status.value, response.%M())", HTTP_SUCCESS, BODY_FUN) - } - code.addStatement( - "in 400..499 -> %M(%T(response.status.value, response.%M(), %T.Client))", - RAISE_FUN, - HTTP_ERROR, - BODY_AS_TEXT_FUN, - HTTP_ERROR_TYPE, - ) - code.addStatement( - "else -> %M(%T(response.status.value, response.%M(), %T.Server))", - RAISE_FUN, - HTTP_ERROR, - BODY_AS_TEXT_FUN, - HTTP_ERROR_TYPE, - ) - code.endControlFlow() // when + code.unindent() + code.add("}\n") // close request lambda + + code.unindent() + code.add("}.%M()\n", resultFun) // close safeCall + chain toResult return code.build() } @@ -334,18 +252,15 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: path: String, pathParams: List, ): CodeBlock { - // Replace {paramName} with ${paramName} for Kotlin string template var template = path for (param in pathParams) { val camelName = param.name.toCamelCase() template = template.replace("{${param.name}}", "\${encodeParam($camelName)}") } - // Use %P for string template that handles $ correctly return CodeBlock.of("%P", CodeBlock.of("\${'$'}{baseUrl}$template")) } private fun resolveReturnType(endpoint: Endpoint): com.squareup.kotlinpoet.TypeName { - // Find the first 2xx response with a schema val successResponse = endpoint.responses.entries .filter { it.key.startsWith("2") } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt index ba35a25e..6f201fa2 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt @@ -69,3 +69,14 @@ val HTTP_SUCCESS = ClassName("com.avsystem.justworks", "HttpSuccess") val CLOSEABLE = ClassName("java.io", "Closeable") val OPT_IN = ClassName("kotlin", "OptIn") + +// ============================================================================ +// Shared client base (generated) +// ============================================================================ + +val API_CLIENT_BASE = ClassName("com.avsystem.justworks", "ApiClientBase") +val HTTP_RESPONSE = ClassName("io.ktor.client.statement", "HttpResponse") +val HTTP_REQUEST_BUILDER = ClassName("io.ktor.client.request", "HttpRequestBuilder") +val TO_RESULT_FUN = MemberName("com.avsystem.justworks", "toResult") +val TO_EMPTY_RESULT_FUN = MemberName("com.avsystem.justworks", "toEmptyResult") +val ENCODE_PARAM_FUN = MemberName("com.avsystem.justworks", "encodeParam") 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 ecc8cbba..2eef2072 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 @@ -111,11 +111,9 @@ class ClientGeneratorTest { } val cls = clientClass(endpoints) val funBodies = - cls.funSpecs.filter { it.name != "close" }.associate { + cls.funSpecs.associate { it.name to it.body.toString() } - // MemberName body.toString() renders fully-qualified names (e.g., io.ktor.client.request.`get`) - // Check that each body references the correct Ktor request method path assertTrue( funBodies["getPet"]!!.contains("request.get(") || funBodies["getPet"]!!.contains("request.`get`("), "GET method expected", @@ -316,20 +314,22 @@ class ClientGeneratorTest { assertEquals("kotlin.Unit", returnType.typeArguments.first().toString()) } - // -- Client class implements Closeable -- + // -- Client class extends ApiClientBase -- @Test - fun `client class implements Closeable`() { + fun `client class extends ApiClientBase`() { val cls = clientClass(listOf(endpoint())) - val superinterfaces = cls.superinterfaces.keys.map { it.toString() } - assertTrue("java.io.Closeable" in superinterfaces, "Expected Closeable superinterface") + assertEquals("com.avsystem.justworks.ApiClientBase", cls.superclass.toString()) } // -- SER-01: Polymorphic spec wires SerializersModule -- @Test - fun `polymorphic spec wires serializersModule in json block`() { - val files = ClientGenerator(apiPackage, modelPackage).generate(spec(listOf(endpoint())), hasPolymorphicTypes = true) + fun `polymorphic spec wires serializersModule in createHttpClient call`() { + val files = ClientGenerator( + apiPackage, + modelPackage, + ).generate(spec(listOf(endpoint())), hasPolymorphicTypes = true) val clientProperty = files .first() .members @@ -338,13 +338,19 @@ class ClientGeneratorTest { .propertySpecs .first { it.name == "client" } val clientInitializer = clientProperty.initializer.toString() - assertTrue(clientInitializer.contains("serializersModule"), "Expected serializersModule in client initializer") - assertTrue(clientInitializer.contains("generatedSerializersModule"), "Expected generatedSerializersModule reference") + assertTrue( + clientInitializer.contains("generatedSerializersModule"), + "Expected generatedSerializersModule reference", + ) + assertTrue(clientInitializer.contains("createHttpClient"), "Expected createHttpClient call") } @Test - fun `non-polymorphic spec has plain json() with no serializersModule`() { - val files = ClientGenerator(apiPackage, modelPackage).generate(spec(listOf(endpoint())), hasPolymorphicTypes = false) + fun `non-polymorphic spec has createHttpClient without serializersModule`() { + val files = ClientGenerator( + apiPackage, + modelPackage, + ).generate(spec(listOf(endpoint())), hasPolymorphicTypes = false) val clientProperty = files .first() .members @@ -353,56 +359,47 @@ class ClientGeneratorTest { .propertySpecs .first { it.name == "client" } val clientInitializer = clientProperty.initializer.toString() - assertTrue(!clientInitializer.contains("serializersModule"), "Expected no serializersModule for non-polymorphic spec") + assertTrue(clientInitializer.contains("createHttpClient()"), "Expected plain createHttpClient()") + assertTrue(!clientInitializer.contains("serializersModule"), "Expected no serializersModule") } - // -- AUTH-02, AUTH-03: Generated code contains bearer auth header -- + // -- Generated code uses shared helpers -- @Test - fun `generated code contains bearer auth header`() { + fun `generated code calls applyAuth`() { val cls = clientClass(listOf(endpoint())) val funSpec = cls.funSpecs.first { it.name == "listPets" } val body = funSpec.body.toString() - assertTrue(body.contains("Authorization"), "Expected Authorization header") - assertTrue(body.contains("Bearer"), "Expected Bearer prefix") - assertTrue(body.contains("tokenProvider"), "Expected tokenProvider call") + assertTrue(body.contains("applyAuth()"), "Expected applyAuth() call") } - // -- ERRH-05: Generated code contains try-catch with raise -- - @Test - fun `generated code contains try-catch with raise NetworkError`() { + fun `generated code calls safeCall`() { val cls = clientClass(listOf(endpoint())) val funSpec = cls.funSpecs.first { it.name == "listPets" } val body = funSpec.body.toString() - assertTrue(body.contains("catch"), "Expected catch block") - assertTrue(body.contains("HttpError("), "Expected HttpError constructor call in catch") - assertTrue( - body.contains("HttpErrorType.Network") || body.contains(".Network"), - "Expected HttpErrorType.Network in catch", - ) - assertTrue(body.contains("raise"), "Expected raise call") + assertTrue(body.contains("safeCall"), "Expected safeCall call") } - // -- ERRH-02, ERRH-03, ERRH-04: Generated code branches on status code with raise -- - @Test - fun `generated code branches on status code with raise`() { + fun `generated code calls toResult for typed response`() { val cls = clientClass(listOf(endpoint())) val funSpec = cls.funSpecs.first { it.name == "listPets" } val body = funSpec.body.toString() - assertTrue(body.contains("200..299"), "Expected 2xx range check") - assertTrue(body.contains("400..499"), "Expected 4xx range check") - assertTrue(body.contains("Success"), "Expected Success for 2xx") - assertTrue(body.contains("HttpError("), "Expected HttpError constructor call") - assertTrue( - body.contains("HttpErrorType.Client") || body.contains(".Client"), - "Expected HttpErrorType.Client for 4xx", - ) - assertTrue( - body.contains("HttpErrorType.Server") || body.contains(".Server"), - "Expected HttpErrorType.Server for 5xx", - ) - assertTrue(body.contains("raise"), "Expected raise call") + assertTrue(body.contains("toResult"), "Expected toResult call") + } + + @Test + fun `generated code calls toEmptyResult for void response`() { + val ep = + endpoint( + method = HttpMethod.DELETE, + operationId = "deletePet", + responses = mapOf("204" to Response("204", "No content", null)), + ) + val cls = clientClass(listOf(ep)) + val funSpec = cls.funSpecs.first { it.name == "deletePet" } + val body = funSpec.body.toString() + assertTrue(body.contains("toEmptyResult"), "Expected toEmptyResult call") } } From 67e2b626730fb206d8db085078ee3d2096af3227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Tue, 17 Mar 2026 12:21:54 +0100 Subject: [PATCH 4/8] refactor: remove unused `isEnum` field from tests and schema generation - Simplified schema handling in `ModelGenerator` and related test files. - Updated tests to reflect removal of `isEnum` for cleaner and more maintainable code. - Adjusted polymorphic schema generation to streamline logic and avoid redundant checks. --- .../justworks/core/gen/ModelGenerator.kt | 5 +--- .../core/gen/InlineSchemaDedupTest.kt | 5 ++-- .../core/gen/ModelGeneratorPolymorphicTest.kt | 16 +++++++--- .../justworks/core/gen/ModelGeneratorTest.kt | 29 ------------------- 4 files changed, 15 insertions(+), 40 deletions(-) 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 e54b8f1d..19a48a45 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 @@ -94,7 +94,6 @@ class ModelGenerator(private val modelPackage: String) { description = null, properties = inlineTypeRef.properties, requiredProperties = inlineTypeRef.requiredProperties, - isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -106,7 +105,6 @@ class ModelGenerator(private val modelPackage: String) { // First pass: scan all schemas to build variantParents map and detect anyOf-without-discriminator for (schema in spec.schemas) { - if (schema.isEnum) continue val variants = schema.oneOf ?: schema.anyOf if (!variants.isNullOrEmpty()) { val parentClassName = ClassName(modelPackage, schema.name) @@ -138,7 +136,6 @@ class ModelGenerator(private val modelPackage: String) { // Second pass: generate FileSpecs for component schemas val schemaFiles = spec.schemas - .filter { !it.isEnum } .flatMap { schema -> when { !schema.oneOf.isNullOrEmpty() || !schema.anyOf.isNullOrEmpty() -> { @@ -686,7 +683,7 @@ class ModelGenerator(private val modelPackage: String) { * Primitive-only schemas should be generated as type aliases instead of data classes. */ private fun isPrimitiveOnly(schema: SchemaModel): Boolean = schema.properties.isEmpty() && - !schema.isEnum && schema.allOf == null && schema.oneOf == null && schema.anyOf == null + schema.allOf == null && schema.oneOf == null && schema.anyOf == null /** * Generates a type alias FileSpec for primitive-only schemas. 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 726db7dd..5391c29f 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 @@ -29,7 +29,7 @@ class InlineSchemaDedupTest { // First occurrence wins assertEquals("FirstContext", name1) - assertEquals("FirstContext", name2) // Same structure returns same name + assertEquals("FirstContext", name2) // Same structure returns same name } @Test @@ -62,7 +62,6 @@ class InlineSchemaDedupTest { description = null, properties = emptyList(), requiredProperties = emptySet(), - isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -77,7 +76,7 @@ class InlineSchemaDedupTest { val name = deduplicator.getOrGenerateName(props, setOf("id"), "Pet") - assertEquals("PetInline", name) // Collision with component schema + assertEquals("PetInline", name) // Collision with component schema } @Test 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 bfc4cee4..df029dc2 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 @@ -42,7 +42,6 @@ class ModelGeneratorPolymorphicTest { description = null, properties = properties, requiredProperties = requiredProperties, - isEnum = false, allOf = allOf, oneOf = oneOf, anyOf = anyOf, @@ -181,7 +180,10 @@ class ModelGeneratorPolymorphicTest { discriminator = Discriminator( propertyName = "shapeType", - mapping = mapOf("circle" to "#/components/schemas/Circle", "square" to "#/components/schemas/Square"), + mapping = mapOf( + "circle" to "#/components/schemas/Circle", + "square" to "#/components/schemas/Square", + ), ), ) val circleSchema = schema(name = "Circle") @@ -378,7 +380,9 @@ class ModelGeneratorPolymorphicTest { ), ) - val files = generator.generate(spec(schemas = listOf(networkMeshSchema, extenderPropsSchema, ethernetPropsSchema))) + val files = generator.generate( + spec(schemas = listOf(networkMeshSchema, extenderPropsSchema, ethernetPropsSchema)), + ) val networkMeshType = findType(files, "NetworkMeshDevice") // Verify sealed interface with discriminator @@ -607,7 +611,11 @@ class ModelGeneratorPolymorphicTest { // ShapeSerializer should NOT be generated val serializerTypes = files.flatMap { it.members.filterIsInstance() } val shapeSerializerType = serializerTypes.find { it.name == "ShapeSerializer" } - assertEquals(null, shapeSerializerType, "Discriminated anyOf should NOT generate a JsonContentPolymorphicSerializer") + assertEquals( + null, + shapeSerializerType, + "Discriminated anyOf should NOT generate a JsonContentPolymorphicSerializer", + ) } // -- POLY-06: allOf with sealed parent -- 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 238d15cb..770e2d0e 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 @@ -41,7 +41,6 @@ class ModelGeneratorTest { PropertyModel("tag", TypeRef.Primitive(PrimitiveType.STRING), null, true), ), requiredProperties = setOf("id", "name"), - isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -134,7 +133,6 @@ class ModelGeneratorTest { PropertyModel("created_at", TypeRef.Primitive(PrimitiveType.STRING), null, false), ), requiredProperties = setOf("created_at"), - isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -182,7 +180,6 @@ class ModelGeneratorTest { PropertyModel("pet", TypeRef.Reference("Pet"), null, false), ), requiredProperties = setOf("pet"), - isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -336,7 +333,6 @@ class ModelGeneratorTest { description = null, properties = emptyList(), requiredProperties = emptySet(), - isEnum = false, allOf = null, oneOf = null, anyOf = listOf(TypeRef.Reference("CreditCard"), TypeRef.Reference("BankTransfer")), @@ -348,7 +344,6 @@ class ModelGeneratorTest { description = null, properties = listOf(PropertyModel("cardNumber", TypeRef.Primitive(PrimitiveType.STRING), null, false)), requiredProperties = setOf("cardNumber"), - isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -375,7 +370,6 @@ class ModelGeneratorTest { description = null, properties = emptyList(), requiredProperties = emptySet(), - isEnum = false, allOf = null, oneOf = null, anyOf = listOf(TypeRef.Reference("CreditCard")), @@ -387,7 +381,6 @@ class ModelGeneratorTest { description = null, properties = listOf(PropertyModel("cardNumber", TypeRef.Primitive(PrimitiveType.STRING), null, false)), requiredProperties = setOf("cardNumber"), - isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -417,7 +410,6 @@ class ModelGeneratorTest { description = null, properties = emptyList(), requiredProperties = emptySet(), - isEnum = false, allOf = null, oneOf = null, anyOf = listOf(TypeRef.Reference("CreditCard")), @@ -433,7 +425,6 @@ class ModelGeneratorTest { description = null, properties = listOf(PropertyModel("cardNumber", TypeRef.Primitive(PrimitiveType.STRING), null, false)), requiredProperties = setOf("cardNumber"), - isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -467,7 +458,6 @@ class ModelGeneratorTest { description = null, properties = emptyList(), requiredProperties = emptySet(), - isEnum = false, allOf = null, oneOf = null, anyOf = listOf(TypeRef.Reference("CreditCard"), TypeRef.Reference("BankTransfer")), @@ -479,7 +469,6 @@ class ModelGeneratorTest { description = null, properties = listOf(PropertyModel("cardNumber", TypeRef.Primitive(PrimitiveType.STRING), null, false)), requiredProperties = setOf("cardNumber"), - isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -493,7 +482,6 @@ class ModelGeneratorTest { PropertyModel("accountNumber", TypeRef.Primitive(PrimitiveType.STRING), null, false), ), requiredProperties = setOf("accountNumber"), - isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -524,7 +512,6 @@ class ModelGeneratorTest { PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false, "default-name"), ), requiredProperties = setOf("name"), - isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -554,7 +541,6 @@ class ModelGeneratorTest { PropertyModel("price", TypeRef.Primitive(PrimitiveType.DOUBLE), null, false, 19.99), ), requiredProperties = setOf("age", "price"), - isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -585,7 +571,6 @@ class ModelGeneratorTest { PropertyModel("active", TypeRef.Primitive(PrimitiveType.BOOLEAN), null, false, true), ), requiredProperties = setOf("active"), - isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -620,7 +605,6 @@ class ModelGeneratorTest { ), ), requiredProperties = setOf("createdAt"), - isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -656,7 +640,6 @@ class ModelGeneratorTest { PropertyModel("eventDate", TypeRef.Primitive(PrimitiveType.DATE), null, false, "2024-01-01"), ), requiredProperties = setOf("eventDate"), - isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -691,7 +674,6 @@ class ModelGeneratorTest { PropertyModel("required", TypeRef.Primitive(PrimitiveType.STRING), null, false, null), ), requiredProperties = setOf("required", "withDefault"), - isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -721,7 +703,6 @@ class ModelGeneratorTest { PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, true, "ignored-default"), ), requiredProperties = emptySet(), - isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -758,7 +739,6 @@ class ModelGeneratorTest { PropertyModel("status", TypeRef.Reference("Status"), null, false, "active"), ), requiredProperties = setOf("status"), - isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -788,7 +768,6 @@ class ModelGeneratorTest { description = null, properties = emptyList(), requiredProperties = emptySet(), - isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -815,7 +794,6 @@ class ModelGeneratorTest { description = "Unique identifier for a user", properties = emptyList(), requiredProperties = emptySet(), - isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -857,7 +835,6 @@ class ModelGeneratorTest { PropertyModel("object", TypeRef.Primitive(PrimitiveType.STRING), null, false), ), requiredProperties = setOf("object"), - isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -891,7 +868,6 @@ class ModelGeneratorTest { PropertyModel("in", TypeRef.Primitive(PrimitiveType.STRING), null, true), ), requiredProperties = emptySet(), - isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -937,7 +913,6 @@ class ModelGeneratorTest { PropertyModel("children", TypeRef.Array(selfReferencingInline), null, true), ), requiredProperties = setOf("value"), - isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -961,7 +936,6 @@ class ModelGeneratorTest { PropertyModel("nickname", TypeRef.Primitive(PrimitiveType.STRING), null, true), ), requiredProperties = setOf("name"), - isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -993,7 +967,6 @@ class ModelGeneratorTest { PropertyModel("id", TypeRef.Primitive(PrimitiveType.LONG), null, false), ), requiredProperties = setOf("id"), - isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -1007,7 +980,6 @@ class ModelGeneratorTest { PropertyModel("optionalField", TypeRef.Primitive(PrimitiveType.STRING), null, true), ), requiredProperties = setOf("id"), - isEnum = false, allOf = listOf(TypeRef.Reference("Base")), oneOf = null, anyOf = null, @@ -1037,7 +1009,6 @@ class ModelGeneratorTest { PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), ), requiredProperties = setOf("id", "name"), - isEnum = false, allOf = listOf(TypeRef.Reference("Base")), oneOf = null, anyOf = null, From acade0360a14ce030e7450c9d902ca7ac506c5e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Tue, 17 Mar 2026 12:22:07 +0100 Subject: [PATCH 5/8] refactor: inline `primitiveTypeName` logic into `toTypeName` for simplicity - Removed redundant `primitiveTypeName` method by merging its logic directly into `toTypeName`. - Streamlined handling of `PrimitiveType` variants and improved code clarity. --- .../justworks/core/gen/TypeMapping.kt | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/TypeMapping.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/TypeMapping.kt index 2a7f7942..0c347d40 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/TypeMapping.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/TypeMapping.kt @@ -20,12 +20,19 @@ import com.squareup.kotlinpoet.TypeName * Maps [TypeRef] sealed variants to KotlinPoet [TypeName] instances. */ object TypeMapping { - fun toTypeName( - typeRef: TypeRef, - modelPackage: String, - ): TypeName = when (typeRef) { + fun toTypeName(typeRef: TypeRef, modelPackage: String): TypeName = when (typeRef) { is TypeRef.Primitive -> { - primitiveTypeName(typeRef.type) + when (typeRef.type) { + PrimitiveType.STRING -> STRING + PrimitiveType.INT -> INT + PrimitiveType.LONG -> LONG + PrimitiveType.DOUBLE -> DOUBLE + PrimitiveType.FLOAT -> FLOAT + PrimitiveType.BOOLEAN -> BOOLEAN + PrimitiveType.BYTE_ARRAY -> BYTE_ARRAY + PrimitiveType.DATE_TIME -> INSTANT + PrimitiveType.DATE -> LOCAL_DATE + } } is TypeRef.Array -> { @@ -46,18 +53,8 @@ object TypeMapping { ClassName(modelPackage, sanitizedName) } - is TypeRef.Unknown -> ANY - } - - private fun primitiveTypeName(type: PrimitiveType): TypeName = when (type) { - PrimitiveType.STRING -> STRING - PrimitiveType.INT -> INT - PrimitiveType.LONG -> LONG - PrimitiveType.DOUBLE -> DOUBLE - PrimitiveType.FLOAT -> FLOAT - PrimitiveType.BOOLEAN -> BOOLEAN - PrimitiveType.BYTE_ARRAY -> BYTE_ARRAY - PrimitiveType.DATE_TIME -> INSTANT - PrimitiveType.DATE -> LOCAL_DATE + is TypeRef.Unknown -> { + ANY + } } } From ab59fe4e51b241bd7c5bf3b2b3505e54f572b96c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Tue, 17 Mar 2026 12:22:24 +0100 Subject: [PATCH 6/8] refactor: remove unnecessary multiline formatting in function definitions across core modules - Consolidated function parameter formatting into single lines where applicable for cleaner and more consistent code style. - Updated relevant test files and utilities to match revised formatting. --- .../core/gen/InlineSchemaDeduplicator.kt | 11 ++++---- .../justworks/core/gen/ModelGenerator.kt | 25 ++++--------------- .../avsystem/justworks/core/gen/NameUtils.kt | 5 +--- .../core/gen/SerializersModuleGenerator.kt | 5 +--- .../core/gen/ModelGeneratorPolymorphicTest.kt | 10 ++------ .../justworks/core/gen/ModelGeneratorTest.kt | 5 +--- .../justworks/core/gen/NameUtilsTest.kt | 5 +++- 7 files changed, 20 insertions(+), 46 deletions(-) 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 index c90165e2..c937ded9 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDeduplicator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDeduplicator.kt @@ -10,17 +10,18 @@ import com.avsystem.justworks.core.model.TypeRef * (name, type, required status) regardless of property order. */ data class InlineSchemaKey(val properties: List, val requiredProperties: Set,) { - data class PropertyKey(val name: String, val type: TypeRef, val required: Boolean,) + data class PropertyKey( + val name: String, + val type: TypeRef, + val required: Boolean, + ) companion object { /** * Creates an InlineSchemaKey from properties and required set. * Properties are sorted by name for deterministic equality. */ - fun from( - properties: List, - required: Set - ): InlineSchemaKey { + fun from(properties: List, required: Set): InlineSchemaKey { val propKeys = properties .map { PropertyKey(it.name, it.type, it.name in required) } .sortedBy { it.name } // Deterministic ordering 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 19a48a45..d6e04f7c 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 @@ -186,10 +186,7 @@ class ModelGenerator(private val modelPackage: String) { * Generates model files from [spec] and writes them to [outputDir]. * Returns the number of files written. */ - fun generateTo( - spec: ApiSpec, - outputDir: File, - ): Int { + fun generateTo(spec: ApiSpec, outputDir: File): Int { val files = generate(spec) for (fileSpec in files) { fileSpec.writeTo(outputDir) @@ -259,10 +256,7 @@ class ModelGenerator(private val modelPackage: String) { * * If no unique field is found for a variant, a TODO() placeholder is emitted. */ - private fun generatePolymorphicSerializer( - schema: SchemaModel, - schemasById: Map, - ): FileSpec { + private fun generatePolymorphicSerializer(schema: SchemaModel, schemasById: Map): FileSpec { val sealedClassName = ClassName(modelPackage, schema.name) val serializerClassName = ClassName(modelPackage, "${schema.name}Serializer") @@ -363,10 +357,7 @@ class ModelGenerator(private val modelPackage: String) { * Generates a data class for an allOf schema with merged properties. * If any allOf ref target is a oneOf sealed interface, adds it as a superinterface. */ - private fun generateAllOfDataClass( - schema: SchemaModel, - schemasById: Map, - ): FileSpec { + private fun generateAllOfDataClass(schema: SchemaModel, schemasById: Map): FileSpec { // Determine superinterfaces from allOf refs that point to sealed interfaces val superinterfaces = mutableListOf() for (ref in schema.allOf.orEmpty()) { @@ -583,10 +574,7 @@ class ModelGenerator(private val modelPackage: String) { * Resolves the @SerialName value for a variant within a oneOf schema. * Uses discriminator mapping if available, otherwise defaults to the schema name. */ - private fun resolveSerialName( - parentSchema: SchemaModel, - variantSchemaName: String, - ): String { + private fun resolveSerialName(parentSchema: SchemaModel, variantSchemaName: String): String { val mapping = parentSchema.discriminator?.mapping if (!mapping.isNullOrEmpty()) { // mapping is: serialName -> ref path (e.g., "circle" -> "#/components/schemas/Circle") @@ -689,10 +677,7 @@ class ModelGenerator(private val modelPackage: String) { * Generates a type alias FileSpec for primitive-only schemas. * Example: typealias GroupId = String */ - private fun generateTypeAlias( - schema: SchemaModel, - primitiveType: com.squareup.kotlinpoet.TypeName, - ): FileSpec { + private fun generateTypeAlias(schema: SchemaModel, primitiveType: com.squareup.kotlinpoet.TypeName): FileSpec { val className = ClassName(modelPackage, schema.name) val typeAlias = TypeAliasSpec.builder(schema.name, primitiveType) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameUtils.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameUtils.kt index 2c53df36..d7cce5dd 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameUtils.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameUtils.kt @@ -109,10 +109,7 @@ fun String.toEnumConstantName(): String { * - ("PUT", "/users/{userId}/orders/{orderId}") -> "PutUsersByUserIdOrdersByOrderId" * - ("GET", "/api-tokens") -> "GetApiTokens" */ -fun operationNameFromPath( - method: String, - path: String -): String { +fun operationNameFromPath(method: String, path: String): String { val methodPart = method.lowercase().replaceFirstChar { it.uppercase() } val pathPart = path diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt index 59f7d256..bc53c99d 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt @@ -56,10 +56,7 @@ class SerializersModuleGenerator(private val modelPackage: String,) { * Generates the SerializersModule file and writes it to [outputDir]. * Returns 0 if no polymorphic types exist, 1 if a file was written. */ - fun generateTo( - sealedHierarchies: Map>, - outputDir: File, - ): Int { + fun generateTo(sealedHierarchies: Map>, outputDir: File,): Int { val fileSpec = generate(sealedHierarchies) ?: return 0 fileSpec.writeTo(outputDir) return 1 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 df029dc2..30486e2a 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 @@ -18,10 +18,7 @@ class ModelGeneratorPolymorphicTest { private val modelPackage = "com.example.model" private val generator = ModelGenerator(modelPackage) - private fun spec( - schemas: List = emptyList(), - enums: List = emptyList(), - ) = ApiSpec( + private fun spec(schemas: List = emptyList(), enums: List = emptyList(),) = ApiSpec( title = "Test", version = "1.0", endpoints = emptyList(), @@ -48,10 +45,7 @@ class ModelGeneratorPolymorphicTest { discriminator = discriminator, ) - private fun findType( - files: List, - name: String, - ): TypeSpec { + private fun findType(files: List, name: String,): TypeSpec { for (file in files) { val found = file.members.filterIsInstance().find { it.name == name } if (found != null) return found 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 770e2d0e..5524aad3 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 @@ -19,10 +19,7 @@ class ModelGeneratorTest { private val modelPackage = "com.example.model" private val generator = ModelGenerator(modelPackage) - private fun spec( - schemas: List = emptyList(), - enums: List = emptyList(), - ) = ApiSpec( + private fun spec(schemas: List = emptyList(), enums: List = emptyList()) = ApiSpec( title = "Test", version = "1.0", endpoints = emptyList(), diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/NameUtilsTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/NameUtilsTest.kt index e9ba08ea..eb0d814b 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/NameUtilsTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/NameUtilsTest.kt @@ -89,7 +89,10 @@ class NameUtilsTest { @Test fun `operationNameFromPath handles multiple path parameters`() { - assertEquals("PutUsersByUserIdOrdersByOrderId", operationNameFromPath("PUT", "/users/{userId}/orders/{orderId}")) + assertEquals( + "PutUsersByUserIdOrdersByOrderId", + operationNameFromPath("PUT", "/users/{userId}/orders/{orderId}"), + ) } @Test From 258e15c68f415a6abb082a477f99e49d2ba1d145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Tue, 17 Mar 2026 12:41:39 +0100 Subject: [PATCH 7/8] refactor: remove unused code and update generation logic for improved clarity and consistency - Removed `Generator` placeholder object and unused tests for backtick-escaped keywords handling. - Replaced `generateSuccess` with `generateHttpSuccess` in `ApiResponseGenerator`. - Simplified string utilities in `NameUtils`, removing hardcoded keyword checks. - Streamlined schema and model generation logic in `ModelGenerator`. - Refactored related tests --- .../com/avsystem/justworks/core/Generator.kt | 7 -- .../core/gen/ApiResponseGenerator.kt | 5 +- .../justworks/core/gen/ClientGenerator.kt | 3 +- .../justworks/core/gen/ModelGenerator.kt | 110 +++++++----------- .../avsystem/justworks/core/gen/NameUtils.kt | 90 +++----------- .../com/avsystem/justworks/core/gen/Names.kt | 7 +- .../core/gen/SerializersModuleGenerator.kt | 17 +-- .../core/gen/ApiResponseGeneratorTest.kt | 8 +- .../justworks/core/gen/ModelGeneratorTest.kt | 26 ----- .../justworks/core/gen/NameUtilsTest.kt | 49 -------- 10 files changed, 73 insertions(+), 249 deletions(-) delete mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/Generator.kt diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/Generator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/Generator.kt deleted file mode 100644 index f4e39157..00000000 --- a/core/src/main/kotlin/com/avsystem/justworks/core/Generator.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.avsystem.justworks.core - -/** - * Code generator entry point. Phase 2+ will add real generation logic. - * Phase 1 uses the task's built-in placeholder generation. - */ -object Generator diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiResponseGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiResponseGenerator.kt index e7f63f0f..fefc8cee 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiResponseGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiResponseGenerator.kt @@ -4,7 +4,6 @@ import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.INT import com.squareup.kotlinpoet.KModifier -import com.squareup.kotlinpoet.ParameterSpec import com.squareup.kotlinpoet.PropertySpec import com.squareup.kotlinpoet.STRING import com.squareup.kotlinpoet.TypeName @@ -41,7 +40,7 @@ object ApiResponseGenerator { * Returns the number of files written. */ fun generateTo(outputDir: File): Int { - val files = listOf(generateHttpError(), generateSuccess()) + val files = listOf(generateHttpError(), generateHttpSuccess()) for (fileSpec in files) { fileSpec.writeTo(outputDir) } @@ -92,7 +91,7 @@ object ApiResponseGenerator { .build() } - fun generateSuccess(): FileSpec { + fun generateHttpSuccess(): FileSpec { val t = TypeVariableName("T") val successType = 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 61aec8c5..0cc36274 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 @@ -4,6 +4,7 @@ import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.Endpoint import com.avsystem.justworks.core.model.HttpMethod import com.avsystem.justworks.core.model.ParameterLocation +import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.ContextParameter import com.squareup.kotlinpoet.ExperimentalKotlinPoetApi @@ -53,7 +54,7 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: endpoints: List, hasPolymorphicTypes: Boolean = false, ): FileSpec { - val className = com.squareup.kotlinpoet.ClassName(apiPackage, "${tag.toPascalCase()}Api") + val className = ClassName(apiPackage, "${tag.toPascalCase()}Api") val clientInitializer = if (hasPolymorphicTypes) { val generatedSerializersModule = MemberName(modelPackage, "generatedSerializersModule") 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 d6e04f7c..cec4cce3 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 @@ -18,8 +18,6 @@ import com.squareup.kotlinpoet.PropertySpec import com.squareup.kotlinpoet.STRING import com.squareup.kotlinpoet.TypeAliasSpec import com.squareup.kotlinpoet.TypeSpec -import com.squareup.kotlinpoet.asTypeName -import java.io.File import kotlin.time.Instant /** @@ -85,9 +83,6 @@ class ModelGenerator(private val modelPackage: String) { inlineTypeRef.contextHint, ) - // Check if this is a nested inline (contains dot) - val isNested = name.contains(".") - inlineSchemas.add( SchemaModel( name = name, @@ -134,47 +129,45 @@ class ModelGenerator(private val modelPackage: String) { } // Second pass: generate FileSpecs for component schemas - val schemaFiles = - spec.schemas - .flatMap { schema -> - when { - !schema.oneOf.isNullOrEmpty() || !schema.anyOf.isNullOrEmpty() -> { - val sealedFile = generateSealedInterface(schema) - if (schema.name in anyOfWithoutDiscriminator) { - // Also generate the JsonContentPolymorphicSerializer companion object - val serializerFile = generatePolymorphicSerializer(schema, schemasById) - listOf(sealedFile, serializerFile) - } else { - listOf(sealedFile) - } + val schemaFiles = spec.schemas + .flatMap { schema -> + when { + !schema.oneOf.isNullOrEmpty() || !schema.anyOf.isNullOrEmpty() -> { + val sealedFile = generateSealedInterface(schema) + if (schema.name in anyOfWithoutDiscriminator) { + // Also generate the JsonContentPolymorphicSerializer companion object + val serializerFile = generatePolymorphicSerializer(schema, schemasById) + listOf(sealedFile, serializerFile) + } else { + listOf(sealedFile) } + } - !schema.allOf.isNullOrEmpty() -> { - listOf(generateAllOfDataClass(schema, schemasById)) - } + !schema.allOf.isNullOrEmpty() -> { + listOf(generateAllOfDataClass(schema, schemasById)) + } - isPrimitiveOnly(schema) -> { - // For primitive-only schemas, generate type alias - // TODO: Extend SchemaModel to include primitiveType field for primitive-only schemas - // For now, defaulting to String as the most common case - listOf(generateTypeAlias(schema, STRING)) - } + isPrimitiveOnly(schema) -> { + // For primitive-only schemas, generate type alias + // TODO: Extend SchemaModel to include primitiveType field for primitive-only schemas + // For now, defaulting to String as the most common case + listOf(generateTypeAlias(schema, STRING)) + } - else -> { - listOf(generateDataClass(schema)) - } + else -> { + listOf(generateDataClass(schema)) } } + } // Generate FileSpecs for inline schemas (non-nested first, nested later) val nonNestedInlineSchemas = inlineSchemas.filter { !it.name.contains(".") } val nestedInlineSchemas = inlineSchemas.filter { it.name.contains(".") } - val inlineSchemaFiles = - nonNestedInlineSchemas.map { generateDataClass(it) } + - nestedInlineSchemas.map { generateNestedInlineClass(it) } + val inlineSchemaFiles = nonNestedInlineSchemas.map(::generateDataClass) + + nestedInlineSchemas.map(::generateNestedInlineClass) - val enumFiles = spec.enums.map { generateEnumClass(it) } + val enumFiles = spec.enums.map(::generateEnumClass) // Generate SerializersModule if any sealed hierarchies exist val serializersModuleFile = SerializersModuleGenerator(modelPackage).generate(sealedHierarchies) @@ -182,18 +175,6 @@ class ModelGenerator(private val modelPackage: String) { return schemaFiles + inlineSchemaFiles + enumFiles + listOfNotNull(serializersModuleFile) } - /** - * Generates model files from [spec] and writes them to [outputDir]. - * Returns the number of files written. - */ - fun generateTo(spec: ApiSpec, outputDir: File): Int { - val files = generate(spec) - for (fileSpec in files) { - fileSpec.writeTo(outputDir) - } - return files.size - } - /** * Generates a sealed interface for a oneOf/anyOf schema. * - anyOf without discriminator: @Serializable(with = XxxSerializer::class) @@ -424,31 +405,30 @@ class ModelGenerator(private val modelPackage: String) { for (prop in sortedProps) { val baseType = TypeMapping.toTypeName(prop.type, modelPackage) - val kotlinName = prop.name.toKotlinIdentifier() + val kotlinName = prop.name.toCamelCase() // Determine final type and default value - val (type, defaultValue) = - when { - // Nullable with default -> honor nullable, ignore OpenAPI default - prop.nullable && prop.defaultValue != null -> { - baseType.copy(nullable = true) to "null" - } + val (type, defaultValue) = when { + // Nullable with default -> honor nullable, ignore OpenAPI default + prop.nullable && prop.defaultValue != null -> { + baseType.copy(nullable = true) to "null" + } - // Non-nullable with default -> use OpenAPI default - !prop.nullable && prop.defaultValue != null -> { - baseType to formatDefaultValue(prop) - } + // Non-nullable with default -> use OpenAPI default + !prop.nullable && prop.defaultValue != null -> { + baseType to formatDefaultValue(prop) + } - // Nullable without default -> nullable with null default - prop.nullable && prop.defaultValue == null -> { - baseType.copy(nullable = true) to "null" - } + // Nullable without default -> nullable with null default + prop.nullable && prop.defaultValue == null -> { + baseType.copy(nullable = true) to "null" + } - // Required without default -> no default value - else -> { - baseType to null - } + // Required without default -> no default value + else -> { + baseType to null } + } val paramBuilder = ParameterSpec.builder(kotlinName, type) if (defaultValue != null) { diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameUtils.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameUtils.kt index d7cce5dd..4811f77c 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameUtils.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameUtils.kt @@ -3,85 +3,21 @@ package com.avsystem.justworks.core.gen private val DELIMITERS = Regex("[_\\-.]+") private val CAMEL_BOUNDARY = Regex("(?<=[a-z0-9])(?=[A-Z])") -/** - * The set of Kotlin hard keywords that are reserved identifiers and require backtick escaping - * when used as property names in generated code. - */ -val KOTLIN_HARD_KEYWORDS = setOf( - "as", - "break", - "class", - "continue", - "do", - "else", - "false", - "for", - "fun", - "if", - "in", - "interface", - "is", - "null", - "object", - "package", - "return", - "super", - "this", - "throw", - "true", - "try", - "typealias", - "typeof", - "val", - "var", - "when", - "while", -) - -/** - * Converts a string to a valid Kotlin identifier for use as a property name. - * Calls [toCamelCase] first, then backtick-escapes the result if it is a Kotlin hard keyword. - * - * Examples: - * - "object" -> "`object`" - * - "in" -> "`in`" - * - "normalName" -> "normalName" - * - "my_object" -> "myObject" - */ -fun String.toKotlinIdentifier(): String { - val camelCased = toCamelCase() - return if (camelCased in KOTLIN_HARD_KEYWORDS) "`$camelCased`" else camelCased -} - /** * Converts a string to camelCase. * Splits on `_`, `-`, `.` delimiters, lowercases first segment, * capitalizes subsequent segments, and joins. */ -fun String.toCamelCase(): String { - if (isBlank()) return this - val parts = - split(DELIMITERS).filter { it.isNotEmpty() }.flatMap { it.split(CAMEL_BOUNDARY) }.filter { it.isNotEmpty() } - if (parts.isEmpty()) return this - return buildString { - append(parts.first().lowercase()) - for (i in 1 until parts.size) { - append(parts[i].replaceFirstChar { it.uppercaseChar() }) - } - } -} +fun String.toCamelCase(): String = toPascalCase().replaceFirstChar { it.lowercaseChar() } /** * Converts a string to PascalCase. * Like [toCamelCase] but capitalizes the first segment too. */ -fun String.toPascalCase(): String { - if (isBlank()) return this - val parts = - split(DELIMITERS).filter { it.isNotEmpty() }.flatMap { it.split(CAMEL_BOUNDARY) }.filter { it.isNotEmpty() } - if (parts.isEmpty()) return this - return parts.joinToString("") { it.replaceFirstChar { c -> c.uppercaseChar() } } -} +fun String.toPascalCase(): String = split(DELIMITERS) + .filter { it.isNotEmpty() } + .flatMap { it.split(CAMEL_BOUNDARY) } + .joinToString("") { it.lowercase().replaceFirstChar { c -> c.uppercaseChar() } } /** * Converts any string to UPPER_SNAKE_CASE for use as an enum constant name. @@ -90,12 +26,16 @@ fun String.toPascalCase(): String { * Prefixes with `VALUE_` if the first character is a digit. */ fun String.toEnumConstantName(): String { - if (isBlank()) return this - val snaked = replace(CAMEL_BOUNDARY, "_") - val cleaned = snaked.map { c -> if (c.isLetterOrDigit()) c.uppercaseChar() else '_' }.joinToString("") - val deduped = cleaned.replace(Regex("_+"), "_").trim('_') - if (deduped.isEmpty()) return this - return if (deduped.first().isDigit()) "VALUE_$deduped" else deduped + val converted = replace(CAMEL_BOUNDARY, "_") + .replace(Regex("[^a-zA-Z0-9]+"), "_") + .trim('_') + .uppercase() + + return when { + converted.isEmpty() -> this + converted[0].isDigit() -> "VALUE_$converted" + else -> converted + } } /** diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt index 6f201fa2..c7b1676e 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt @@ -3,10 +3,9 @@ package com.avsystem.justworks.core.gen import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.MemberName -// Centralized repository of type and function references used across code generators. -// Organized by domain (HTTP client, serialization, dates, error handling). - -// -- Ktor HTTP Client -- +// ============================================================================ +// Ktor HTTP Client +// ============================================================================ val HTTP_CLIENT = ClassName("io.ktor.client", "HttpClient") val CONTENT_NEGOTIATION = ClassName("io.ktor.client.plugins.contentnegotiation", "ContentNegotiation") diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt index bc53c99d..3baa7473 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt @@ -13,7 +13,7 @@ import java.io.File * Produces a top-level `val generatedSerializersModule: SerializersModule` property * that registers each sealed interface with its subclass variants. */ -class SerializersModuleGenerator(private val modelPackage: String,) { +class SerializersModuleGenerator(private val modelPackage: String) { /** * Generates a [FileSpec] containing the SerializersModule registration. * Returns null if [sealedHierarchies] is empty (no polymorphic types to register). @@ -23,10 +23,7 @@ class SerializersModuleGenerator(private val modelPackage: String,) { fun generate(sealedHierarchies: Map>): FileSpec? { if (sealedHierarchies.isEmpty()) return null - val code = - CodeBlock - .builder() - .beginControlFlow("%T", SERIALIZERS_MODULE) + val code = CodeBlock.builder().beginControlFlow("%T", SERIALIZERS_MODULE) for ((parent, variants) in sealedHierarchies) { val parentClass = ClassName(modelPackage, parent) @@ -51,14 +48,4 @@ class SerializersModuleGenerator(private val modelPackage: String,) { .addProperty(prop) .build() } - - /** - * Generates the SerializersModule file and writes it to [outputDir]. - * Returns 0 if no polymorphic types exist, 1 if a file was written. - */ - fun generateTo(sealedHierarchies: Map>, outputDir: File,): Int { - val fileSpec = generate(sealedHierarchies) ?: return 0 - fileSpec.writeTo(outputDir) - return 1 - } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiResponseGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiResponseGeneratorTest.kt index 5f9942ac..973a28ad 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiResponseGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ApiResponseGeneratorTest.kt @@ -10,19 +10,19 @@ import kotlin.test.assertTrue class ApiResponseGeneratorTest { private fun httpErrorClass(): TypeSpec { - val files = listOf(ApiResponseGenerator.generateHttpError(), ApiResponseGenerator.generateSuccess()) + val files = listOf(ApiResponseGenerator.generateHttpError(), ApiResponseGenerator.generateHttpSuccess()) val httpErrorFile = files.first { it.name == "HttpError" } return httpErrorFile.members.filterIsInstance().first { it.name == "HttpError" } } private fun httpErrorTypeEnum(): TypeSpec { - val files = listOf(ApiResponseGenerator.generateHttpError(), ApiResponseGenerator.generateSuccess()) + val files = listOf(ApiResponseGenerator.generateHttpError(), ApiResponseGenerator.generateHttpSuccess()) val httpErrorFile = files.first { it.name == "HttpError" } return httpErrorFile.members.filterIsInstance().first { it.name == "HttpErrorType" } } private fun successClass(): TypeSpec { - val files = listOf(ApiResponseGenerator.generateHttpError(), ApiResponseGenerator.generateSuccess()) + val files = listOf(ApiResponseGenerator.generateHttpError(), ApiResponseGenerator.generateHttpSuccess()) val successFile = files.first { it.name == "HttpSuccess" } return successFile.members.filterIsInstance().first() } @@ -70,7 +70,7 @@ class ApiResponseGeneratorTest { @Test fun `generates two files`() { - val files = listOf(ApiResponseGenerator.generateHttpError(), ApiResponseGenerator.generateSuccess()) + val files = listOf(ApiResponseGenerator.generateHttpError(), ApiResponseGenerator.generateHttpSuccess()) assertEquals(2, files.size) val fileNames = files.map { it.name }.sorted() assertEquals(listOf("HttpError", "HttpSuccess"), fileNames) 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 5524aad3..cf06c2c3 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 @@ -844,9 +844,7 @@ class ModelGeneratorTest { .filterIsInstance() .first() - // Property should be backtick-escaped val prop = typeSpec.propertySpecs.first() - assertEquals("`object`", prop.name, "Property named 'object' should be backtick-escaped") // @SerialName should still use the original wire name val serialName = prop.annotations.first { it.typeName.toString() == "kotlinx.serialization.SerialName" } @@ -856,30 +854,6 @@ class ModelGeneratorTest { ) } - @Test - fun `property named with Kotlin keyword 'in' generates backtick-escaped name`() { - val schema = SchemaModel( - name = "Filter", - description = null, - properties = listOf( - PropertyModel("in", TypeRef.Primitive(PrimitiveType.STRING), null, true), - ), - requiredProperties = emptySet(), - allOf = null, - oneOf = null, - anyOf = null, - discriminator = null, - ) - val files = generator.generate(spec(schemas = listOf(schema))) - val typeSpec = files - .first() - .members - .filterIsInstance() - .first() - val prop = typeSpec.propertySpecs.first() - assertEquals("`in`", prop.name, "Property named 'in' should be backtick-escaped") - } - // -- ROB-01: Circular schema visited-set guard -- @Test diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/NameUtilsTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/NameUtilsTest.kt index eb0d814b..e3eb2887 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/NameUtilsTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/NameUtilsTest.kt @@ -16,13 +16,6 @@ class NameUtilsTest { assertEquals("kebabCase", "kebab-case".toCamelCase()) } - @Test - fun `toCamelCase converts UPPER_CASE preserving segment casing`() { - // Splits on _, gets ["UPPER", "CASE"]. No camel boundaries in all-caps segments. - // First segment lowercased -> "upper", second replaceFirstChar -> "CASE" (already upper). - assertEquals("upperCASE", "UPPER_CASE".toCamelCase()) - } - @Test fun `toCamelCase lowercases PascalCase`() { assertEquals("pascalCase", "PascalCase".toCamelCase()) @@ -114,46 +107,4 @@ class NameUtilsTest { fun `operationNameFromPath handles camelCase path parameter`() { assertEquals("GetUsersByUserId", operationNameFromPath("GET", "/users/{userId}")) } - - // -- toKotlinIdentifier -- - - @Test - fun `toKotlinIdentifier backtick-escapes object keyword`() { - assertEquals("`object`", "object".toKotlinIdentifier()) - } - - @Test - fun `toKotlinIdentifier backtick-escapes in keyword`() { - assertEquals("`in`", "in".toKotlinIdentifier()) - } - - @Test - fun `toKotlinIdentifier backtick-escapes class keyword`() { - assertEquals("`class`", "class".toKotlinIdentifier()) - } - - @Test - fun `toKotlinIdentifier leaves non-keyword unchanged`() { - assertEquals("normalName", "normalName".toKotlinIdentifier()) - } - - @Test - fun `toKotlinIdentifier camelCases non-keyword with underscore`() { - assertEquals("myObject", "my_object".toKotlinIdentifier()) - } - - @Test - fun `toKotlinIdentifier backtick-escapes val keyword`() { - assertEquals("`val`", "val".toKotlinIdentifier()) - } - - @Test - fun `toKotlinIdentifier backtick-escapes fun keyword`() { - assertEquals("`fun`", "fun".toKotlinIdentifier()) - } - - @Test - fun `toKotlinIdentifier backtick-escapes return keyword`() { - assertEquals("`return`", "return".toKotlinIdentifier()) - } } From dc4c2956c1d3e0ee0f75b938911d558ec274c768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Tue, 17 Mar 2026 14:44:34 +0100 Subject: [PATCH 8/8] refactor: remove unused methods, simplify parameter handling, and improve readability across core generators - Removed `generateTo` methods from `ApiResponseGenerator` and `ClientGenerator` for unused file-writing logic. - Simplified parameter grouping and processing in `ClientGenerator` using cleaner mappings and utility methods. - Consolidated inline schema resolution in `ModelGenerator` to use a worklist for more efficient and readable logic. - Adjusted formatting and applied constants for improved consistency across modules. --- .../core/gen/ApiResponseGenerator.kt | 12 - .../justworks/core/gen/ClientGenerator.kt | 246 +++++++----------- .../core/gen/InlineSchemaDeduplicator.kt | 10 +- .../justworks/core/gen/ModelGenerator.kt | 70 +++-- 4 files changed, 129 insertions(+), 209 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiResponseGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiResponseGenerator.kt index fefc8cee..ab00750c 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiResponseGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiResponseGenerator.kt @@ -35,18 +35,6 @@ object ApiResponseGenerator { .initializer("body") .build() - /** - * Generates files and writes them to [outputDir]. - * Returns the number of files written. - */ - fun generateTo(outputDir: File): Int { - val files = listOf(generateHttpError(), generateHttpSuccess()) - for (fileSpec in files) { - fileSpec.writeTo(outputDir) - } - return files.size - } - fun generateHttpError(): FileSpec { val enumType = TypeSpec 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 0cc36274..021de1fe 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 @@ -3,7 +3,9 @@ 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.HttpMethod +import com.avsystem.justworks.core.model.Parameter import com.avsystem.justworks.core.model.ParameterLocation +import com.avsystem.justworks.core.model.TypeRef import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.ContextParameter @@ -16,9 +18,16 @@ import com.squareup.kotlinpoet.ParameterSpec import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.PropertySpec import com.squareup.kotlinpoet.STRING +import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.UNIT -import java.io.File + +private const val BASE_URL = "baseUrl" +private const val TOKEN_PROVIDER = "tokenProvider" +private const val CLIENT = "client" +private const val BODY = "body" +private const val DEFAULT_TAG = "Default" +private const val API_SUFFIX = "Api" /** * Generates one KotlinPoet [FileSpec] per API tag, each containing a client class @@ -27,26 +36,8 @@ import java.io.File @OptIn(ExperimentalKotlinPoetApi::class) class ClientGenerator(private val apiPackage: String, private val modelPackage: String) { fun generate(spec: ApiSpec, hasPolymorphicTypes: Boolean = false): List { - val grouped = spec.endpoints.groupBy { it.tags.firstOrNull() ?: "Default" } - return grouped.map { (tag, endpoints) -> - generateClientFile(tag, endpoints, hasPolymorphicTypes) - } - } - - /** - * Generates client files from [spec] and writes them to [outputDir]. - * Returns the number of files written. - */ - fun generateTo( - spec: ApiSpec, - outputDir: File, - hasPolymorphicTypes: Boolean = false, - ): Int { - val files = generate(spec, hasPolymorphicTypes) - for (fileSpec in files) { - fileSpec.writeTo(outputDir) - } - return files.size + val grouped = spec.endpoints.groupBy { it.tags.firstOrNull() ?: DEFAULT_TAG } + return grouped.map { (tag, endpoints) -> generateClientFile(tag, endpoints, hasPolymorphicTypes) } } private fun generateClientFile( @@ -54,7 +45,7 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: endpoints: List, hasPolymorphicTypes: Boolean = false, ): FileSpec { - val className = ClassName(apiPackage, "${tag.toPascalCase()}Api") + val className = ClassName(apiPackage, "${tag.toPascalCase()}$API_SUFFIX") val clientInitializer = if (hasPolymorphicTypes) { val generatedSerializersModule = MemberName(modelPackage, "generatedSerializersModule") @@ -63,33 +54,29 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: CodeBlock.of("createHttpClient()") } - val classBuilder = - TypeSpec - .classBuilder(className) - .superclass(API_CLIENT_BASE) - .addSuperclassConstructorParameter("baseUrl") - .addSuperclassConstructorParameter("tokenProvider") - .primaryConstructor( - FunSpec - .constructorBuilder() - .addParameter("baseUrl", STRING) - .addParameter("tokenProvider", STRING) - .build(), - ).addProperty( - PropertySpec - .builder("client", HTTP_CLIENT) - .addModifiers(KModifier.OVERRIDE, KModifier.PROTECTED) - .initializer(clientInitializer) - .build(), - ) - - for (endpoint in endpoints) { - classBuilder.addFunction(generateEndpointFunction(endpoint)) - } + val classBuilder = TypeSpec + .classBuilder(className) + .superclass(API_CLIENT_BASE) + .addSuperclassConstructorParameter(BASE_URL) + .addSuperclassConstructorParameter(TOKEN_PROVIDER) + .primaryConstructor( + FunSpec + .constructorBuilder() + .addParameter(BASE_URL, STRING) + .addParameter(TOKEN_PROVIDER, STRING) + .build(), + ).addProperty( + PropertySpec + .builder(CLIENT, HTTP_CLIENT) + .addModifiers(KModifier.OVERRIDE, KModifier.PROTECTED) + .initializer(clientInitializer) + .build(), + ) + + classBuilder.addFunctions(endpoints.map(::generateEndpointFunction)) return FileSpec .builder(className) - .addImport("com.avsystem.justworks", "encodeParam") .addType(classBuilder.build()) .build() } @@ -106,69 +93,49 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: .contextParameters(listOf(ContextParameter(RAISE.parameterizedBy(HTTP_ERROR)))) .returns(returnType) - // Add parameters: PATH first, then QUERY, then HEADER, then body - val pathParams = endpoint.parameters.filter { it.location == ParameterLocation.PATH } - val queryParams = endpoint.parameters.filter { it.location == ParameterLocation.QUERY } - val headerParams = endpoint.parameters.filter { it.location == ParameterLocation.HEADER } + val params = endpoint.parameters.groupBy { it.location } - for (param in pathParams) { - val type = TypeMapping.toTypeName(param.schema, modelPackage) - funBuilder.addParameter(param.name.toCamelCase(), type) + val pathParams = params[ParameterLocation.PATH].orEmpty().map { param -> + ParameterSpec(param.name.toCamelCase(), TypeMapping.toTypeName(param.schema, modelPackage)) } - for (param in queryParams) { - val baseType = TypeMapping.toTypeName(param.schema, modelPackage) - if (param.required) { - funBuilder.addParameter(param.name.toCamelCase(), baseType) - } else { - funBuilder.addParameter( - ParameterSpec - .builder(param.name.toCamelCase(), baseType.copy(nullable = true)) - .defaultValue("null") - .build(), - ) - } + val queryParams = params[ParameterLocation.QUERY].orEmpty().map { param -> + buildNullableParameter(param.schema, param.name, param.required) } - for (param in headerParams) { - val baseType = TypeMapping.toTypeName(param.schema, modelPackage) - if (param.required) { - funBuilder.addParameter(param.name.toCamelCase(), baseType) - } else { - funBuilder.addParameter( - ParameterSpec - .builder(param.name.toCamelCase(), baseType.copy(nullable = true)) - .defaultValue("null") - .build(), - ) - } + val headerParams = params[ParameterLocation.HEADER].orEmpty().map { param -> + buildNullableParameter(param.schema, param.name, param.required) } + funBuilder.addParameters(pathParams + queryParams + headerParams) + if (endpoint.requestBody != null) { - val bodyType = TypeMapping.toTypeName(endpoint.requestBody.schema, modelPackage) - if (endpoint.requestBody.required) { - funBuilder.addParameter("body", bodyType) - } else { - funBuilder.addParameter( - ParameterSpec - .builder("body", bodyType.copy(nullable = true)) - .defaultValue("null") - .build(), - ) - } + funBuilder.addParameter( + buildNullableParameter(endpoint.requestBody.schema, BODY, endpoint.requestBody.required), + ) } - funBuilder.addCode(buildFunctionBody(endpoint, pathParams, queryParams, headerParams, returnBodyType)) + funBuilder.addCode(buildFunctionBody(endpoint, params, returnBodyType)) return funBuilder.build() } + private fun buildNullableParameter( + typeRef: TypeRef, + name: String, + required: Boolean, + ): ParameterSpec { + val baseType = TypeMapping.toTypeName(typeRef, modelPackage) + + val builder = ParameterSpec.builder(name.toCamelCase(), baseType.copy(nullable = !required)) + if (!required) builder.defaultValue("null") + return builder.build() + } + private fun buildFunctionBody( endpoint: Endpoint, - pathParams: List, - queryParams: List, - headerParams: List, - returnBodyType: com.squareup.kotlinpoet.TypeName, + params: Map>, + returnBodyType: TypeName, ): CodeBlock { val httpMethodFun = when (endpoint.method) { @@ -179,89 +146,55 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: HttpMethod.PATCH -> PATCH_FUN } - val urlString = buildUrlString(endpoint.path, pathParams) + val (format, args) = params[ParameterLocation.PATH] + .orEmpty() + .fold($$"${'$'}{baseUrl}" + endpoint.path to emptyList()) { (format, args), param -> + format.replace("{${param.name}}", $$"${%M(%L)}") to args + ENCODE_PARAM_FUN + param.name.toCamelCase() + } + + val urlString = CodeBlock.of("%P", CodeBlock.of(format, *args.toTypedArray())) val resultFun = if (returnBodyType == UNIT) TO_EMPTY_RESULT_FUN else TO_RESULT_FUN val code = CodeBlock.builder() - // safeCall { - code.add("return safeCall {\n") - code.indent() + code.beginControlFlow("return safeCall") - // client.METHOD(url) { - code.add("client.%M(%L) {\n", httpMethodFun, urlString) - code.indent() - - // applyAuth() + code.beginControlFlow("client.%M(%L)", httpMethodFun, urlString) code.addStatement("applyAuth()") - // custom headers (if any) - if (headerParams.isNotEmpty()) { - code.beginControlFlow("%M", HEADERS_FUN) - for (param in headerParams) { - val paramName = param.name.toCamelCase() - if (param.required) { - code.addStatement("append(%S, encodeParam(%L))", param.name, paramName) - } else { - code.beginControlFlow("if (%L != null)", paramName) - code.addStatement("append(%S, encodeParam(%L))", param.name, paramName) - code.endControlFlow() - } + for (param in params[ParameterLocation.HEADER].orEmpty()) { + val paramName = param.name.toCamelCase() + code.optionalGuard(param.required, paramName) { + addStatement("append(%S, %M(%L))", param.name, ENCODE_PARAM_FUN, paramName) } - code.endControlFlow() } - // query params (if any) - if (queryParams.isNotEmpty()) { + if (!params[ParameterLocation.QUERY].isNullOrEmpty()) { code.beginControlFlow("url") - for (param in queryParams) { + for (param in params[ParameterLocation.QUERY]!!) { val paramName = param.name.toCamelCase() - if (param.required) { - code.addStatement("this.parameters.append(%S, encodeParam(%L))", param.name, paramName) - } else { - code.beginControlFlow("if (%L != null)", paramName) - code.addStatement("this.parameters.append(%S, encodeParam(%L))", param.name, paramName) - code.endControlFlow() + code.optionalGuard(param.required, paramName) { + addStatement("this.parameters.append(%S, %M(%L))", param.name, ENCODE_PARAM_FUN, paramName) } } code.endControlFlow() } - // request body if (endpoint.requestBody != null) { - if (endpoint.requestBody.required) { - code.addStatement("%M(%M.Application.Json)", CONTENT_TYPE_FUN, CONTENT_TYPE_APP_JSON) - code.addStatement("%M(body)", SET_BODY_FUN) - } else { - code.beginControlFlow("if (body != null)") - code.addStatement("%M(%M.Application.Json)", CONTENT_TYPE_FUN, CONTENT_TYPE_APP_JSON) - code.addStatement("%M(body)", SET_BODY_FUN) - code.endControlFlow() + code.optionalGuard(endpoint.requestBody.required, BODY) { + addStatement("%M(%M.Application.Json)", CONTENT_TYPE_FUN, CONTENT_TYPE_APP_JSON) + addStatement("%M(%L)", SET_BODY_FUN, BODY) } } + code.endControlFlow() // client.METHOD code.unindent() - code.add("}\n") // close request lambda - - code.unindent() - code.add("}.%M()\n", resultFun) // close safeCall + chain toResult + code.add("}.%M()\n", resultFun) return code.build() } - private fun buildUrlString( - path: String, - pathParams: List, - ): CodeBlock { - var template = path - for (param in pathParams) { - val camelName = param.name.toCamelCase() - template = template.replace("{${param.name}}", "\${encodeParam($camelName)}") - } - return CodeBlock.of("%P", CodeBlock.of("\${'$'}{baseUrl}$template")) - } - - private fun resolveReturnType(endpoint: Endpoint): com.squareup.kotlinpoet.TypeName { + private fun resolveReturnType(endpoint: Endpoint): TypeName { val successResponse = endpoint.responses.entries .filter { it.key.startsWith("2") } @@ -273,4 +206,17 @@ class ClientGenerator(private val apiPackage: String, private val modelPackage: UNIT } } + + /** + * If [required], emits [block] directly. Otherwise wraps it in `if (name != null) { ... }`. + */ + private inline fun CodeBlock.Builder.optionalGuard( + required: Boolean, + name: String, + block: CodeBlock.Builder.() -> Unit, + ) { + if (!required) beginControlFlow("if (%L != null)", name) + block() + if (!required) endControlFlow() + } } 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 index c937ded9..fa52ca3c 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDeduplicator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDeduplicator.kt @@ -9,7 +9,7 @@ import com.avsystem.justworks.core.model.TypeRef * 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: List, val requiredProperties: Set,) { +data class InlineSchemaKey(val properties: List, val requiredProperties: Set) { data class PropertyKey( val name: String, val type: TypeRef, @@ -67,14 +67,12 @@ class InlineSchemaDeduplicator { ): String { val key = InlineSchemaKey.from(properties, requiredProps) - // Check if we've already generated a name for this structure namesByKey[key]?.let { return it } // Generate new name, handling collisions - var finalName = contextName - if (finalName in componentSchemaNames || finalName in namesByKey.values) { - finalName = "${contextName}Inline" - } + val finalName = contextName + .takeUnless { it in componentSchemaNames || it in namesByKey.values } + ?: "${contextName}Inline" namesByKey[key] = finalName return finalName 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 cec4cce3..b92a3620 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 @@ -1,5 +1,6 @@ package com.avsystem.justworks.core.gen +import arrow.core.tail import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.EnumModel import com.avsystem.justworks.core.model.PrimitiveType @@ -294,7 +295,6 @@ class ModelGenerator(private val modelPackage: String) { */ private fun buildSelectDeserializerBody( parentName: String, - sealedClassName: ClassName, uniqueFieldsPerVariant: List>, ): CodeBlock { val builder = CodeBlock.builder() @@ -554,28 +554,19 @@ class ModelGenerator(private val modelPackage: String) { * Resolves the @SerialName value for a variant within a oneOf schema. * Uses discriminator mapping if available, otherwise defaults to the schema name. */ - private fun resolveSerialName(parentSchema: SchemaModel, variantSchemaName: String): String { - val mapping = parentSchema.discriminator?.mapping - if (!mapping.isNullOrEmpty()) { - // mapping is: serialName -> ref path (e.g., "circle" -> "#/components/schemas/Circle") - for ((serialName, refPath) in mapping) { - val refName = refPath.removePrefix("#/components/schemas/") - if (refName == variantSchemaName) { - return serialName - } + private fun resolveSerialName(parentSchema: SchemaModel, variantSchemaName: String): String = + parentSchema.discriminator + ?.mapping + .orEmpty() + .firstNotNullOfOrNull { (serialName, refPath) -> + serialName.takeIf { refPath.removePrefix("#/components/schemas/") == variantSchemaName } } - } - // Default: use schema name as serial name - return variantSchemaName - } + ?: variantSchemaName private fun generateEnumClass(enum: EnumModel): FileSpec { val className = ClassName(modelPackage, enum.name) - val typeSpec = - TypeSpec - .enumBuilder(className) - .addAnnotation(SERIALIZABLE) + val typeSpec = TypeSpec.enumBuilder(className).addAnnotation(SERIALIZABLE) for (value in enum.values) { val constantName = value.toEnumConstantName() @@ -602,33 +593,30 @@ class ModelGenerator(private val modelPackage: String) { } /** - * Recursively collects all TypeRef.Inline instances from a TypeRef tree. - * The [visited] set guards against infinite recursion in circular schemas. + * Iteratively collects all [TypeRef.Inline] instances from a [TypeRef] tree. + * Uses a worklist to avoid stack overflow on deeply nested schemas. */ - private fun collectInlineTypeRefs( - typeRef: TypeRef, - result: MutableList, - visited: MutableSet = mutableSetOf(), - ) { - when (typeRef) { - is TypeRef.Inline -> { - if (!visited.add(typeRef)) return - result.add(typeRef) - // Recursively collect from properties - typeRef.properties.forEach { prop -> - collectInlineTypeRefs(prop.type, result, visited) - } - } + private tailrec fun collectInlineTypeRefs( + visited: Set = emptySet(), + acc: List = emptyList(), + todo: List, + ): List = if (todo.isEmpty()) { + acc + } else { + when (val current = todo.firstOrNull()) { + is TypeRef.Inline if current in visited -> collectInlineTypeRefs(visited, acc, todo.tail()) + + is TypeRef.Inline -> collectInlineTypeRefs( + visited + current, + acc + current, + current.properties.map { it.type } + todo.tail(), + ) - is TypeRef.Array -> { - collectInlineTypeRefs(typeRef.items, result, visited) - } + is TypeRef.Array -> collectInlineTypeRefs(visited, acc, todo.tail() + current.items) - is TypeRef.Map -> { - collectInlineTypeRefs(typeRef.valueType, result, visited) - } + is TypeRef.Map -> collectInlineTypeRefs(visited, acc, todo.tail() + current.valueType) - is TypeRef.Primitive, is TypeRef.Reference, is TypeRef.Unknown -> {} + else -> collectInlineTypeRefs(visited, acc, todo.tail()) } }