diff --git a/.gitignore b/.gitignore index f4ad3507..a0989c52 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ build/ .gradle/ .kotlin/ .idea/ +.planning/ diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDeduplicator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDeduplicator.kt index eef16641..c937ded9 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDeduplicator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDeduplicator.kt @@ -1,6 +1,7 @@ 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 /** @@ -8,7 +9,7 @@ import com.avsystem.justworks.core.model.TypeRef * Two inline schemas are considered equal if they have the same properties * (name, type, required status) regardless of property order. */ -data class InlineSchemaKey(val properties: Set) { +data class InlineSchemaKey(val properties: List, val requiredProperties: Set,) { data class PropertyKey( val name: String, val type: TypeRef, @@ -16,9 +17,16 @@ data class InlineSchemaKey(val properties: Set) { ) companion object { - fun from(properties: List, required: Set) = InlineSchemaKey( - properties = properties.map { PropertyKey(it.name, it.type, it.name in required) }.toSet(), - ) + /** + * 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) + } } } @@ -27,24 +35,48 @@ data class InlineSchemaKey(val properties: Set) { * Ensures that structurally identical inline schemas generate only one class, * and handles name collisions with component schemas. */ -class InlineSchemaDeduplicator(private val componentSchemaNames: Set) { +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 = namesByKey.getOrPut(InlineSchemaKey.from(properties, requiredProps)) { - val inlineName = contextName.toInlinedName() - val candidates = sequence { - yield(inlineName) - yield("${inlineName}Inline") - generateSequence(2) { it + 1 }.forEach { - yield("${inlineName}${it}Inline") - } + ): String { + val key = InlineSchemaKey.from(properties, requiredProps) + + // Check if we've already generated a name for this structure + namesByKey[key]?.let { return it } + + // Generate new name, handling collisions + var finalName = contextName + if (finalName in componentSchemaNames || finalName in namesByKey.values) { + finalName = "${contextName}Inline" } - val existingNames = (componentSchemaNames + namesByKey.values).toSet() - candidates.first { it !in existingNames } + namesByKey[key] = finalName + return finalName } } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt index d59a5f0f..b305fc42 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt @@ -1,6 +1,5 @@ package com.avsystem.justworks.core.gen -import arrow.core.raise.catch import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.EnumModel import com.avsystem.justworks.core.model.PrimitiveType @@ -18,10 +17,10 @@ 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.TypeName import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.WildcardTypeName -import kotlinx.datetime.LocalDate +import com.squareup.kotlinpoet.asTypeName +import java.io.File import kotlin.time.Instant /** @@ -31,108 +30,168 @@ import kotlin.time.Instant * and one file per [EnumModel] (enum class), all annotated with kotlinx.serialization annotations. */ class ModelGenerator(private val modelPackage: String) { - fun generate(spec: ApiSpec): List = context( - buildHierarchyInfo(spec.schemas), - InlineSchemaDeduplicator(spec.schemas.map { it.name }.toSet()), - ) { - val schemaFiles = spec.schemas.flatMap { generateSchemaFiles(it) } - - val inlineSchemaFiles = collectAllInlineSchemas(spec).map { - if (it.isNested) generateNestedInlineClass(it) else generateDataClass(it) - } + // Maps sealed parent name -> list of variant schema names + private val sealedHierarchies = mutableMapOf>() - val enumFiles = spec.enums.map(::generateEnumClass) + // Maps variant schema name -> (parent ClassName, serialName) + private val variantParents = mutableMapOf>>() - val serializersModuleFile = SerializersModuleGenerator(modelPackage).generate() + // Set of schema names that are anyOf without discriminator (use JsonContentPolymorphicSerializer) + private val anyOfWithoutDiscriminator = mutableSetOf() - schemaFiles + inlineSchemaFiles + enumFiles + listOfNotNull(serializersModuleFile) - } + fun getSealedHierarchies(): Map> = sealedHierarchies.toMap() - data class HierarchyInfo( - val sealedHierarchies: Map>, - val variantParents: Map>, - val anyOfWithoutDiscriminator: Set, - val schemas: List, - ) - - private fun buildHierarchyInfo(schemas: List): HierarchyInfo { - fun SchemaModel.variants() = oneOf ?: anyOf ?: emptyList() - - val polymorphicSchemas = schemas.filter { it.variants().isNotEmpty() } - - val sealedHierarchies = polymorphicSchemas.associate { schema -> - schema.name to schema - .variants() - .asSequence() - .filterIsInstance() - .map { it.schemaName } - .toList() - } + fun generate(spec: ApiSpec): List { + // Reset state + sealedHierarchies.clear() + variantParents.clear() + anyOfWithoutDiscriminator.clear() - val variantParents = polymorphicSchemas - .asSequence() - .flatMap { schema -> - val parentClass = ClassName(modelPackage, schema.name) - schema.variants().filterIsInstance().map { ref -> - ref.schemaName to (parentClass to resolveSerialName(schema, ref.schemaName)) - } - }.groupBy({ it.first }, { it.second }) - .mapValues { (_, entries) -> entries.toMap() } + val schemasById = spec.schemas.associateBy { it.name } - val anyOfWithoutDiscriminator = polymorphicSchemas - .asSequence() - .filter { !it.anyOf.isNullOrEmpty() && it.discriminator == null } - .map { it.name } - .toSet() + // Initialize deduplicator and register component schemas + val deduplicator = InlineSchemaDeduplicator() + deduplicator.registerComponentSchemas(spec.schemas) - return HierarchyInfo(sealedHierarchies, variantParents, anyOfWithoutDiscriminator, schemas) - } + // Collect all inline TypeRefs from the spec + val inlineTypeRefs = mutableListOf() - context(deduplicator: InlineSchemaDeduplicator) - private fun collectAllInlineSchemas(spec: ApiSpec): List { - val endpointRefs = spec.endpoints.flatMap { endpoint -> - val requestRef = endpoint.requestBody?.schema - val responseRefs = endpoint.responses.values.map { it.schema } - responseRefs + requestRef + // 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) } + } } - val schemaPropertyRefs = spec.schemas.flatMap { schema -> schema.properties.map { it.type } } + // Scan component schemas for inline property schemas + for (schema in spec.schemas) { + for (property in schema.properties) { + collectInlineTypeRefs(property.type, inlineTypeRefs) + } + } - return collectInlineTypeRefs(endpointRefs + schemaPropertyRefs) - .asSequence() - .sortedBy { it.contextHint } - .distinctBy { InlineSchemaKey.from(it.properties, it.requiredProperties) } - .map { ref -> - SchemaModel( - name = deduplicator.getOrGenerateName(ref.properties, ref.requiredProperties, ref.contextHint), - description = null, - properties = ref.properties, - requiredProperties = ref.requiredProperties, - allOf = null, - oneOf = null, - anyOf = null, - discriminator = null, + // 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, + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ), ) - }.toList() - } - - context(hierarchy: HierarchyInfo) - private fun generateSchemaFiles(schema: SchemaModel): List = when { - !schema.anyOf.isNullOrEmpty() || !schema.oneOf.isNullOrEmpty() -> { - if (schema.name in hierarchy.anyOfWithoutDiscriminator) { - listOf(generateSealedInterface(schema), generatePolymorphicSerializer(schema)) - } else { - listOf(generateSealedInterface(schema)) } } - schema.isPrimitiveOnly -> { - listOf(generateTypeAlias(schema, STRING)) + // First pass: scan all schemas to build variantParents map and detect anyOf-without-discriminator + for (schema in spec.schemas) { + if (schema.isEnum) continue + val variants = schema.oneOf ?: schema.anyOf + if (!variants.isNullOrEmpty()) { + val parentClassName = ClassName(modelPackage, schema.name) + 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) + } + } } - else -> { - listOf(generateDataClass(schema)) + // Second pass: generate FileSpecs for component schemas + val schemaFiles = + spec.schemas + .filter { !it.isEnum } + .flatMap { schema -> + when { + !schema.oneOf.isNullOrEmpty() || !schema.anyOf.isNullOrEmpty() -> { + val sealedFile = generateSealedInterface(schema) + if (schema.name in anyOfWithoutDiscriminator) { + // Also generate the JsonContentPolymorphicSerializer companion object + val serializerFile = generatePolymorphicSerializer(schema, schemasById) + listOf(sealedFile, serializerFile) + } else { + listOf(sealedFile) + } + } + + !schema.allOf.isNullOrEmpty() -> { + listOf(generateAllOfDataClass(schema, schemasById)) + } + + isPrimitiveOnly(schema) -> { + // For primitive-only schemas, generate type alias + // DEFERRED(Phase 4 - ENHN-04): primitive-only schema defaults to String; needs SchemaModel.primitiveType field + listOf(generateTypeAlias(schema, STRING)) + } + + else -> { + listOf(generateDataClass(schema)) + } + } + } + + // Generate FileSpecs for inline schemas (non-nested first, nested later) + val nonNestedInlineSchemas = inlineSchemas.filter { !it.name.contains(".") } + val nestedInlineSchemas = inlineSchemas.filter { it.name.contains(".") } + + val inlineSchemaFiles = + nonNestedInlineSchemas.map { generateDataClass(it) } + + nestedInlineSchemas.map { generateNestedInlineClass(it) } + + val enumFiles = spec.enums.map { generateEnumClass(it) } + + // Generate SerializersModule if any sealed hierarchies exist + val serializersModuleFile = SerializersModuleGenerator(modelPackage).generate(sealedHierarchies) + + return schemaFiles + inlineSchemaFiles + enumFiles + listOfNotNull(serializersModuleFile) + } + + /** + * Generates model files from [spec] and writes them to [outputDir]. + * Returns the number of files written. + */ + fun generateTo(spec: ApiSpec, outputDir: File,): Int { + val files = generate(spec) + for (fileSpec in files) { + fileSpec.writeTo(outputDir) } + return files.size } /** @@ -140,13 +199,13 @@ class ModelGenerator(private val modelPackage: String) { * - anyOf without discriminator: @Serializable(with = XxxSerializer::class) * - oneOf or anyOf with discriminator: plain @Serializable + @JsonClassDiscriminator */ - context(hierarchy: HierarchyInfo) private fun generateSealedInterface(schema: SchemaModel): FileSpec { val className = ClassName(modelPackage, schema.name) val typeSpec = TypeSpec.interfaceBuilder(className).addModifiers(KModifier.SEALED) - if (schema.name in hierarchy.anyOfWithoutDiscriminator) { + if (schema.name in anyOfWithoutDiscriminator) { + // anyOf without discriminator: use JsonContentPolymorphicSerializer val serializerClassName = ClassName(modelPackage, "${schema.name}Serializer") typeSpec.addAnnotation( AnnotationSpec @@ -171,8 +230,12 @@ class ModelGenerator(private val modelPackage: String) { typeSpec.addKdoc("%L", schema.description) } - val fileBuilder = FileSpec.builder(className).addType(typeSpec.build()) + val fileBuilder = + FileSpec + .builder(className) + .addType(typeSpec.build()) + // Add @OptIn for ExperimentalSerializationApi when discriminator is used if (schema.discriminator != null) { fileBuilder.addAnnotation( AnnotationSpec @@ -187,35 +250,37 @@ class ModelGenerator(private val modelPackage: String) { /** * 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. + * DEFERRED(Phase 4 - ENHN-02): anyOf without discriminator generates TODO(); requires heuristic or spec constraint */ - context(hierarchy: HierarchyInfo) - private fun generatePolymorphicSerializer(schema: SchemaModel): FileSpec { + private fun generatePolymorphicSerializer(schema: SchemaModel, schemasById: Map,): FileSpec { val sealedClassName = ClassName(modelPackage, schema.name) val serializerClassName = ClassName(modelPackage, "${schema.name}Serializer") - val schemasById = hierarchy.schemas.associateBy { it.name } - - val variantProperties = schema.anyOf + // Collect property names per variant + val variantProperties: List>> = schema.anyOf .orEmpty() - .asSequence() .filterIsInstance() - .associate { ref -> - val propNames = schemasById[ref.schemaName]?.properties?.map { it.name }?.toSet() ?: emptySet() + .map { ref -> + val variantSchema = schemasById[ref.schemaName] + val propNames = variantSchema?.properties?.map { it.name }?.toSet() ?: emptySet() ref.schemaName to propNames } - val allFields = variantProperties.values - .asSequence() - .flatten() - .groupingBy { it } - .eachCount() - - val uniqueFieldsPerVariant = variantProperties - .mapValues { (_, fields) -> - fields.firstOrNull { allFields[it] == 1 } - } + // 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 + } - val selectDeserializerBody = buildSelectDeserializerBody(schema.name, uniqueFieldsPerVariant) + // Build selectDeserializer function body + val selectDeserializerBody = buildSelectDeserializerBody(schema.name, sealedClassName, uniqueFieldsPerVariant) val deserializationStrategy = ClassName("kotlinx.serialization", "DeserializationStrategy") .parameterizedBy(WildcardTypeName.producerOf(sealedClassName)) @@ -230,8 +295,9 @@ class ModelGenerator(private val modelPackage: String) { val objectSpec = TypeSpec .objectBuilder(serializerClassName) - .superclass(JSON_CONTENT_POLYMORPHIC_SERIALIZER.parameterizedBy(sealedClassName)) - .addSuperclassConstructorParameter("%T::class", sealedClassName) + .superclass( + JSON_CONTENT_POLYMORPHIC_SERIALIZER.parameterizedBy(sealedClassName), + ).addSuperclassConstructorParameter("%T::class", sealedClassName) .addFunction(selectFun) .build() @@ -243,40 +309,46 @@ class ModelGenerator(private val modelPackage: String) { /** * 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: Map, + sealedClassName: ClassName, + uniqueFieldsPerVariant: List>, ): CodeBlock { val builder = CodeBlock.builder() builder.beginControlFlow("return when") - val notUnique = uniqueFieldsPerVariant.mapNotNull { (variantName, uniqueField) -> + // First pass: emit only variants with unique discriminating fields + 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, - ClassName(modelPackage, variantName), + variantClassName, ) - null - } else { - builder.addStatement("// No unique discriminating fields found for variant '$variantName'") - variantName } } - if (notUnique.isNotEmpty()) { - builder.addStatement( - "else -> TODO(%S)", - "Cannot discriminate variants [${notUnique.joinToString()}] of anyOf '$parentName' - manual selectDeserializer required", - ) - } else { + // Trailing else branch (always exactly one, after the loop) + val allHaveUniqueFields = uniqueFieldsPerVariant.all { it.second != null } + if (allHaveUniqueFields) { builder.addStatement( "else -> throw %T(%S + element)", SERIALIZATION_EXCEPTION, "Unknown $parentName variant: ", ) + } else { + val missingVariants = uniqueFieldsPerVariant + .filter { it.second == null } + .joinToString(", ") { it.first } + builder.addStatement( + "else -> TODO(%S)", + "No unique discriminating fields found for variant(s) '$missingVariants' of anyOf '$parentName' - manual selectDeserializer required", + ) } builder.endControlFlow() @@ -284,55 +356,142 @@ class ModelGenerator(private val modelPackage: String) { } /** - * Generates a data class FileSpec, with superinterfaces and @SerialName resolved from hierarchy. + * 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. */ - context(hierarchy: HierarchyInfo) - private fun generateDataClass(schema: SchemaModel): FileSpec { + private fun generateAllOfDataClass(schema: SchemaModel, schemasById: Map,): FileSpec { + // Determine superinterfaces from allOf refs that point to sealed interfaces + val superinterfaces = mutableListOf() + for (ref in schema.allOf.orEmpty()) { + 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) - val parentEntries = hierarchy.variantParents[schema.name].orEmpty() - val serialName = parentEntries.values.firstOrNull() - val superinterfaces = parentEntries.keys + // Check if this variant has parent info from oneOf scanning + val effectiveSuperinterfaces = superinterfaces.toMutableList() + val effectiveSerialName = serialName ?: variantParents[schema.name]?.firstOrNull()?.second - val sortedProps = schema.properties.sortedBy { prop -> - when { - prop.name in schema.requiredProperties && prop.defaultValue == null -> 1 - prop.defaultValue != null -> 2 - else -> 3 + 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 = sortedProps.map { prop -> - val type = TypeMapping.toTypeName(prop.type, modelPackage).copy(nullable = prop.nullable) - val kotlinName = prop.name.toCamelCase() + val propertySpecs = mutableListOf() + + for (prop in sortedProps) { + val baseType = TypeMapping.toTypeName(prop.type, modelPackage) + val kotlinName = prop.name.toKotlinIdentifier() + + // Determine final type and default value + val (type, defaultValue) = + when { + // Nullable with default -> honor nullable, ignore OpenAPI default + prop.nullable && prop.defaultValue != null -> { + baseType.copy(nullable = true) to "null" + } + + // Non-nullable with default -> use OpenAPI default + !prop.nullable && prop.defaultValue != null -> { + baseType to formatDefaultValue(prop) + } + + // Nullable without default -> nullable with null default + prop.nullable && prop.defaultValue == null -> { + baseType.copy(nullable = true) to "null" + } + + // Required without default -> no default value + else -> { + baseType to null + } + } val paramBuilder = ParameterSpec.builder(kotlinName, type) - - when { - prop.nullable -> paramBuilder.defaultValue(CodeBlock.of("null")) - prop.defaultValue != null -> paramBuilder.defaultValue(formatDefaultValue(prop)) + if (defaultValue != null) { + paramBuilder.defaultValue(defaultValue) } - constructorBuilder.addParameter(paramBuilder.build()) - PropertySpec - .builder(kotlinName, type) - .initializer(kotlinName) - .addAnnotation(AnnotationSpec.builder(SERIAL_NAME).addMember("%S", prop.name).build()) - .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) - .addSuperinterfaces(superinterfaces) + val typeSpec = + TypeSpec + .classBuilder(className) + .addModifiers(KModifier.DATA) + .primaryConstructor(constructorBuilder.build()) + .addProperties(propertySpecs) + .addAnnotation(SERIALIZABLE) + + // Add superinterfaces + for (si in effectiveSuperinterfaces) { + typeSpec.addSuperinterface(si) + } - if (serialName != null) { - typeSpec.addAnnotation(AnnotationSpec.builder(SERIAL_NAME).addMember("%S", serialName).build()) + // Add @SerialName for variants + if (effectiveSerialName != null) { + typeSpec.addAnnotation( + AnnotationSpec + .builder(SERIAL_NAME) + .addMember("%S", effectiveSerialName) + .build(), + ) } if (schema.description != null) { @@ -347,76 +506,111 @@ class ModelGenerator(private val modelPackage: String) { /** * 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): CodeBlock = when (prop.type) { + private fun formatDefaultValue(prop: PropertyModel): String = when (prop.type) { is TypeRef.Primitive -> { when (prop.type.type) { - PrimitiveType.STRING -> CodeBlock.of("%S", prop.defaultValue) + PrimitiveType.STRING -> { + // Return the string with quotes for KotlinPoet + "\"${prop.defaultValue}\"" + } PrimitiveType.INT, PrimitiveType.LONG, PrimitiveType.DOUBLE, PrimitiveType.FLOAT, PrimitiveType.BOOLEAN, - -> CodeBlock.of("%L", prop.defaultValue) + -> { + prop.defaultValue.toString() + } - PrimitiveType.DATE_TIME -> catch( - { Instant.parse(prop.defaultValue as String) }, - { CodeBlock.of("%T.parse(%S)", INSTANT, prop.defaultValue) }, - { e -> + 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}", + "Invalid ISO-8601 date-time default '${prop.defaultValue}' " + + "for property ${prop.name}: ${e.message}", ) - }, - ) + } + } - PrimitiveType.DATE -> catch( - { LocalDate.parse(prop.defaultValue as String) }, - { CodeBlock.of("%T.parse(%S)", LOCAL_DATE, prop.defaultValue) }, - { e -> + 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}", + "Invalid ISO-8601 date default '${prop.defaultValue}' " + + "for property ${prop.name}: ${e.message}", ) - }, - ) + } + } - else -> throw IllegalArgumentException("Unsupported default value type: ${prop.type}") + else -> { + throw IllegalArgumentException( + "Unsupported default value type: ${prop.type}", + ) + } } } is TypeRef.Reference -> { + // Enum default: use constant name conversion val constantName = prop.defaultValue.toString().toEnumConstantName() - CodeBlock.of("%T.%L", ClassName(modelPackage, prop.type.schemaName), constantName) + "${prop.type.schemaName}.$constantName" } else -> { - throw IllegalArgumentException("Unsupported default value type: ${prop.type}") + 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 } + private fun resolveSerialName(parentSchema: SchemaModel, variantSchemaName: String,): String { + val mapping = parentSchema.discriminator?.mapping + if (!mapping.isNullOrEmpty()) { + // mapping is: serialName -> ref path (e.g., "circle" -> "#/components/schemas/Circle") + for ((serialName, refPath) in mapping) { + val refName = refPath.removePrefix("#/components/schemas/") + if (refName == variantSchemaName) { + return serialName + } } - ?: variantSchemaName + } + // Default: use schema name as serial name + return variantSchemaName + } private fun generateEnumClass(enum: EnumModel): FileSpec { val className = ClassName(modelPackage, enum.name) - val typeSpec = TypeSpec.enumBuilder(className).addAnnotation(SERIALIZABLE) - - enum.values.forEach { value -> - val anonymousClass = TypeSpec - .anonymousClassBuilder() - .addAnnotation(AnnotationSpec.builder(SERIAL_NAME).addMember("%S", value).build()) - .build() - typeSpec.addEnumConstant(value.toEnumConstantName(), anonymousClass) + 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) { @@ -430,40 +624,62 @@ class ModelGenerator(private val modelPackage: String) { } /** - * Iteratively collects all [TypeRef.Inline] instances from a [TypeRef] tree. + * Recursively collects all TypeRef.Inline instances from a TypeRef tree. + * The [visited] set guards against infinite recursion in circular schemas. */ - private fun collectInlineTypeRefs(initialTodo: List): List { - val todo = ArrayDeque(initialTodo.filterNotNull()) - val visited = linkedSetOf() - - while (todo.isNotEmpty()) { - when (val current = todo.removeFirst()) { - is TypeRef.Inline if visited.add(current) -> { - todo.addAll(current.properties.map { it.type }) - } - - is TypeRef.Array -> { - todo.addFirst(current.items) + private fun collectInlineTypeRefs( + typeRef: TypeRef, + result: MutableList, + visited: MutableSet = mutableSetOf(), + ) { + when (typeRef) { + is TypeRef.Inline -> { + if (!visited.add(typeRef)) return + result.add(typeRef) + // Recursively collect from properties + typeRef.properties.forEach { prop -> + collectInlineTypeRefs(prop.type, result, visited) } + } - is TypeRef.Map -> { - todo.addFirst(current.valueType) - } + is TypeRef.Array -> { + collectInlineTypeRefs(typeRef.items, result, visited) + } - else -> {} + is TypeRef.Map -> { + collectInlineTypeRefs(typeRef.valueType, result, visited) } + + is TypeRef.Primitive, is TypeRef.Reference -> {} } - return visited.toList() } - context(_: HierarchyInfo) - private fun generateNestedInlineClass(schema: SchemaModel): FileSpec = - generateDataClass(schema.copy(name = schema.name.toInlinedName())) + /** + * 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. + * DEFERRED(Phase 4): nested inline classes generated as top-level; true nesting requires architecture change + */ + 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) + } - private val SchemaModel.isPrimitiveOnly: Boolean - get() = properties.isEmpty() && allOf == null && oneOf == null && anyOf == null + /** + * Checks if a schema is primitive-only (has no properties and no composite types). + * Primitive-only schemas should be generated as type aliases instead of data classes. + */ + private fun isPrimitiveOnly(schema: SchemaModel): Boolean = schema.properties.isEmpty() && + !schema.isEnum && schema.allOf == null && schema.oneOf == null && schema.anyOf == null - private fun generateTypeAlias(schema: SchemaModel, primitiveType: TypeName): FileSpec { + /** + * 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) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameUtils.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameUtils.kt index 55996273..d7cce5dd 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameUtils.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/NameUtils.kt @@ -1,47 +1,103 @@ package com.avsystem.justworks.core.gen private val DELIMITERS = Regex("[_\\-.]+") -private val CAMEL_BOUNDARY = Regex("(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])") +private val CAMEL_BOUNDARY = Regex("(?<=[a-z0-9])(?=[A-Z])") + +/** + * The set of Kotlin hard keywords that are reserved identifiers and require backtick escaping + * when used as property names in generated code. + */ +val KOTLIN_HARD_KEYWORDS = setOf( + "as", + "break", + "class", + "continue", + "do", + "else", + "false", + "for", + "fun", + "if", + "in", + "interface", + "is", + "null", + "object", + "package", + "return", + "super", + "this", + "throw", + "true", + "try", + "typealias", + "typeof", + "val", + "var", + "when", + "while", +) + +/** + * Converts a string to a valid Kotlin identifier for use as a property name. + * Calls [toCamelCase] first, then backtick-escapes the result if it is a Kotlin hard keyword. + * + * Examples: + * - "object" -> "`object`" + * - "in" -> "`in`" + * - "normalName" -> "normalName" + * - "my_object" -> "myObject" + */ +fun String.toKotlinIdentifier(): String { + val camelCased = toCamelCase() + return if (camelCased in KOTLIN_HARD_KEYWORDS) "`$camelCased`" else camelCased +} /** * Converts a string to camelCase. * Splits on `_`, `-`, `.` delimiters, lowercases first segment, * capitalizes subsequent segments, and joins. */ -fun String.toCamelCase(): String = toPascalCase().replaceFirstChar { it.lowercaseChar() } +fun String.toCamelCase(): String { + if (isBlank()) return this + val parts = + split(DELIMITERS).filter { it.isNotEmpty() }.flatMap { it.split(CAMEL_BOUNDARY) }.filter { it.isNotEmpty() } + if (parts.isEmpty()) return this + return buildString { + append(parts.first().lowercase()) + for (i in 1 until parts.size) { + append(parts[i].replaceFirstChar { it.uppercaseChar() }) + } + } +} /** * Converts a string to PascalCase. * Like [toCamelCase] but capitalizes the first segment too. */ -fun String.toPascalCase(): String = split(DELIMITERS) - .filter { it.isNotEmpty() } - .flatMap { it.split(CAMEL_BOUNDARY) } - .joinToString("") { it.lowercase().replaceFirstChar { c -> c.uppercaseChar() } } +fun String.toPascalCase(): String { + if (isBlank()) return this + val parts = + split(DELIMITERS).filter { it.isNotEmpty() }.flatMap { it.split(CAMEL_BOUNDARY) }.filter { it.isNotEmpty() } + if (parts.isEmpty()) return this + return parts.joinToString("") { it.replaceFirstChar { c -> c.uppercaseChar() } } +} /** * Converts any string to UPPER_SNAKE_CASE for use as an enum constant name. * Inserts `_` before uppercase letters in camelCase, replaces non-alphanumeric * with `_`, uppercases, deduplicates `_`, trims leading/trailing `_`. + * Prefixes with `VALUE_` if the first character is a digit. */ fun String.toEnumConstantName(): String { - val converted = replace(CAMEL_BOUNDARY, "_") - .replace(Regex("[^a-zA-Z0-9]+"), "_") - .trim('_') - .uppercase() - - return when { - converted.isEmpty() -> this - else -> converted - } + if (isBlank()) return this + val snaked = replace(CAMEL_BOUNDARY, "_") + val cleaned = snaked.map { c -> if (c.isLetterOrDigit()) c.uppercaseChar() else '_' }.joinToString("") + val deduped = cleaned.replace(Regex("_+"), "_").trim('_') + if (deduped.isEmpty()) return this + return if (deduped.first().isDigit()) "VALUE_$deduped" else deduped } -/** - * Sanitizes a nested inline schema name by replacing dot separators with underscores. - * E.g. "Pet.Address" becomes "Pet_Address". - */ -fun String.toInlinedName(): String = replace(".", "_") - /** * Generates a PascalCase operation name from HTTP method and path. * Path parameters like {id} become "ById", {userId} becomes "ByUserId". diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt index bb18fb15..f5746703 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt @@ -3,6 +3,10 @@ package com.avsystem.justworks.core.gen import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.MemberName +// Centralized repository of KotlinPoet ClassName and MemberName constants used across all code generators. +// Avoids scattered magic strings and makes library dependency updates a single-file change. +// Organized by domain (HTTP client, serialization, dates, error handling). + // ============================================================================ // Ktor HTTP Client // ============================================================================ @@ -16,7 +20,7 @@ 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_APPLICATION = ClassName("io.ktor.http", "ContentType", "Application") +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") @@ -46,7 +50,7 @@ val POLYMORPHIC_FUN = MemberName("kotlinx.serialization.modules", "polymorphic") val SUBCLASS_FUN = MemberName("kotlinx.serialization.modules", "subclass") // ============================================================================ -// Date/Time (kotlin.time / kotlinx.datetime) +// Date/Time // ============================================================================ val INSTANT = ClassName("kotlin.time", "Instant") @@ -57,7 +61,12 @@ val LOCAL_DATE = ClassName("kotlinx.datetime", "LocalDate") // ============================================================================ val RAISE = ClassName("arrow.core.raise", "Raise") -val RAISE_FUN = MemberName("arrow.core.raise.context", "raise") + +// `raise` is a member of Raise and is called directly via context parameter — +// no MemberName import needed; use literal "raise" in code blocks to avoid +// emitting `import arrow.core.raise.context.raise` which fails when +// `-Xcontext-parameters` is active because `context` becomes a keyword. +const val RAISE_FUN = "raise" val HTTP_ERROR = ClassName("com.avsystem.justworks", "HttpError") val HTTP_ERROR_TYPE = ClassName("com.avsystem.justworks", "HttpErrorType") @@ -71,6 +80,7 @@ val CLOSEABLE = ClassName("java.io", "Closeable") val IO_EXCEPTION = ClassName("java.io", "IOException") val HTTP_REQUEST_TIMEOUT_EXCEPTION = ClassName("io.ktor.client.plugins", "HttpRequestTimeoutException") val OPT_IN = ClassName("kotlin", "OptIn") +<<<<<<< HEAD // ============================================================================ // Shared client base (generated) @@ -96,3 +106,5 @@ const val SAFE_CALL = "safeCall" const val CREATE_HTTP_CLIENT = "createHttpClient" const val GENERATED_SERIALIZERS_MODULE = "generatedSerializersModule" const val NETWORK_ERROR = "Network error" +======= +>>>>>>> 3168d68 (feat: enhance parser with anyOf, inline schemas, defaults, and type improvements) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt index de3780d5..d90c12d9 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt @@ -1,10 +1,11 @@ package com.avsystem.justworks.core.gen -import com.avsystem.justworks.core.gen.ModelGenerator.HierarchyInfo 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. @@ -12,24 +13,22 @@ import com.squareup.kotlinpoet.PropertySpec * Produces a top-level `val generatedSerializersModule: SerializersModule` property * that registers each sealed interface with its subclass variants. */ -class SerializersModuleGenerator(private val modelPackage: String) { +class SerializersModuleGenerator(private val modelPackage: String,) { /** * Generates a [FileSpec] containing the SerializersModule registration. - * Returns null if the hierarchy has no sealed types to register. + * 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 - context(hierarchy: HierarchyInfo) - fun generate(): FileSpec? { - // anyOf hierarchies without a discriminator use JsonContentPolymorphicSerializer - // with custom deserialization logic, so they don't need SerializersModule registration. - val discriminatorHierarchies = - hierarchy.sealedHierarchies.filterKeys { it !in hierarchy.anyOfWithoutDiscriminator } + val code = + CodeBlock + .builder() + .beginControlFlow("%T", SERIALIZERS_MODULE) - if (discriminatorHierarchies.isEmpty()) return null - - val code = CodeBlock.builder().beginControlFlow("%T", SERIALIZERS_MODULE) - - for ((parent, variants) in discriminatorHierarchies) { + for ((parent, variants) in sealedHierarchies) { val parentClass = ClassName(modelPackage, parent) code.beginControlFlow("%M(%T::class)", POLYMORPHIC_FUN, parentClass) for (variant in variants) { @@ -52,4 +51,14 @@ class SerializersModuleGenerator(private val modelPackage: String) { .addProperty(prop) .build() } + + /** + * Generates the SerializersModule file and writes it to [outputDir]. + * Returns 0 if no polymorphic types exist, 1 if a file was written. + */ + fun generateTo(sealedHierarchies: Map>, outputDir: File,): Int { + val fileSpec = generate(sealedHierarchies) ?: return 0 + fileSpec.writeTo(outputDir) + return 1 + } } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/TypeMapping.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/TypeMapping.kt index 674bf95f..a275e6f0 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/TypeMapping.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/TypeMapping.kt @@ -2,7 +2,6 @@ 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 @@ -20,19 +19,9 @@ import com.squareup.kotlinpoet.TypeName * Maps [TypeRef] sealed variants to KotlinPoet [TypeName] instances. */ object TypeMapping { - fun toTypeName(typeRef: TypeRef, modelPackage: String): TypeName = when (typeRef) { + fun toTypeName(typeRef: TypeRef, modelPackage: String,): TypeName = when (typeRef) { is TypeRef.Primitive -> { - 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 - } + primitiveTypeName(typeRef.type) } is TypeRef.Array -> { @@ -48,11 +37,21 @@ object TypeMapping { } is TypeRef.Inline -> { - ClassName(modelPackage, typeRef.contextHint.toInlinedName()) + // For nested inline classes (e.g., "Pet.Address"), sanitize to "PetAddress" + val sanitizedName = typeRef.contextHint.replace(".", "") + ClassName(modelPackage, sanitizedName) } + } - is TypeRef.Unknown -> { - ANY - } + private fun primitiveTypeName(type: PrimitiveType): TypeName = when (type) { + PrimitiveType.STRING -> STRING + PrimitiveType.INT -> INT + PrimitiveType.LONG -> LONG + PrimitiveType.DOUBLE -> DOUBLE + PrimitiveType.FLOAT -> FLOAT + PrimitiveType.BOOLEAN -> BOOLEAN + PrimitiveType.BYTE_ARRAY -> BYTE_ARRAY + PrimitiveType.DATE_TIME -> INSTANT + PrimitiveType.DATE -> LOCAL_DATE } } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt b/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt index 2117a403..8ca585f6 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt @@ -1,12 +1,5 @@ package com.avsystem.justworks.core.model -/** - * Intermediate model representing a fully parsed OpenAPI specification. - * - * Produced by [com.avsystem.justworks.core.parser.SpecParser] and consumed by the - * code generators. Bridges the raw Swagger Parser OAS model and the generated - * Kotlin client/model source files. - */ data class ApiSpec( val title: String, val version: String, @@ -26,17 +19,7 @@ data class Endpoint( val responses: Map, ) -enum class HttpMethod { - GET, - POST, - PUT, - DELETE, - PATCH; - - companion object { - fun parse(name: String): HttpMethod? = entries.find { it.name.equals(name, true) } - } -} +enum class HttpMethod { GET, POST, PUT, DELETE, PATCH } data class Parameter( val name: String, @@ -46,27 +29,18 @@ data class Parameter( val description: String?, ) -// todo: add cookie -enum class ParameterLocation { - PATH, - QUERY, - HEADER; - - companion object { - fun parse(name: String): ParameterLocation? = entries.find { it.name.equals(name, true) } - } -} +enum class ParameterLocation { PATH, QUERY, HEADER } data class RequestBody( val required: Boolean, val contentType: String, - val schema: TypeRef, + val schema: TypeRef ) data class Response( val statusCode: String, val description: String?, - val schema: TypeRef?, + val schema: TypeRef? ) data class SchemaModel( @@ -74,13 +48,12 @@ data class SchemaModel( val description: String?, val properties: List, val requiredProperties: Set, + val isEnum: Boolean, val allOf: List?, val oneOf: List?, val anyOf: List?, val discriminator: Discriminator?, -) { - val isNested get() = name.contains(".") -} +) data class PropertyModel( val name: String, @@ -94,16 +67,9 @@ data class EnumModel( val name: String, val description: String?, val type: EnumBackingType, - val values: List, + val values: List ) -enum class EnumBackingType { - STRING, - INTEGER; - - companion object { - fun parse(name: String): EnumBackingType? = entries.find { it.name.equals(name, true) } - } -} +enum class EnumBackingType { STRING, INTEGER } data class Discriminator(val propertyName: String, val mapping: Map) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt b/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt index 82387255..468d2572 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt @@ -14,8 +14,6 @@ sealed interface TypeRef { val requiredProperties: Set, val contextHint: String, // "request"|"response"|property name for context-aware naming ) : TypeRef - - data object Unknown : TypeRef } enum class PrimitiveType { STRING, INT, LONG, DOUBLE, FLOAT, BOOLEAN, BYTE_ARRAY, DATE_TIME, DATE } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/ParseResult.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/ParseResult.kt new file mode 100644 index 00000000..64148fe8 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/ParseResult.kt @@ -0,0 +1,9 @@ +package com.avsystem.justworks.core.parser + +import com.avsystem.justworks.core.model.ApiSpec + +sealed interface ParseResult { + data class Success(val spec: ApiSpec, val warnings: List = emptyList()) : ParseResult + + data class Failure(val errors: List) : ParseResult +} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt index a9239d57..da98a7df 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -1,12 +1,6 @@ package com.avsystem.justworks.core.parser -import arrow.core.fold -import arrow.core.merge -import arrow.core.raise.context.Raise -import arrow.core.raise.context.ensure -import arrow.core.raise.context.ensureNotNull -import arrow.core.raise.either -import arrow.core.raise.nullable +import com.avsystem.justworks.core.gen.operationNameFromPath import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.Discriminator import com.avsystem.justworks.core.model.Endpoint @@ -28,242 +22,271 @@ import io.swagger.v3.oas.models.media.Schema import io.swagger.v3.parser.core.models.ParseOptions import java.io.File import java.util.IdentityHashMap -import io.swagger.v3.oas.models.parameters.Parameter as SwaggerParameter - -/** - * Result of parsing an OpenAPI specification file. - * - * Use pattern matching to handle both outcomes: - * ```kotlin - * when (val result = SpecParser.parse(file)) { - * is ParseResult.Success -> result.apiSpec - * is ParseResult.Failure -> handleErrors(result.errors) - * } - * ``` - * - * Both [Success] and [Failure] may carry [warnings] about non-fatal issues - * encountered during parsing or validation. - */ -sealed interface ParseResult { - data class Success(val apiSpec: ApiSpec, val warnings: List = emptyList()) : ParseResult - - data class Failure(val errors: List, val warnings: List = emptyList()) : ParseResult -} -object SpecParser { - /** - * Parses an OpenAPI 3.0 specification file into an [ApiSpec] intermediate model. - * - * Accepts YAML or JSON files. Swagger 2.0 specs are automatically converted to - * OpenAPI 3.0 by the underlying Swagger Parser before model extraction. - * - * Uses Arrow [either] for the internal parse pipeline; the result is collapsed - * to [ParseResult] via [arrow.core.merge] so callers always receive a [ParseResult] - * and never an [arrow.core.Either]. - * - * @param specFile path to the OpenAPI or Swagger 2.0 specification file - * @return [ParseResult.Success] with the parsed model and any warnings, or - * [ParseResult.Failure] with a non-empty list of error messages - */ - fun parse(specFile: File): ParseResult = either { - val parseOptions = ParseOptions().apply { - isResolve = true - isResolveFully = true - isResolveCombinators = false - } +class SpecParser { + // Identity map from resolved Schema objects to their component schema names. + // Populated during transformToModel to detect inlined refs after resolveFully. + private var componentSchemaIdentity: IdentityHashMap, String> = IdentityHashMap() + + // Component schemas keyed by name, for resolving $ref in allOf sub-schemas. + private var componentSchemas: Map> = emptyMap() + + fun parse(specFile: File): ParseResult { + val parseOptions = + ParseOptions().apply { + isResolve = true + isResolveFully = true + isResolveCombinators = false + } val swaggerResult = OpenAPIParser().readLocation(specFile.absolutePath, null, parseOptions) - val openApi = swaggerResult.openAPI - val swaggerMessages = swaggerResult.messages.orEmpty() - ensureNotNull(openApi) { - ParseResult.Failure(swaggerMessages.ifEmpty { listOf("Failed to parse spec: ${specFile.name}") }) - } + val messages = swaggerResult.messages.orEmpty() + val openApi = + swaggerResult.openAPI + ?: return ParseResult.Failure( + messages.ifEmpty { + listOf("Failed to parse spec: ${specFile.name}") + }, + ) - val validationIssues = SpecValidator.validate(openApi) - val (errors, warnings) = validationIssues.partition { it is SpecValidator.ValidationIssue.Error } - val allWarnings = warnings.map { it.message } + swaggerMessages + val validationErrors = SpecValidator.validate(openApi) + if (validationErrors.isNotEmpty()) { + return ParseResult.Failure(validationErrors) + } - ensure(errors.isEmpty()) { - ParseResult.Failure(errors.map { it.message }, allWarnings) + return try { + val apiSpec = transformToModel(openApi) + ParseResult.Success(apiSpec, warnings = messages) + } catch (e: IllegalArgumentException) { + ParseResult.Failure(listOf(e.message ?: "Schema validation failed")) } + } - ParseResult.Success(openApi.toApiSpec(), warnings = allWarnings) - }.merge() + private fun transformToModel(openApi: OpenAPI): ApiSpec { + val allSchemas = openApi.components?.schemas.orEmpty() - private typealias ComponentSchemaIdentity = IdentityHashMap, String> - private typealias ComponentSchemas = MutableMap> + // Build identity map from resolved Schema objects to their component names. + // After resolveFully=true, $ref pointers are replaced with the actual Schema + // object from components, so we can detect resolved refs by object identity. + componentSchemas = allSchemas + componentSchemaIdentity = + IdentityHashMap, String>().apply { + allSchemas.forEach { (name, schema) -> put(schema, name) } + } - context(_: Raise) - private fun OpenAPI.toApiSpec(): ApiSpec { - val allSchemas = components?.schemas.orEmpty() + val enumModels = mutableListOf() + val schemaModels = mutableListOf() - val componentSchemaIdentity = ComponentSchemaIdentity(allSchemas.size).apply { - allSchemas.forEach { (name, schema) -> this[schema] = name } + allSchemas.forEach { (name, schema) -> + if (isEnumSchema(schema)) { + enumModels.add(extractEnumModel(name, schema)) + } else { + schemaModels.add(extractSchemaModel(name, schema)) + } } - val componentSchemas: ComponentSchemas = allSchemas.toMutableMap() - - context(componentSchemaIdentity, componentSchemas) { - val endpoints = extractEndpoints(paths.orEmpty()) - - val (enumModels, schemaModels) = allSchemas.fold( - emptyList() to emptyList(), - ) { (accEnum, accModels), (name, schema) -> - if (schema.isEnumSchema) { - accEnum + extractEnumModel(name, schema) to accModels - } else { - accEnum to accModels + extractSchemaModel(name, schema) - } + // Extract any inline schemas that were created during wrapper unwrapping + // These are now in componentSchemas but weren't in the original allSchemas + val newInlineSchemas = componentSchemas.filter { (name, _) -> name !in allSchemas.keys } + newInlineSchemas.forEach { (name, schema) -> + if (isEnumSchema(schema)) { + enumModels.add(extractEnumModel(name, schema)) + } else { + schemaModels.add(extractSchemaModel(name, schema)) } - - return ApiSpec( - title = info?.title ?: "Untitled", - version = info?.version ?: "0.0.0", - endpoints = endpoints, - schemas = schemaModels, - enums = enumModels, - ) } + + val endpoints = extractEndpoints(openApi.paths.orEmpty()) + + return ApiSpec( + title = openApi.info?.title ?: "Untitled", + version = openApi.info?.version ?: "0.0.0", + endpoints = endpoints, + schemas = schemaModels, + enums = enumModels, + ) } - context(_: ComponentSchemaIdentity, _: ComponentSchemas) - private fun extractEndpoints(paths: Map): List = paths - .asSequence() - .flatMap { (path, pathItem) -> - pathItem - .readOperationsMap() - .asSequence() - .mapNotNull { (method, value) -> HttpMethod.parse(method.name)?.let { it to value } } - .map { (method, operation) -> - val operationId = operation.operationId ?: generateOperationId(method, path) - - val mergedParams = (operation.parameters.orEmpty() + pathItem.parameters.orEmpty()) - .distinctBy { "${it.name}:${it.`in`}" } - .map { it.toParameter() } - - val requestBody = nullable { - val body = operation.requestBody.bind() - val content = body.content.bind() - val schema = content[JSON_CONTENT_TYPE]?.schema.bind() - RequestBody( - required = body.required ?: false, - contentType = JSON_CONTENT_TYPE, - schema = schema.toTypeRef("${operationId.replaceFirstChar { it.uppercase() }}Request"), - ) + private fun extractEndpoints(paths: Map): List = paths.flatMap { (path, pathItem) -> + pathItem.readOperationsMap().map { (method, operation) -> + // Merge path-level and operation-level parameters + // Operation-level takes precedence (unique key = name + location) + val pathParams = pathItem.parameters.orEmpty() + val opParams = operation.parameters.orEmpty() + val mergedParams = + (pathParams + opParams) + .associateBy { "${it.name}:${it.`in`}" } + .values + .map { toParameter(it) } + + val requestBody = + operation.requestBody?.let { body -> + val contentEntry = body.content?.entries?.firstOrNull() + contentEntry?.let { (contentType, mediaType) -> + mediaType.schema?.let { schema -> + val schemaTypeRef = if (isInlineObjectSchema(schema)) { + // Inline request body schema + val contextName = operationNameFromPath(method.name, path) + "Request" + createInlineTypeRef(schema, contextName) + } else { + schemaToTypeRef(schema) + } + RequestBody( + required = body.required ?: false, + contentType = contentType, + schema = schemaTypeRef, + ) + } } + } - val responses = operation.responses - .orEmpty() - .mapValues { (code, resp) -> + val responses = + operation.responses + ?.map { (code, resp) -> + val schema = + resp.content + ?.get("application/json") + ?.schema + val schemaTypeRef = schema?.let { s -> + if (isInlineObjectSchema(s)) { + // Inline response body schema + val contextName = operationNameFromPath(method.name, path) + "Response" + createInlineTypeRef(s, contextName) + } else { + schemaToTypeRef(s) + } + } + code to Response( statusCode = code, description = resp.description, - schema = resp.content - ?.get(JSON_CONTENT_TYPE) - ?.schema - ?.toTypeRef("${operationId.replaceFirstChar { it.uppercase() }}Response"), + schema = schemaTypeRef, ) - } - - Endpoint( - path = path, - method = method, - operationId = operationId, - summary = operation.summary, - tags = operation.tags.orEmpty(), - parameters = mergedParams, - requestBody = requestBody, - responses = responses, - ) - } - }.toList() - - context(_: ComponentSchemaIdentity, _: ComponentSchemas) - private fun SwaggerParameter.toParameter(): Parameter = Parameter( - name = name ?: "", - location = ParameterLocation.parse(`in`) ?: ParameterLocation.QUERY, - required = required ?: false, - schema = schema?.toTypeRef() ?: TypeRef.Primitive(PrimitiveType.STRING), - description = description, - ) - - // --- Schema extraction --- - - context(_: Raise, _: ComponentSchemaIdentity, _: ComponentSchemas) - private fun extractSchemaModel(name: String, schema: Schema<*>): SchemaModel { - val allOf = schema.allOf?.mapNotNull { it.resolveName() } + }?.toMap() + .orEmpty() + + Endpoint( + path = path, + method = HttpMethod.valueOf(method.name), + operationId = + operation.operationId + ?: generateOperationId(method.name, path), + summary = operation.summary, + tags = operation.tags.orEmpty(), + parameters = mergedParams, + requestBody = requestBody, + responses = responses, + ) + } + } - val (oneOf, discriminatorFromWrapper) = detectAndUnwrapOneOfWrappers(schema) // may register new schemas - ?: (schema.oneOf?.mapNotNull { it.resolveName() } to null) + private fun toParameter(param: io.swagger.v3.oas.models.parameters.Parameter): Parameter { + val location = + when (param.`in`?.lowercase()) { + "path" -> ParameterLocation.PATH + "query" -> ParameterLocation.QUERY + "header" -> ParameterLocation.HEADER + else -> ParameterLocation.QUERY + } + return Parameter( + name = param.name ?: "", + location = location, + required = param.required ?: false, + schema = + param.schema?.let { schemaToTypeRef(it) } + ?: TypeRef.Primitive(PrimitiveType.STRING), + description = param.description, + ) + } - val anyOf = schema.anyOf?.mapNotNull { it.resolveName() } + private fun schemaToTypeRef(schema: Schema<*>): TypeRef { + schema.`$ref`?.let { ref -> + val schemaName = ref.removePrefix("#/components/schemas/") + return TypeRef.Reference(schemaName) + } - ensure(oneOf.isNullOrEmpty() || anyOf.isNullOrEmpty()) { - ParseResult.Failure(listOf("Schema '$name' has both oneOf and anyOf. Use one combinator only.")) + // After resolveFully, $ref is null but the schema object may be the same + // instance as a component schema. Detect by identity to preserve references. + componentSchemaIdentity[schema]?.let { schemaName -> + return TypeRef.Reference(schemaName) } - val (properties, requiredProps) = - if (!schema.allOf.isNullOrEmpty()) { - extractAllOfProperties(name, schema) - } else { - val requiredProps = schema.required.orEmpty().toSet() - val props = schema - .propertyModels(requiredProps) { propName -> "$name.${propName.toPascalCase()}" } - .values - .toList() - props to requiredProps + // Check for allOf with single reference (common pattern for property schemas with defaults) + schema.allOf?.takeIf { it.size == 1 }?.firstOrNull()?.let { allOfSchema -> + allOfSchema.`$ref`?.let { ref -> + return TypeRef.Reference(ref.removePrefix("#/components/schemas/")) + } + componentSchemaIdentity[allOfSchema]?.let { schemaName -> + return TypeRef.Reference(schemaName) } - - val discriminator = discriminatorFromWrapper ?: nullable { - val disc = schema.discriminator.bind() - val propertyName = disc.propertyName.bind() - Discriminator(propertyName = propertyName, mapping = disc.mapping.orEmpty()) } - return SchemaModel( - name = name, - description = schema.description, - properties = properties, - requiredProperties = requiredProps, - allOf = allOf?.let { it.map(TypeRef::Reference).ifEmpty { null } }, - oneOf = oneOf?.let { it.map(TypeRef::Reference).ifEmpty { null } }, - anyOf = anyOf?.let { it.map(TypeRef::Reference).ifEmpty { null } }, - discriminator = discriminator, - ) - } + return when (schema.type) { + "string" -> { + when (schema.format) { + "byte" -> TypeRef.Primitive(PrimitiveType.BYTE_ARRAY) + "date-time" -> TypeRef.Primitive(PrimitiveType.DATE_TIME) + "date" -> TypeRef.Primitive(PrimitiveType.DATE) + else -> TypeRef.Primitive(PrimitiveType.STRING) + } + } - private fun extractEnumModel(name: String, schema: Schema<*>): EnumModel = EnumModel( - name = name, - description = schema.description, - type = EnumBackingType.parse(schema.type) ?: EnumBackingType.STRING, - values = schema.enum.map { it.toString() }, - ) + "integer" -> { + when (schema.format) { + "int64" -> TypeRef.Primitive(PrimitiveType.LONG) + else -> TypeRef.Primitive(PrimitiveType.INT) + } + } - // --- allOf property merging --- + "number" -> { + when (schema.format) { + "float" -> TypeRef.Primitive(PrimitiveType.FLOAT) + else -> TypeRef.Primitive(PrimitiveType.DOUBLE) + } + } - context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) - private fun extractAllOfProperties(parentName: String, schema: Schema<*>): Pair, Set> { - val topRequired = schema.required.orEmpty().toSet() - val contextCreator: (String) -> String? = { propName -> "$parentName.${propName.toPascalCase()}" } - - val (required, properties) = schema.allOf - .orEmpty() - .fold(topRequired to emptyMap()) { (accRequired, accProperties), subSchema -> - val resolvedSchema = subSchema.resolveSubSchema() - val mergedRequired = accRequired + resolvedSchema.required.orEmpty().toSet() - mergedRequired to accProperties + resolvedSchema.propertyModels(mergedRequired, contextCreator) + "boolean" -> { + TypeRef.Primitive(PrimitiveType.BOOLEAN) } - val topLevelProperties = schema.propertyModels(required, contextCreator) - val finalProperties = - properties.plus(topLevelProperties).values.map { prop -> prop.copy(nullable = prop.name !in required) } + "array" -> { + val items = + schema.items + ?: return TypeRef.Primitive(PrimitiveType.STRING) + TypeRef.Array(schemaToTypeRef(items)) + } + + "object" -> { + val additionalProperties = schema.additionalProperties + if (additionalProperties is Schema<*>) { + TypeRef.Map(schemaToTypeRef(additionalProperties)) + } else { + TypeRef.Reference(schema.title ?: "Unknown") + } + } - return finalProperties to required + else -> { + TypeRef.Primitive(PrimitiveType.STRING) + } + } } - context(_: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) - private fun Schema<*>.resolveSubSchema(): Schema<*> = resolveName()?.let { componentSchemas[it] } ?: this + private fun isEnumSchema(schema: Schema<*>): Boolean = !schema.enum.isNullOrEmpty() + + private fun extractEnumModel(name: String, schema: Schema<*>,): EnumModel { + val backingType = + when (schema.type) { + "integer" -> EnumBackingType.INTEGER + else -> EnumBackingType.STRING + } + return EnumModel( + name = name, + description = schema.description, + type = backingType, + values = schema.enum.map { it.toString() }, + ) + } /** * Detects and unwraps the oneOf wrapper pattern where each variant is a single-property @@ -278,126 +301,294 @@ object SpecParser { * * Returns: Pair of (unwrapped oneOf refs, synthetic discriminator) or null if pattern not matched. */ - context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) - private fun detectAndUnwrapOneOfWrappers(schema: Schema<*>): Pair, Discriminator>? = nullable { - ensure(!schema.oneOf.isNullOrEmpty() && schema.discriminator == null) - - val variants = schema.oneOf.orEmpty() - ensure(variants.all { it.isInlineObject }) + private fun detectAndUnwrapOneOfWrappers(schema: Schema<*>): Pair, Discriminator>? { + // Must have oneOf and no explicit discriminator + if (schema.oneOf.isNullOrEmpty() || schema.discriminator != null) { + return null + } - val unwrapped = variants - .associate { - val (propertyName, propertySchema) = ensureNotNull( - it.properties?.entries?.singleOrNull(), - ) + val unwrappedRefs = mutableListOf() + val discriminatorMapping = mutableMapOf() - val schemaName = ensureNotNull( - propertySchema.resolveName() ?: propertyName - .takeIf { propertySchema.isInlineObject } - ?.also { name -> - componentSchemas[name] = propertySchema - componentSchemaIdentity[propertySchema] = name - }, - ) + for (variant in schema.oneOf.orEmpty()) { + // Check if variant is a wrapper object (not a $ref, not in identity map) + if (variant.`$ref` != null || componentSchemaIdentity[variant] != null) { + // This variant is a direct reference, not a wrapper object + return null + } - propertyName to schemaName + // Check if variant is object type with exactly one property + if (variant.type != "object" || variant.properties.isNullOrEmpty()) { + return null } - ensure(unwrapped.size == variants.size) + val properties = variant.properties.orEmpty() + if (properties.size != 1) { + return null + } - val mapping = unwrapped.mapValues { (_, schemaName) -> "$SCHEMA_PREFIX$schemaName" } - unwrapped.values.toList() to Discriminator(propertyName = "type", mapping = mapping) - } + // Extract the single property + val (propertyName, propertySchema) = properties.entries.first() + + // The property value must be a reference or inline object + val unwrappedRef = if (propertySchema.`$ref` != null) { + // Property points to a $ref - extract schema name + val schemaName = propertySchema.`$ref`.removePrefix("#/components/schemas/") + discriminatorMapping[propertyName] = propertySchema.`$ref` + TypeRef.Reference(schemaName) + } else if (componentSchemaIdentity[propertySchema] != null) { + // Property is a resolved reference (after resolveFully) + val schemaName = componentSchemaIdentity[propertySchema]!! + discriminatorMapping[propertyName] = "#/components/schemas/$schemaName" + TypeRef.Reference(schemaName) + } else if (propertySchema.type == "object" && !propertySchema.properties.isNullOrEmpty()) { + // Property is an inline object - create a component schema for it + val inlineSchemaName = propertyName.toPascalCase() + val inlineSchema = propertySchema + + // Register this inline schema as a component for future lookups + componentSchemas = componentSchemas + (inlineSchemaName to inlineSchema) + componentSchemaIdentity[inlineSchema] = inlineSchemaName + + discriminatorMapping[propertyName] = "#/components/schemas/$inlineSchemaName" + TypeRef.Reference(inlineSchemaName) + } else { + // Property value is neither a ref nor an inline object + return null + } - context(_: ComponentSchemaIdentity, _: ComponentSchemas) - private fun Schema<*>.toTypeRef(contextName: String? = null): TypeRef = contextName?.let { toInlineTypeRef(it) } - ?: (resolveName() ?: allOf?.singleOrNull()?.resolveName())?.let(TypeRef::Reference) - ?: TypeRef.Unknown.takeIf { (allOf?.size ?: 0) > 1 } - ?: when (type) { - "string" -> STRING_FORMAT_MAP[format] ?: TypeRef.Primitive(PrimitiveType.STRING) + unwrappedRefs.add(unwrappedRef) + } - "integer" -> INTEGER_FORMAT_MAP[format] ?: TypeRef.Primitive(PrimitiveType.INT) + // All variants matched the wrapper pattern - create synthetic discriminator + val syntheticDiscriminator = Discriminator( + propertyName = "type", + mapping = discriminatorMapping, + ) - "number" -> NUMBER_FORMAT_MAP[format] ?: TypeRef.Primitive(PrimitiveType.DOUBLE) + return unwrappedRefs to syntheticDiscriminator + } - "boolean" -> TypeRef.Primitive(PrimitiveType.BOOLEAN) + private fun extractSchemaModel(name: String, schema: Schema<*>,): SchemaModel { + val allOf = + schema.allOf?.mapNotNull { subSchema -> + subSchema.`$ref`?.let { ref -> + TypeRef.Reference(ref.removePrefix("#/components/schemas/")) + } ?: componentSchemaIdentity[subSchema]?.let { TypeRef.Reference(it) } + } - "array" -> TypeRef.Array(items?.toTypeRef(contextName?.let { "${it}Item" }) ?: TypeRef.Unknown) + // Check for oneOf wrapper pattern before standard extraction + val (oneOf, discriminatorFromWrapper) = detectAndUnwrapOneOfWrappers(schema) + ?: run { + // Standard oneOf extraction (no wrapper pattern) + val standardOneOf = schema.oneOf?.mapNotNull { subSchema -> + subSchema.`$ref`?.let { ref -> + TypeRef.Reference(ref.removePrefix("#/components/schemas/")) + } ?: componentSchemaIdentity[subSchema]?.let { TypeRef.Reference(it) } + } + standardOneOf to null + } - "object" -> when (val ap = additionalProperties) { - is Schema<*> -> TypeRef.Map(ap.toTypeRef()) - is Boolean -> if (ap) TypeRef.Map(TypeRef.Unknown) else TypeRef.Unknown - else -> title?.let(TypeRef::Reference) ?: TypeRef.Unknown + val anyOf = + schema.anyOf?.mapNotNull { subSchema -> + subSchema.`$ref`?.let { ref -> + TypeRef.Reference(ref.removePrefix("#/components/schemas/")) + } ?: componentSchemaIdentity[subSchema]?.let { TypeRef.Reference(it) } } - else -> TypeRef.Unknown + // Validate no mixed anyOf+oneOf (check first, before discriminator check) + if (oneOf != null && oneOf.isNotEmpty() && anyOf != null && anyOf.isNotEmpty()) { + throw IllegalArgumentException("Schema '$name' has both oneOf and anyOf. Use one combinator only.") } - context(_: ComponentSchemaIdentity, _: ComponentSchemas) - private fun Schema<*>.toInlineTypeRef(contextName: String): TypeRef? = takeIf { isInlineObject }?.let { - val required = required.orEmpty().toSet() - TypeRef.Inline( - properties = propertyModels(required) { "$contextName.${it.toPascalCase()}" }.values.toList(), - requiredProperties = required, - contextHint = contextName, + // For allOf schemas, merge properties from all sub-schemas + val (properties, requiredProps) = + if (!schema.allOf.isNullOrEmpty()) { + extractAllOfProperties(schema) + } else { + val requiredProps = schema.required.orEmpty().toSet() + val props = + schema.properties.orEmpty().map { (propName, propSchema) -> + val propType = if (isInlineObjectSchema(propSchema)) { + // Inline property schema - use Parent.PropertyName pattern + val contextName = "$name.${propName.toPascalCase()}" + createInlineTypeRef(propSchema, contextName) + } else { + schemaToTypeRef(propSchema) + } + PropertyModel( + name = propName, + type = propType, + description = propSchema.description, + nullable = propName !in requiredProps, + defaultValue = propSchema.default, + ) + } + props to requiredProps + } + + val discriminator = + discriminatorFromWrapper ?: schema.discriminator?.let { disc -> + disc.propertyName?.let { + Discriminator(propertyName = it, mapping = disc.mapping.orEmpty()) + } + } + + return SchemaModel( + name = name, + description = schema.description, + properties = properties, + requiredProperties = requiredProps, + isEnum = false, + allOf = allOf?.ifEmpty { null }, + oneOf = oneOf?.ifEmpty { null }, + anyOf = anyOf?.ifEmpty { null }, + discriminator = discriminator, ) } - context(componentSchemaIdentity: ComponentSchemaIdentity) - private fun Schema<*>.resolveName(): String? = `$ref`?.removePrefix(SCHEMA_PREFIX) ?: componentSchemaIdentity[this] - - context(componentSchemaIdentity: ComponentSchemaIdentity) - private val Schema<*>.isInlineObject - get(): Boolean = `$ref` == null && - this !in componentSchemaIdentity && type == "object" && !properties.isNullOrEmpty() + /** + * Extracts and merges properties from all allOf sub-schemas. + * For $ref sub-schemas (resolved by identity), looks up properties from the resolved schema. + * For inline sub-schemas, extracts properties directly. + * Also includes any top-level properties defined alongside allOf. + * Deduplicates by property name (later definition wins). + */ + private fun extractAllOfProperties(schema: Schema<*>): Pair, Set> { + val mergedRequired = mutableSetOf() + val mergedProperties = mutableMapOf() + + // Collect properties from each allOf sub-schema. + // For $ref sub-schemas (or identity-matched resolved refs), look up the + // referenced component schema to get its properties and required fields. + for (subSchema in schema.allOf.orEmpty()) { + val resolvedSchema = resolveSubSchema(subSchema) + val resolvedSchemaName = componentSchemaIdentity[resolvedSchema] ?: "Unknown" + val subRequired = resolvedSchema.required.orEmpty().toSet() + mergedRequired.addAll(subRequired) + + resolvedSchema.properties.orEmpty().forEach { (propName, propSchema) -> + val propType = if (isInlineObjectSchema(propSchema)) { + val contextName = "$resolvedSchemaName.${propName.toPascalCase()}" + createInlineTypeRef(propSchema, contextName) + } else { + schemaToTypeRef(propSchema) + } + mergedProperties[propName] = + PropertyModel( + name = propName, + type = propType, + description = propSchema.description, + nullable = propName !in mergedRequired, + defaultValue = propSchema.default, + ) + } + } - private val Schema<*>.isEnumSchema get(): Boolean = !enum.isNullOrEmpty() + // Add top-level properties (inline alongside allOf) + val topRequired = schema.required.orEmpty().toSet() + mergedRequired.addAll(topRequired) + val parentSchemaName = componentSchemaIdentity[schema] ?: "Unknown" - context(_: ComponentSchemaIdentity, _: ComponentSchemas) - private fun Schema<*>.propertyModels(required: Set, createContext: (String) -> String? = { null }) = - properties - .orEmpty() - .mapValues { (propName, propSchema) -> + schema.properties.orEmpty().forEach { (propName, propSchema) -> + val propType = if (isInlineObjectSchema(propSchema)) { + val contextName = "$parentSchemaName.${propName.toPascalCase()}" + createInlineTypeRef(propSchema, contextName) + } else { + schemaToTypeRef(propSchema) + } + mergedProperties[propName] = PropertyModel( name = propName, - type = propSchema.toTypeRef(createContext(propName)), + type = propType, description = propSchema.description, - nullable = propName !in required, + nullable = propName !in mergedRequired, defaultValue = propSchema.default, ) - } + } - private fun generateOperationId(method: HttpMethod, path: String): String { - val segments = path - .split("/") - .filter { it.isNotEmpty() } - .joinToString("") { segment -> - if (segment.startsWith("{") && segment.endsWith("}")) { - "By${segment.removePrefix("{").removeSuffix("}").toPascalCase()}" - } else { - segment.toPascalCase() - } + // Recompute nullable based on final merged required set + val finalProperties = + mergedProperties.values.map { prop -> + prop.copy(nullable = prop.name !in mergedRequired) } - return method.name.lowercase() + segments + + return finalProperties to mergedRequired } - private fun String.toPascalCase(): String = - split("-", "_", ".").joinToString("") { part -> part.replaceFirstChar { it.uppercase() } } + /** + * Resolves a sub-schema that may be a `$ref` or an identity-matched ref to the + * actual component schema with properties. Returns the sub-schema itself if it is + * an inline schema (not a reference). + */ + private fun resolveSubSchema(subSchema: Schema<*>): Schema<*> { + // Check for explicit $ref string + subSchema.`$ref`?.let { ref -> + val name = ref.removePrefix("#/components/schemas/") + componentSchemas[name]?.let { return it } + } + // Check for identity-matched resolved ref + componentSchemaIdentity[subSchema]?.let { name -> + componentSchemas[name]?.let { return it } + } + return subSchema + } - private const val JSON_CONTENT_TYPE = "application/json" - private const val SCHEMA_PREFIX = "#/components/schemas/" + /** + * Checks if a schema is an inline object schema (not a $ref, not in componentSchemaIdentity, + * is type object with properties). + */ + private fun isInlineObjectSchema(schema: Schema<*>): Boolean = schema.`$ref` == null && + componentSchemaIdentity[schema] == null && + schema.type == "object" && + !schema.properties.isNullOrEmpty() - private val STRING_FORMAT_MAP = mapOf( - "byte" to TypeRef.Primitive(PrimitiveType.BYTE_ARRAY), - "date-time" to TypeRef.Primitive(PrimitiveType.DATE_TIME), - "date" to TypeRef.Primitive(PrimitiveType.DATE), - ) + /** + * Creates a TypeRef.Inline for an inline object schema. + */ + private fun createInlineTypeRef(schema: Schema<*>, contextName: String): TypeRef { + val requiredProps = schema.required.orEmpty().toSet() + val properties = schema.properties.orEmpty().map { (propName, propSchema) -> + val propType = if (isInlineObjectSchema(propSchema)) { + // Nested inline property schema + val nestedContextName = "$contextName.${propName.toPascalCase()}" + createInlineTypeRef(propSchema, nestedContextName) + } else { + schemaToTypeRef(propSchema) + } + PropertyModel( + name = propName, + type = propType, + description = propSchema.description, + nullable = propName !in requiredProps, + defaultValue = propSchema.default, + ) + } + return TypeRef.Inline( + properties = properties, + requiredProperties = requiredProps, + contextHint = contextName, + ) + } - private val INTEGER_FORMAT_MAP = mapOf( - "int64" to TypeRef.Primitive(PrimitiveType.LONG), - ) + private fun generateOperationId(method: String, path: String,): String { + val segments = + path + .split("/") + .filter { it.isNotEmpty() } + .joinToString("") { segment -> + if (segment.startsWith("{") && segment.endsWith("}")) { + "By${segment.removePrefix("{").removeSuffix("}").toPascalCase()}" + } else { + segment.toPascalCase() + } + } + return method.lowercase() + segments + } - private val NUMBER_FORMAT_MAP = mapOf( - "float" to TypeRef.Primitive(PrimitiveType.FLOAT), - ) + private fun String.toPascalCase(): String = split("-", "_", ".").joinToString("") { part -> + part.replaceFirstChar { + it.uppercase() + } + } } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt index 8ccc15df..cf6edd64 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt @@ -1,61 +1,38 @@ package com.avsystem.justworks.core.parser -import arrow.core.raise.ExperimentalRaiseAccumulateApi -import arrow.core.raise.context.accumulate -import arrow.core.raise.context.ensureNotNullOrAccumulate -import arrow.core.raise.context.ensureOrAccumulate -import arrow.core.raise.fold import io.swagger.v3.oas.models.OpenAPI object SpecValidator { - sealed class ValidationIssue { - abstract val message: String + fun validate(openApi: OpenAPI): List { + val errors = mutableListOf() - class Error(override val message: String) : ValidationIssue() + // Missing info is an error -- required by OpenAPI spec + if (openApi.info == null) { + errors.add("[JUSTWORKS] Spec is missing required 'info' section") + } - class Warning(override val message: String) : ValidationIssue() - } - - /** - * Validates a parsed OpenAPI model for required fields and unsupported constructs. - * - * Collects all issues without short-circuiting (using Arrow [accumulate]) so that - * callers receive the full list of problems in a single call. - * - * Returned issues are either [ValidationIssue.Error] (spec is unusable) or - * [ValidationIssue.Warning] (spec can be processed but some features will be ignored). - * - * @param openApi the parsed OpenAPI model from Swagger Parser - * @return list of [ValidationIssue]; empty when the spec is fully valid - */ - @OptIn(ExperimentalRaiseAccumulateApi::class) - fun validate(openApi: OpenAPI): List = fold( - { - accumulate { - ensureNotNullOrAccumulate(openApi.info) { - ValidationIssue.Error("Spec is missing required 'info' section") - } + // Missing paths is a warning — accumulated but filtered out (not returned to caller) + if (openApi.paths.isNullOrEmpty()) { + errors.add("[JUSTWORKS] Warning: Spec has no paths defined") + } - ensureOrAccumulate(!openApi.paths.isNullOrEmpty()) { - ValidationIssue.Warning("Spec has no paths defined") - } - // Detect unsupported constructs for v1 - openApi.paths?.values?.forEach { pathItem -> - pathItem.readOperationsMap()?.values?.forEach { operation -> - ensureOrAccumulate(operation.callbacks.isNullOrEmpty()) { - ValidationIssue.Warning("Callbacks are not supported in v1 and will be ignored") - } - } + // Detect unsupported constructs for v1 + openApi.paths?.values?.forEach { pathItem -> + pathItem.readOperationsMap()?.values?.forEach { operation -> + if (!operation.callbacks.isNullOrEmpty()) { + errors.add("[JUSTWORKS] Warning: Callbacks are not supported in v1 and will be ignored") + return@forEach } + } + } - openApi.components?.links?.let { links -> - ensureOrAccumulate(links.isEmpty()) { - ValidationIssue.Warning("Links are not supported in v1 and will be ignored") - } - } + openApi.components?.links?.let { links -> + if (links.isNotEmpty()) { + errors.add("[JUSTWORKS] Warning: Links are not supported in v1 and will be ignored") } - }, - { it }, - { emptyList() }, - ) + } + + // Only return actual errors (non-warnings) as blockers + return errors.filter { !it.contains("Warning:") } + } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDedupTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDedupTest.kt index 385ad99e..f602d519 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDedupTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/InlineSchemaDedupTest.kt @@ -2,6 +2,7 @@ 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 @@ -10,7 +11,7 @@ import kotlin.test.assertNotEquals class InlineSchemaDedupTest { @Test fun `identical schemas return same name`() { - val deduplicator = InlineSchemaDeduplicator(emptySet()) + val deduplicator = InlineSchemaDeduplicator() val props1 = listOf( PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, false), @@ -26,13 +27,14 @@ class InlineSchemaDedupTest { 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(emptySet()) + val deduplicator = InlineSchemaDeduplicator() val props1 = listOf( PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, false), @@ -52,7 +54,22 @@ class InlineSchemaDedupTest { @Test fun `name collision with component schema appends Inline suffix`() { - val deduplicator = InlineSchemaDeduplicator(componentSchemaNames = setOf("Pet")) + val deduplicator = InlineSchemaDeduplicator() + + val componentSchemas = listOf( + SchemaModel( + name = "Pet", + description = null, + properties = emptyList(), + requiredProperties = emptySet(), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ), + ) + deduplicator.registerComponentSchemas(componentSchemas) val props = listOf( PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, false), @@ -65,7 +82,7 @@ class InlineSchemaDedupTest { @Test fun `property order does not affect equality`() { - val deduplicator = InlineSchemaDeduplicator(emptySet()) + val deduplicator = InlineSchemaDeduplicator() val props1 = listOf( PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), @@ -89,7 +106,7 @@ class InlineSchemaDedupTest { @Test fun `different required sets produce different keys`() { - val deduplicator = InlineSchemaDeduplicator(emptySet()) + val deduplicator = InlineSchemaDeduplicator() val props = listOf( PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, false), @@ -107,7 +124,7 @@ class InlineSchemaDedupTest { @Test fun `collision with existing inline schema name appends Inline suffix`() { - val deduplicator = InlineSchemaDeduplicator(emptySet()) + val deduplicator = InlineSchemaDeduplicator() val props1 = listOf( PropertyModel("id", TypeRef.Primitive(PrimitiveType.INT), null, false), diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorPolymorphicTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorPolymorphicTest.kt index 30486e2a..7a014e9d 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorPolymorphicTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorPolymorphicTest.kt @@ -39,6 +39,7 @@ class ModelGeneratorPolymorphicTest { description = null, properties = properties, requiredProperties = requiredProperties, + isEnum = false, allOf = allOf, oneOf = oneOf, anyOf = anyOf, diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorTest.kt index e616a390..5d56dabe 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorTest.kt @@ -7,7 +7,9 @@ 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 @@ -17,7 +19,7 @@ class ModelGeneratorTest { private val modelPackage = "com.example.model" private val generator = ModelGenerator(modelPackage) - private fun spec(schemas: List = emptyList(), enums: List = emptyList()) = ApiSpec( + private fun spec(schemas: List = emptyList(), enums: List = emptyList(),) = ApiSpec( title = "Test", version = "1.0", endpoints = emptyList(), @@ -36,6 +38,7 @@ class ModelGeneratorTest { PropertyModel("tag", TypeRef.Primitive(PrimitiveType.STRING), null, true), ), requiredProperties = setOf("id", "name"), + isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -96,7 +99,6 @@ class ModelGeneratorTest { val constructor = assertNotNull(typeSpec.primaryConstructor) val tagParam = constructor.parameters.first { it.name == "tag" } assertTrue(tagParam.type.isNullable, "Optional property 'tag' should be nullable") - assertNotNull(tagParam.defaultValue, "Optional property 'tag' should have a default value") assertEquals("null", tagParam.defaultValue.toString()) } @@ -129,6 +131,7 @@ class ModelGeneratorTest { PropertyModel("created_at", TypeRef.Primitive(PrimitiveType.STRING), null, false), ), requiredProperties = setOf("created_at"), + isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -176,6 +179,7 @@ class ModelGeneratorTest { PropertyModel("pet", TypeRef.Reference("Pet"), null, false), ), requiredProperties = setOf("pet"), + isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -286,9 +290,9 @@ class ModelGeneratorTest { .filterIsInstance() .first() val constants = typeSpec.enumConstants.entries.toList() - assertEquals("1", constants[0].key) - assertEquals("2", constants[1].key) - assertEquals("3", constants[2].key) + 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 = @@ -329,6 +333,7 @@ class ModelGeneratorTest { description = null, properties = emptyList(), requiredProperties = emptySet(), + isEnum = false, allOf = null, oneOf = null, anyOf = listOf(TypeRef.Reference("CreditCard"), TypeRef.Reference("BankTransfer")), @@ -340,6 +345,7 @@ class ModelGeneratorTest { description = null, properties = listOf(PropertyModel("cardNumber", TypeRef.Primitive(PrimitiveType.STRING), null, false)), requiredProperties = setOf("cardNumber"), + isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -366,6 +372,7 @@ class ModelGeneratorTest { description = null, properties = emptyList(), requiredProperties = emptySet(), + isEnum = false, allOf = null, oneOf = null, anyOf = listOf(TypeRef.Reference("CreditCard")), @@ -377,6 +384,7 @@ class ModelGeneratorTest { description = null, properties = listOf(PropertyModel("cardNumber", TypeRef.Primitive(PrimitiveType.STRING), null, false)), requiredProperties = setOf("cardNumber"), + isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -406,6 +414,7 @@ class ModelGeneratorTest { description = null, properties = emptyList(), requiredProperties = emptySet(), + isEnum = false, allOf = null, oneOf = null, anyOf = listOf(TypeRef.Reference("CreditCard")), @@ -421,6 +430,7 @@ class ModelGeneratorTest { description = null, properties = listOf(PropertyModel("cardNumber", TypeRef.Primitive(PrimitiveType.STRING), null, false)), requiredProperties = setOf("cardNumber"), + isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -446,6 +456,58 @@ class ModelGeneratorTest { ) } + @Test + fun `anyOf variants registered in sealedHierarchies map`() { + val paymentSchema = + SchemaModel( + name = "Payment", + description = null, + properties = emptyList(), + requiredProperties = emptySet(), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = listOf(TypeRef.Reference("CreditCard"), TypeRef.Reference("BankTransfer")), + discriminator = null, + ) + val creditCardSchema = + SchemaModel( + name = "CreditCard", + description = null, + properties = listOf(PropertyModel("cardNumber", TypeRef.Primitive(PrimitiveType.STRING), null, false)), + requiredProperties = setOf("cardNumber"), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val bankTransferSchema = + SchemaModel( + name = "BankTransfer", + description = null, + properties = listOf( + PropertyModel("accountNumber", TypeRef.Primitive(PrimitiveType.STRING), null, false), + ), + requiredProperties = setOf("accountNumber"), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + + generator.generate(spec(schemas = listOf(paymentSchema, creditCardSchema, bankTransferSchema))) + val hierarchies = generator.getSealedHierarchies() + + assertTrue("Payment" in hierarchies, "Payment should be in sealedHierarchies map") + assertEquals( + listOf("CreditCard", "BankTransfer"), + hierarchies["Payment"], + "Payment should have CreditCard and BankTransfer as variants", + ) + } + // -- Default value tests (DFLT-01 through DFLT-05) -- @Test @@ -459,6 +521,7 @@ class ModelGeneratorTest { PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false, "default-name"), ), requiredProperties = setOf("name"), + isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -488,6 +551,7 @@ class ModelGeneratorTest { PropertyModel("price", TypeRef.Primitive(PrimitiveType.DOUBLE), null, false, 19.99), ), requiredProperties = setOf("age", "price"), + isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -518,6 +582,7 @@ class ModelGeneratorTest { PropertyModel("active", TypeRef.Primitive(PrimitiveType.BOOLEAN), null, false, true), ), requiredProperties = setOf("active"), + isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -552,6 +617,7 @@ class ModelGeneratorTest { ), ), requiredProperties = setOf("createdAt"), + isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -587,6 +653,7 @@ class ModelGeneratorTest { PropertyModel("eventDate", TypeRef.Primitive(PrimitiveType.DATE), null, false, "2024-01-01"), ), requiredProperties = setOf("eventDate"), + isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -621,6 +688,7 @@ class ModelGeneratorTest { PropertyModel("required", TypeRef.Primitive(PrimitiveType.STRING), null, false, null), ), requiredProperties = setOf("required", "withDefault"), + isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -650,6 +718,7 @@ class ModelGeneratorTest { PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, true, "ignored-default"), ), requiredProperties = emptySet(), + isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -665,7 +734,6 @@ class ModelGeneratorTest { val constructor = assertNotNull(typeSpec.primaryConstructor) val param = constructor.parameters.first { it.name == "name" } assertTrue(param.type.isNullable, "Property should be nullable") - assertNotNull(param.defaultValue, "Nullable property should have a default value") assertEquals("null", param.defaultValue.toString(), "Nullable property should use null default") } @@ -687,6 +755,7 @@ class ModelGeneratorTest { PropertyModel("status", TypeRef.Reference("Status"), null, false, "active"), ), requiredProperties = setOf("status"), + isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -716,6 +785,7 @@ class ModelGeneratorTest { description = null, properties = emptyList(), requiredProperties = emptySet(), + isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -742,6 +812,7 @@ class ModelGeneratorTest { description = "Unique identifier for a user", properties = emptyList(), requiredProperties = emptySet(), + isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -783,6 +854,7 @@ class ModelGeneratorTest { PropertyModel("object", TypeRef.Primitive(PrimitiveType.STRING), null, false), ), requiredProperties = setOf("object"), + isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -795,7 +867,9 @@ class ModelGeneratorTest { .filterIsInstance() .first() + // Property should be backtick-escaped val prop = typeSpec.propertySpecs.first() + assertEquals("`object`", prop.name, "Property named 'object' should be backtick-escaped") // @SerialName should still use the original wire name val serialName = prop.annotations.first { it.typeName.toString() == "kotlinx.serialization.SerialName" } @@ -805,23 +879,48 @@ class ModelGeneratorTest { ) } - // -- ROB-01: Circular schema visited-set guard -- - @Test - fun `collectInlineTypeRefs with nested inline TypeRef does not stack overflow`() { - // Build a schema model containing a deeply nested inline structure - // (inline -> property -> another inline with the same shape) - val innerInline = TypeRef.Inline( + fun `property named with Kotlin keyword 'in' generates backtick-escaped name`() { + val schema = SchemaModel( + name = "Filter", + description = null, properties = listOf( - PropertyModel("value", TypeRef.Primitive(PrimitiveType.STRING), null, false), + PropertyModel("in", TypeRef.Primitive(PrimitiveType.STRING), null, true), ), requiredProperties = emptySet(), + isEnum = false, + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generator.generate(spec(schemas = listOf(schema))) + val typeSpec = files + .first() + .members + .filterIsInstance() + .first() + val prop = typeSpec.propertySpecs.first() + assertEquals("`in`", prop.name, "Property named 'in' should be backtick-escaped") + } + + // -- ROB-01: Circular schema visited-set guard -- + + @Test + fun `collectInlineTypeRefs with circular TypeRef does not stack overflow`() { + // Create a circular inline TypeRef: TreeNode with a property 'children' of type Array + // We use a self-referential arrangement via a property list + val treeNodeInline = TypeRef.Inline( + properties = emptyList(), // We'll override with a property referencing itself below + requiredProperties = emptySet(), contextHint = "treeNode", ) + // Build a schema model containing a property that uses a nested Inline + // (circular structure: inline -> property -> same inline structure) val selfReferencingInline = TypeRef.Inline( properties = listOf( - PropertyModel("children", TypeRef.Array(innerInline), null, true), + PropertyModel("children", TypeRef.Array(treeNodeInline), null, true), ), requiredProperties = emptySet(), contextHint = "treeNode", @@ -835,6 +934,7 @@ class ModelGeneratorTest { PropertyModel("children", TypeRef.Array(selfReferencingInline), null, true), ), requiredProperties = setOf("value"), + isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -858,6 +958,7 @@ class ModelGeneratorTest { PropertyModel("nickname", TypeRef.Primitive(PrimitiveType.STRING), null, true), ), requiredProperties = setOf("name"), + isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -873,7 +974,6 @@ class ModelGeneratorTest { val nicknameParam = constructor.parameters.first { it.name == "nickname" } assertTrue(nicknameParam.type.isNullable, "Non-required property should be nullable") - assertNotNull(nicknameParam.defaultValue, "Non-required property should have a default value") assertEquals("null", nicknameParam.defaultValue.toString(), "Non-required property should have = null default") val nameParam = constructor.parameters.first { it.name == "name" } @@ -890,6 +990,7 @@ class ModelGeneratorTest { PropertyModel("id", TypeRef.Primitive(PrimitiveType.LONG), null, false), ), requiredProperties = setOf("id"), + isEnum = false, allOf = null, oneOf = null, anyOf = null, @@ -903,6 +1004,7 @@ class ModelGeneratorTest { PropertyModel("optionalField", TypeRef.Primitive(PrimitiveType.STRING), null, true), ), requiredProperties = setOf("id"), + isEnum = false, allOf = listOf(TypeRef.Reference("Base")), oneOf = null, anyOf = null, @@ -915,7 +1017,6 @@ class ModelGeneratorTest { val optionalParam = constructor.parameters.first { it.name == "optionalField" } assertTrue(optionalParam.type.isNullable, "Non-required allOf property should be nullable") - assertNotNull(optionalParam.defaultValue, "Non-required allOf property should have a default value") assertEquals( "null", optionalParam.defaultValue.toString(), @@ -933,6 +1034,7 @@ class ModelGeneratorTest { PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false), ), requiredProperties = setOf("id", "name"), + isEnum = false, allOf = listOf(TypeRef.Reference("Base")), oneOf = null, anyOf = null, diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/NameUtilsTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/NameUtilsTest.kt index 5b1cb1a4..eb0d814b 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/NameUtilsTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/NameUtilsTest.kt @@ -16,6 +16,13 @@ class NameUtilsTest { assertEquals("kebabCase", "kebab-case".toCamelCase()) } + @Test + fun `toCamelCase converts UPPER_CASE preserving segment casing`() { + // Splits on _, gets ["UPPER", "CASE"]. No camel boundaries in all-caps segments. + // First segment lowercased -> "upper", second replaceFirstChar -> "CASE" (already upper). + assertEquals("upperCASE", "UPPER_CASE".toCamelCase()) + } + @Test fun `toCamelCase lowercases PascalCase`() { assertEquals("pascalCase", "PascalCase".toCamelCase()) @@ -36,61 +43,6 @@ class NameUtilsTest { assertEquals("alreadyCamel", "already_camel".toCamelCase()) } - @Test - fun `toCamelCase handles consecutive underscores`() { - assertEquals("fooBar", "foo__bar".toCamelCase()) - } - - @Test - fun `toCamelCase handles consecutive hyphens`() { - assertEquals("fooBar", "foo--bar".toCamelCase()) - } - - @Test - fun `toCamelCase handles mixed consecutive delimiters`() { - assertEquals("fooBar", "foo-_bar".toCamelCase()) - } - - @Test - fun `toCamelCase treats all-uppercase as single word`() { - assertEquals("url", "URL".toCamelCase()) - } - - @Test - fun `toCamelCase splits acronym followed by word`() { - assertEquals("urlMapping", "URLMapping".toCamelCase()) - } - - @Test - fun `toCamelCase splits multiple acronyms`() { - assertEquals("httpsConfig", "HTTPSConfig".toCamelCase()) - } - - @Test - fun `toCamelCase handles acronym in middle`() { - assertEquals("getUrlMapping", "getURLMapping".toCamelCase()) - } - - @Test - fun `toPascalCase treats all-uppercase as single word`() { - assertEquals("Url", "URL".toPascalCase()) - } - - @Test - fun `toPascalCase splits acronym followed by word`() { - assertEquals("UrlMapping", "URLMapping".toPascalCase()) - } - - @Test - fun `toPascalCase splits multiple acronyms`() { - assertEquals("HttpsConfig", "HTTPSConfig".toPascalCase()) - } - - @Test - fun `toPascalCase handles acronym in middle`() { - assertEquals("GetUrlMapping", "getURLMapping".toPascalCase()) - } - // -- toEnumConstantName -- @Test @@ -108,6 +60,11 @@ class NameUtilsTest { 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()) @@ -118,73 +75,85 @@ class NameUtilsTest { assertEquals("WITH_SPACES", "with spaces".toEnumConstantName()) } + // -- operationNameFromPath -- + + @Test + fun `operationNameFromPath converts simple path`() { + assertEquals("PostPets", operationNameFromPath("POST", "/pets")) + } + @Test - fun `toEnumConstantName returns original for all-special-chars input`() { - assertEquals("!!!", "!!!".toEnumConstantName()) + fun `operationNameFromPath handles path parameter`() { + assertEquals("GetPetsById", operationNameFromPath("GET", "/pets/{id}")) } @Test - fun `toEnumConstantName returns original for empty string`() { - assertEquals("", "".toEnumConstantName()) + fun `operationNameFromPath handles multiple path parameters`() { + assertEquals( + "PutUsersByUserIdOrdersByOrderId", + operationNameFromPath("PUT", "/users/{userId}/orders/{orderId}"), + ) } @Test - fun `toEnumConstantName handles consecutive underscores`() { - assertEquals("FOO_BAR", "foo__bar".toEnumConstantName()) + fun `operationNameFromPath handles hyphens in path`() { + assertEquals("GetApiTokens", operationNameFromPath("GET", "/api-tokens")) } @Test - fun `toEnumConstantName handles consecutive hyphens`() { - assertEquals("FOO_BAR", "foo--bar".toEnumConstantName()) + fun `operationNameFromPath handles underscores in path`() { + assertEquals("GetApiTokens", operationNameFromPath("GET", "/api_tokens")) } @Test - fun `toEnumConstantName splits acronym followed by word`() { - assertEquals("URL_MAPPING", "URLMapping".toEnumConstantName()) + fun `operationNameFromPath handles mixed case method`() { + assertEquals("DeletePets", operationNameFromPath("DELETE", "/pets")) } @Test - fun `toEnumConstantName handles multiple acronyms`() { - assertEquals("HTTPS_CONFIG", "HTTPSConfig".toEnumConstantName()) + fun `operationNameFromPath handles camelCase path parameter`() { + assertEquals("GetUsersByUserId", operationNameFromPath("GET", "/users/{userId}")) } - // -- operationNameFromPath -- + // -- toKotlinIdentifier -- @Test - fun `operationNameFromPath converts simple path`() { - assertEquals("PostPets", operationNameFromPath("POST", "/pets")) + fun `toKotlinIdentifier backtick-escapes object keyword`() { + assertEquals("`object`", "object".toKotlinIdentifier()) } @Test - fun `operationNameFromPath handles path parameter`() { - assertEquals("GetPetsById", operationNameFromPath("GET", "/pets/{id}")) + fun `toKotlinIdentifier backtick-escapes in keyword`() { + assertEquals("`in`", "in".toKotlinIdentifier()) } @Test - fun `operationNameFromPath handles multiple path parameters`() { - assertEquals( - "PutUsersByUserIdOrdersByOrderId", - operationNameFromPath("PUT", "/users/{userId}/orders/{orderId}"), - ) + fun `toKotlinIdentifier backtick-escapes class keyword`() { + assertEquals("`class`", "class".toKotlinIdentifier()) } @Test - fun `operationNameFromPath handles hyphens in path`() { - assertEquals("GetApiTokens", operationNameFromPath("GET", "/api-tokens")) + fun `toKotlinIdentifier leaves non-keyword unchanged`() { + assertEquals("normalName", "normalName".toKotlinIdentifier()) } @Test - fun `operationNameFromPath handles underscores in path`() { - assertEquals("GetApiTokens", operationNameFromPath("GET", "/api_tokens")) + fun `toKotlinIdentifier camelCases non-keyword with underscore`() { + assertEquals("myObject", "my_object".toKotlinIdentifier()) } @Test - fun `operationNameFromPath handles uppercase method`() { - assertEquals("DeletePets", operationNameFromPath("DELETE", "/pets")) + fun `toKotlinIdentifier backtick-escapes val keyword`() { + assertEquals("`val`", "val".toKotlinIdentifier()) } @Test - fun `operationNameFromPath handles camelCase path parameter`() { - assertEquals("GetUsersByUserId", operationNameFromPath("GET", "/users/{userId}")) + fun `toKotlinIdentifier backtick-escapes fun keyword`() { + assertEquals("`fun`", "fun".toKotlinIdentifier()) + } + + @Test + fun `toKotlinIdentifier backtick-escapes return keyword`() { + assertEquals("`return`", "return".toKotlinIdentifier()) } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGeneratorTest.kt index fbabf73e..2a733a39 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGeneratorTest.kt @@ -2,6 +2,7 @@ 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 @@ -10,20 +11,10 @@ class SerializersModuleGeneratorTest { private val modelPackage = "com.example.model" private val generator = SerializersModuleGenerator(modelPackage) - private fun hierarchyInfo( - sealedHierarchies: Map>, - anyOfWithoutDiscriminator: Set = emptySet(), - ) = ModelGenerator.HierarchyInfo( - sealedHierarchies = sealedHierarchies, - variantParents = emptyMap(), - anyOfWithoutDiscriminator = anyOfWithoutDiscriminator, - schemas = emptyList(), - ) - @Test fun `generates SerializersModule with polymorphic registration`() { val hierarchies = mapOf("Shape" to listOf("Circle", "Square")) - val fileSpec = context(hierarchyInfo(hierarchies)) { generator.generate() } + val fileSpec = generator.generate(hierarchies) assertNotNull(fileSpec, "Should generate a FileSpec for non-empty hierarchies") @@ -42,7 +33,7 @@ class SerializersModuleGeneratorTest { "Shape" to listOf("Circle", "Square"), "Animal" to listOf("Cat", "Dog"), ) - val fileSpec = context(hierarchyInfo(hierarchies)) { generator.generate() } + val fileSpec = generator.generate(hierarchies) assertNotNull(fileSpec) val initializer = @@ -61,36 +52,7 @@ class SerializersModuleGeneratorTest { @Test fun `returns null for empty hierarchies`() { - val result = context(hierarchyInfo(emptyMap())) { generator.generate() } + val result = generator.generate(emptyMap()) assertNull(result, "Should return null for empty hierarchies") } - - @Test - fun `excludes anyOf hierarchies without discriminator`() { - val hierarchies = mapOf( - "Shape" to listOf("Circle", "Square"), - "Pet" to listOf("Cat", "Dog"), - ) - val info = hierarchyInfo(hierarchies, anyOfWithoutDiscriminator = setOf("Pet")) - val fileSpec = context(info) { generator.generate() } - - assertNotNull(fileSpec) - val initializer = fileSpec.members - .filterIsInstance() - .first { it.name == "generatedSerializersModule" } - .initializer - .toString() - - assertTrue(initializer.contains("Shape"), "Should contain discriminator-based hierarchy") - assertTrue(!initializer.contains("Pet"), "Should exclude anyOf without discriminator") - } - - @Test - fun `returns null when all hierarchies are anyOf without discriminator`() { - val hierarchies = mapOf("Pet" to listOf("Cat", "Dog")) - val info = hierarchyInfo(hierarchies, anyOfWithoutDiscriminator = setOf("Pet")) - val result = context(info) { generator.generate() } - - assertNull(result, "Should return null when only non-discriminator anyOf hierarchies exist") - } } 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 index fa1a2449..53917e6b 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/TypeMappingTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/TypeMappingTest.kt @@ -1,7 +1,6 @@ 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.TypeRef import kotlin.test.Test import kotlin.test.assertEquals @@ -100,25 +99,4 @@ class TypeMappingTest { val result = TypeMapping.toTypeName(ref, pkg) assertEquals("kotlin.collections.List", result.toString()) } - - // -- Inline -- - - @Test - fun `maps Inline to ClassName using contextHint`() { - val ref = TypeRef.Inline( - properties = listOf(PropertyModel("name", TypeRef.Primitive(PrimitiveType.STRING), null, false)), - requiredProperties = setOf("name"), - contextHint = "Pet.Address", - ) - val result = TypeMapping.toTypeName(ref, pkg) - assertEquals("com.example.model.Pet_Address", result.toString()) - } - - // -- Unknown -- - - @Test - fun `maps Unknown to kotlin Any`() { - val result = TypeMapping.toTypeName(TypeRef.Unknown, pkg) - assertEquals("kotlin.Any", result.toString()) - } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt index 908a836d..f5886668 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt @@ -1,16 +1,28 @@ package com.avsystem.justworks.core.parser import com.avsystem.justworks.core.model.TypeRef +import java.io.File import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.test.fail -class SpecParserPolymorphicTest : SpecParserTestBase() { +class SpecParserPolymorphicTest { + private val parser = SpecParser() + + private fun loadResource(name: String): File { + val url = + javaClass.getResource("/$name") + ?: fail("Test resource not found: $name") + return File(url.toURI()) + } + @Test fun `allOf schema has merged properties from referenced schema`() { - val spec = parseSpec(loadResource("polymorphic-spec.yaml")) + val result = parser.parse(loadResource("polymorphic-spec.yaml")) + val spec = assertIs(result).spec val extendedDog = spec.schemas.find { it.name == "ExtendedDog" } @@ -30,7 +42,8 @@ class SpecParserPolymorphicTest : SpecParserTestBase() { @Test fun `oneOf schema preserves oneOf refs`() { - val spec = parseSpec(loadResource("polymorphic-spec.yaml")) + val result = parser.parse(loadResource("polymorphic-spec.yaml")) + val spec = assertIs(result).spec val shape = spec.schemas.find { it.name == "Shape" } @@ -44,7 +57,8 @@ class SpecParserPolymorphicTest : SpecParserTestBase() { @Test fun `discriminator is preserved in parsed model`() { - val spec = parseSpec(loadResource("polymorphic-spec.yaml")) + val result = parser.parse(loadResource("polymorphic-spec.yaml")) + val spec = assertIs(result).spec val shape = spec.schemas.find { it.name == "Shape" } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt index fc9871a3..74e21f36 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt @@ -1,14 +1,11 @@ package com.avsystem.justworks.core.parser -import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.EnumBackingType import com.avsystem.justworks.core.model.HttpMethod import com.avsystem.justworks.core.model.ParameterLocation import com.avsystem.justworks.core.model.PrimitiveType import com.avsystem.justworks.core.model.TypeRef -import org.junit.jupiter.api.TestInstance import java.io.File -import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -16,33 +13,32 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.test.fail -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class SpecParserTest : SpecParserTestBase() { - private lateinit var petstore: ApiSpec +class SpecParserTest { + private val parser = SpecParser() - @BeforeTest - fun setUp() { - if (!::petstore.isInitialized) { - petstore = parseSpec(loadResource("petstore.yaml")) - } - } - - private fun parseSpecErrors(file: File): List { - val result = SpecParser.parse(file) - check(result is ParseResult.Failure) { "Expected failure" } - return result.errors + private fun loadResource(name: String): File { + val url = + javaClass.getResource("/$name") + ?: fail("Test resource not found: $name") + return File(url.toURI()) } // -- SPEC-01: OpenAPI 3.0 parsing -- @Test fun `parse petstore yaml produces Success with endpoints`() { - assertEquals(3, petstore.endpoints.size, "Expected 3 endpoints") + val result = parser.parse(loadResource("petstore.yaml")) + val spec = assertIs(result).spec + + assertEquals(3, spec.endpoints.size, "Expected 3 endpoints") } @Test fun `parse petstore yaml produces schemas`() { - val schemaNames = petstore.schemas.map { it.name }.toSet() + val result = parser.parse(loadResource("petstore.yaml")) + val spec = assertIs(result).spec + + val schemaNames = spec.schemas.map { it.name }.toSet() assertTrue("Pet" in schemaNames, "Pet schema missing") assertTrue("NewPet" in schemaNames, "NewPet schema missing") assertTrue("Error" in schemaNames, "Error schema missing") @@ -50,7 +46,10 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `parse petstore yaml produces enums`() { - val petStatus = petstore.enums.find { it.name == "PetStatus" } + val result = parser.parse(loadResource("petstore.yaml")) + val spec = assertIs(result).spec + + val petStatus = spec.enums.find { it.name == "PetStatus" } assertNotNull(petStatus, "PetStatus enum missing") assertEquals(EnumBackingType.STRING, petStatus.type) assertEquals(listOf("available", "pending", "sold"), petStatus.values) @@ -58,8 +57,10 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `parsed Pet schema has correct properties`() { + val result = parser.parse(loadResource("petstore.yaml")) + val spec = assertIs(result).spec val pet = - petstore.schemas.find { it.name == "Pet" } + spec.schemas.find { it.name == "Pet" } ?: fail("Pet schema not found") val propMap = pet.properties.associateBy { it.name } @@ -89,8 +90,10 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `parsed GET pets endpoint has query parameter limit with INT type`() { + val result = parser.parse(loadResource("petstore.yaml")) + val spec = assertIs(result).spec val listPets = - petstore.endpoints.find { it.operationId == "listPets" } + spec.endpoints.find { it.operationId == "listPets" } ?: fail("listPets endpoint not found") assertEquals(HttpMethod.GET, listPets.method) @@ -106,8 +109,10 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `parsed GET pets petId has path parameter`() { + val result = parser.parse(loadResource("petstore.yaml")) + val spec = assertIs(result).spec val getPet = - petstore.endpoints.find { it.operationId == "getPetById" } + spec.endpoints.find { it.operationId == "getPetById" } ?: fail("getPetById endpoint not found") assertEquals(HttpMethod.GET, getPet.method) @@ -121,8 +126,10 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `parsed POST pets has requestBody referencing NewPet`() { + val result = parser.parse(loadResource("petstore.yaml")) + val spec = assertIs(result).spec val createPet = - petstore.endpoints.find { it.operationId == "createPet" } + spec.endpoints.find { it.operationId == "createPet" } ?: fail("createPet endpoint not found") assertEquals(HttpMethod.POST, createPet.method) @@ -137,14 +144,18 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `parsed endpoints have tags`() { - val listPets = petstore.endpoints.find { it.operationId == "listPets" }!! + val result = parser.parse(loadResource("petstore.yaml")) + val spec = assertIs(result).spec + val listPets = spec.endpoints.find { it.operationId == "listPets" }!! assertTrue(listPets.tags.contains("pets"), "listPets should have 'pets' tag") } @Test fun `parsed GET pets response is array of Pet`() { - val listPets = petstore.endpoints.find { it.operationId == "listPets" }!! + val result = parser.parse(loadResource("petstore.yaml")) + val spec = assertIs(result).spec + val listPets = spec.endpoints.find { it.operationId == "listPets" }!! val okResponse = listPets.responses["200"] @@ -159,7 +170,8 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `parse refs spec resolves all references`() { - val spec = parseSpec(loadResource("refs-spec.yaml")) + val result = parser.parse(loadResource("refs-spec.yaml")) + val spec = assertIs(result).spec // All schema names that are referenced should exist in schemas val allSchemaNames = (spec.schemas.map { it.name } + spec.enums.map { it.name }).toSet() @@ -189,7 +201,8 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `refs spec nested references are resolved in model`() { - val spec = parseSpec(loadResource("refs-spec.yaml")) + val result = parser.parse(loadResource("refs-spec.yaml")) + val spec = assertIs(result).spec // Order -> Item -> ItemDetails (chain of refs) val order = @@ -207,7 +220,8 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `refs spec parameter ref is resolved`() { - val spec = parseSpec(loadResource("refs-spec.yaml")) + val result = parser.parse(loadResource("refs-spec.yaml")) + val spec = assertIs(result).spec val listOrders = spec.endpoints.find { it.operationId == "listOrders" } @@ -224,17 +238,18 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `parse invalid spec returns Failure`() { - val result = SpecParser.parse(loadResource("invalid-spec.yaml")) + val result = parser.parse(loadResource("invalid-spec.yaml")) assertIs(result) } @Test fun `parse invalid spec has descriptive error messages`() { - val errors = parseSpecErrors(loadResource("invalid-spec.yaml")) + val result = parser.parse(loadResource("invalid-spec.yaml")) + val failure = assertIs(result) - assertTrue(errors.isNotEmpty(), "Failure should have error messages") + assertTrue(failure.errors.isNotEmpty(), "Failure should have error messages") // Errors should be human-readable, not empty or codes-only - errors.forEach { error -> + failure.errors.forEach { error -> assertTrue(error.length > 5, "Error message too short to be useful: '$error'") } } @@ -243,13 +258,14 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `parse swagger 2 json returns Success`() { - val result = SpecParser.parse(loadResource("petstore-v2.json")) + val result = parser.parse(loadResource("petstore-v2.json")) assertIs(result) } @Test fun `swagger 2 spec produces endpoints and schemas`() { - val spec = parseSpec(loadResource("petstore-v2.json")) + val result = parser.parse(loadResource("petstore-v2.json")) + val spec = assertIs(result).spec assertTrue(spec.endpoints.isNotEmpty(), "v2 spec should produce endpoints") assertTrue( @@ -269,7 +285,8 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `anyOf without discriminator parses successfully`() { - val spec = parseSpec(loadResource("anyof-spec.yaml")) + val result = parser.parse(loadResource("anyof-spec.yaml")) + val spec = assertIs(result).spec val unionPayment = spec.schemas.find { it.name == "UnionPayment" } assertNotNull(unionPayment, "UnionPayment schema should exist") @@ -280,7 +297,8 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `anyOf with discriminator parses successfully`() { - val spec = parseSpec(loadResource("anyof-valid-spec.yaml")) + val result = parser.parse(loadResource("anyof-valid-spec.yaml")) + val spec = assertIs(result).spec val payment = spec.schemas.find { it.name == "Payment" } assertNotNull(payment, "Payment schema should exist") @@ -291,10 +309,11 @@ class SpecParserTest : SpecParserTestBase() { } @Test - fun `mixed anyOf and oneOf raises error`() { - val errors = parseSpecErrors(loadResource("mixed-combinator-spec.yaml")) + fun `mixed anyOf and oneOf throws IllegalArgumentException`() { + val result = parser.parse(loadResource("mixed-combinator-spec.yaml")) + val failure = assertIs(result) - val errorMessages = errors.joinToString("\n") + val errorMessages = failure.errors.joinToString("\n") assertTrue( "both oneOf and anyOf" in errorMessages, "Expected error about mixed combinators, got: $errorMessages", @@ -331,7 +350,8 @@ class SpecParserTest : SpecParserTestBase() { - name """.trimIndent() - val apiSpec = parseSpec(spec.toTempFile()) + val result = parser.parse(spec.toTempFile()) + val apiSpec = assertIs(result).spec val task = apiSpec.schemas.find { it.name == "Task" } assertNotNull(task) @@ -353,7 +373,7 @@ class SpecParserTest : SpecParserTestBase() { return tempFile } - private fun collectRefs(typeRef: TypeRef?, refs: MutableSet) { + private fun collectRefs(typeRef: TypeRef?, refs: MutableSet,) { when (typeRef) { is TypeRef.Reference -> { refs.add(typeRef.schemaName) @@ -374,7 +394,7 @@ class SpecParserTest : SpecParserTestBase() { } } - is TypeRef.Primitive, TypeRef.Unknown, null -> {} + is TypeRef.Primitive, null -> {} } } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTestBase.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTestBase.kt deleted file mode 100644 index e0d5fa16..00000000 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTestBase.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.avsystem.justworks.core.parser - -import com.avsystem.justworks.core.model.ApiSpec -import java.io.File -import kotlin.test.fail - -abstract class SpecParserTestBase { - protected fun loadResource(name: String): File { - val url = - javaClass.getResource("/$name") - ?: fail("Test resource not found: $name") - return File(url.toURI()) - } - - protected fun parseSpec(file: File): ApiSpec = when (val result = SpecParser.parse(file)) { - is ParseResult.Success -> result.apiSpec - is ParseResult.Failure -> fail("Expected success but got errors: ${result.errors}") - } -} diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecValidatorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecValidatorTest.kt index 67de584d..a95cda6e 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecValidatorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecValidatorTest.kt @@ -5,12 +5,9 @@ import io.swagger.v3.oas.models.PathItem import io.swagger.v3.oas.models.Paths import io.swagger.v3.oas.models.info.Info import kotlin.test.Test -import kotlin.test.assertIs import kotlin.test.assertTrue class SpecValidatorTest { - // -- VALID-01: Valid spec -- - @Test fun `valid OpenAPI object produces no errors`() { val openApi = @@ -30,8 +27,6 @@ class SpecValidatorTest { assertTrue(errors.isEmpty(), "Valid spec should produce no errors, got: $errors") } - // -- VALID-02: Missing required fields -- - @Test fun `OpenAPI with null info produces errors`() { val openApi = @@ -43,35 +38,11 @@ class SpecValidatorTest { } } - val issues = SpecValidator.validate(openApi) - assertTrue(issues.isNotEmpty(), "Missing info should produce issues") - assertTrue( - issues.any { it.message.contains("info", ignoreCase = true) }, - "Error should mention 'info': $issues", - ) - } - - // -- VALID-03: No paths warning -- - - @Test - fun `OpenAPI with no paths produces warning`() { - val openApi = - OpenAPI().apply { - info = - Info().apply { - title = "Empty API" - version = "1.0.0" - } - paths = null - } - - val issues = SpecValidator.validate(openApi) - assertTrue(issues.isNotEmpty(), "Spec with no paths should produce issues") - val warning = issues.firstOrNull { it is SpecValidator.ValidationIssue.Warning } - assertIs(warning, "Expected a Warning for no paths, got: $issues") + val errors = SpecValidator.validate(openApi) + assertTrue(errors.isNotEmpty(), "Missing info should produce errors") assertTrue( - warning.message.contains("paths", ignoreCase = true), - "Warning should mention 'paths': ${warning.message}", + errors.any { it.contains("info", ignoreCase = true) }, + "Error should mention 'info': $errors", ) } }