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..ab00750c --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiResponseGenerator.kt @@ -0,0 +1,100 @@ +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.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() + + 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 generateHttpSuccess(): 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..021de1fe --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ClientGenerator.kt @@ -0,0 +1,222 @@ +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 +import com.squareup.kotlinpoet.ExperimentalKotlinPoetApi +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +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.TypeName +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.UNIT + +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 + * 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) { + fun generate(spec: ApiSpec, hasPolymorphicTypes: Boolean = false): List { + val grouped = spec.endpoints.groupBy { it.tags.firstOrNull() ?: DEFAULT_TAG } + return grouped.map { (tag, endpoints) -> generateClientFile(tag, endpoints, hasPolymorphicTypes) } + } + + private fun generateClientFile( + tag: String, + endpoints: List, + hasPolymorphicTypes: Boolean = false, + ): FileSpec { + val className = ClassName(apiPackage, "${tag.toPascalCase()}$API_SUFFIX") + + val clientInitializer = if (hasPolymorphicTypes) { + val generatedSerializersModule = MemberName(modelPackage, "generatedSerializersModule") + CodeBlock.of("createHttpClient(%M)", generatedSerializersModule) + } else { + CodeBlock.of("createHttpClient()") + } + + 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) + .addType(classBuilder.build()) + .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) + + val params = endpoint.parameters.groupBy { it.location } + + val pathParams = params[ParameterLocation.PATH].orEmpty().map { param -> + ParameterSpec(param.name.toCamelCase(), TypeMapping.toTypeName(param.schema, modelPackage)) + } + + val queryParams = params[ParameterLocation.QUERY].orEmpty().map { param -> + buildNullableParameter(param.schema, param.name, param.required) + } + + val headerParams = params[ParameterLocation.HEADER].orEmpty().map { param -> + buildNullableParameter(param.schema, param.name, param.required) + } + + funBuilder.addParameters(pathParams + queryParams + headerParams) + + if (endpoint.requestBody != null) { + funBuilder.addParameter( + buildNullableParameter(endpoint.requestBody.schema, BODY, endpoint.requestBody.required), + ) + } + + 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, + params: Map>, + returnBodyType: 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 + } + + 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() + + code.beginControlFlow("return safeCall") + + code.beginControlFlow("client.%M(%L)", httpMethodFun, urlString) + code.addStatement("applyAuth()") + + 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) + } + } + + if (!params[ParameterLocation.QUERY].isNullOrEmpty()) { + code.beginControlFlow("url") + for (param in params[ParameterLocation.QUERY]!!) { + val paramName = param.name.toCamelCase() + code.optionalGuard(param.required, paramName) { + addStatement("this.parameters.append(%S, %M(%L))", param.name, ENCODE_PARAM_FUN, paramName) + } + } + code.endControlFlow() + } + + if (endpoint.requestBody != null) { + 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("}.%M()\n", resultFun) + + return code.build() + } + + private fun resolveReturnType(endpoint: Endpoint): TypeName { + val successResponse = + endpoint.responses.entries + .filter { it.key.startsWith("2") } + .firstNotNullOfOrNull { it.value.schema } + + return if (successResponse != null) { + TypeMapping.toTypeName(successResponse, modelPackage) + } else { + 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 new file mode 100644 index 00000000..fa52ca3c --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDeduplicator.kt @@ -0,0 +1,80 @@ +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) + + namesByKey[key]?.let { return it } + + // Generate new name, handling collisions + 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 new file mode 100644 index 00000000..b92a3620 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt @@ -0,0 +1,662 @@ +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 +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 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, + ) + + inlineSchemas.add( + SchemaModel( + name = name, + description = null, + properties = inlineTypeRef.properties, + requiredProperties = inlineTypeRef.requiredProperties, + 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) { + 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 + .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) + + nestedInlineSchemas.map(::generateNestedInlineClass) + + val enumFiles = spec.enums.map(::generateEnumClass) + + // Generate SerializersModule if any sealed hierarchies exist + val serializersModuleFile = SerializersModuleGenerator(modelPackage).generate(sealedHierarchies) + + return schemaFiles + inlineSchemaFiles + enumFiles + listOfNotNull(serializersModuleFile) + } + + /** + * 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, + 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.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" + } + + // 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 = + parentSchema.discriminator + ?.mapping + .orEmpty() + .firstNotNullOfOrNull { (serialName, refPath) -> + serialName.takeIf { refPath.removePrefix("#/components/schemas/") == variantSchemaName } + } + ?: 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() + } + + /** + * Iteratively collects all [TypeRef.Inline] instances from a [TypeRef] tree. + * Uses a worklist to avoid stack overflow on deeply nested schemas. + */ + 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(visited, acc, todo.tail() + current.items) + + is TypeRef.Map -> collectInlineTypeRefs(visited, acc, todo.tail() + current.valueType) + + else -> collectInlineTypeRefs(visited, acc, todo.tail()) + } + } + + /** + * 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.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..4811f77c --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameUtils.kt @@ -0,0 +1,69 @@ +package com.avsystem.justworks.core.gen + +private val DELIMITERS = Regex("[_\\-.]+") +private val CAMEL_BOUNDARY = Regex("(?<=[a-z0-9])(?=[A-Z])") + +/** + * Converts a string to camelCase. + * Splits on `_`, `-`, `.` delimiters, lowercases first segment, + * capitalizes subsequent segments, and joins. + */ +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 = 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. + * 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 { + 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 + } +} + +/** + * 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..c7b1676e --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt @@ -0,0 +1,81 @@ +package com.avsystem.justworks.core.gen + +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.MemberName + +// ============================================================================ +// 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") + +// ============================================================================ +// 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/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..3baa7473 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt @@ -0,0 +1,51 @@ +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() + } +} 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..0c347d40 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/TypeMapping.kt @@ -0,0 +1,60 @@ +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 -> { + 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 -> { + 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 + } + } +} 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..973a28ad --- /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.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.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.generateHttpSuccess()) + 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.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/ClientGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt new file mode 100644 index 00000000..2eef2072 --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt @@ -0,0 +1,405 @@ +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.associate { + it.name to it.body.toString() + } + 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 extends ApiClientBase -- + + @Test + fun `client class extends ApiClientBase`() { + val cls = clientClass(listOf(endpoint())) + assertEquals("com.avsystem.justworks.ApiClientBase", cls.superclass.toString()) + } + + // -- SER-01: Polymorphic spec wires SerializersModule -- + + @Test + fun `polymorphic spec wires serializersModule in createHttpClient call`() { + 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("generatedSerializersModule"), + "Expected generatedSerializersModule reference", + ) + assertTrue(clientInitializer.contains("createHttpClient"), "Expected createHttpClient call") + } + + @Test + fun `non-polymorphic spec has createHttpClient without 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("createHttpClient()"), "Expected plain createHttpClient()") + assertTrue(!clientInitializer.contains("serializersModule"), "Expected no serializersModule") + } + + // -- Generated code uses shared helpers -- + + @Test + 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("applyAuth()"), "Expected applyAuth() call") + } + + @Test + 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("safeCall"), "Expected safeCall call") + } + + @Test + 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("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") + } +} 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..5391c29f --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDedupTest.kt @@ -0,0 +1,143 @@ +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(), + 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..30486e2a --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorPolymorphicTest.kt @@ -0,0 +1,644 @@ +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, + 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..cf06c2c3 --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorTest.kt @@ -0,0 +1,997 @@ +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"), + 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"), + 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"), + 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(), + 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"), + 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(), + 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"), + 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(), + 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"), + 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(), + 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"), + 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"), + 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"), + 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"), + 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"), + 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"), + 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"), + 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"), + 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(), + 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"), + 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(), + 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(), + 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"), + 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() + + // @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", + ) + } + + // -- 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"), + 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"), + 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"), + 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"), + 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"), + 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..e3eb2887 --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/NameUtilsTest.kt @@ -0,0 +1,110 @@ +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 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}")) + } +} 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()) + } +}