diff --git a/README.md b/README.md index 9491f936..e45322ae 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,9 @@ The library currently has support for generating: * Models * **Jackson** annotated **data classes** + * **Kotlinx.serialization** annotated **data classes** * Clients - * **OkHttp Client** - with the option for a resilience4j fault-tolerance wrapper + * **OkHttp Client (w/ Jackson Models)** - with the option for a resilience4j fault-tolerance wrapper * **OpenFeign** annotated client interfaces * Controllers * **Spring MVC** annotated controller interfaces @@ -210,6 +211,10 @@ This section documents the available CLI parameters for controlling what gets ge | `--http-model-suffix` | Specify custom suffix for all generated model classes. Defaults to no suffix. | | `--output-directory` | Allows the generation dir to be overridden. Defaults to current dir | | `--resources-path` | Allows the path for generated resources to be overridden. Defaults to `src/main/resources` | +| `--serialization-library` | Specify which serialization library to use for annotations in generated model classes. Default: JACKSON | +| | CHOOSE ONE OF: | +| | `JACKSON` - Use Jackson for serialization and deserialization | +| | `KOTLINX_SERIALIZATION` - Use kotlinx.serialization for serialization and deserialization | | `--src-path` | Allows the path for generated source files to be overridden. Defaults to `src/main/kotlin` | | `--targets` | Targets are the parts of the application that you want to be generated. | | | CHOOSE ANY OF: | diff --git a/build.gradle.kts b/build.gradle.kts index fc79da18..e1692fc3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -41,6 +41,8 @@ allprojects { val jacksonVersion by extra { "2.15.1" } val junitVersion by extra { "5.9.2" } val ktorVersion by extra { "2.3.9" } +val kotlinxSerializationVersion by extra { "1.6.3" } +val kotlinxDateTimeVersion by extra { "0.6.1" } dependencies { implementation(platform("org.jetbrains.kotlin:kotlin-bom")) @@ -56,6 +58,9 @@ dependencies { implementation("com.reprezen.jsonoverlay:jsonoverlay:4.0.4") implementation("com.squareup:kotlinpoet:1.14.2") { exclude(module = "kotlin-stdlib-jre7") } implementation("com.google.flogger:flogger:0.7.4") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationVersion") + + implementation("org.jetbrains.kotlinx:kotlinx-datetime:$kotlinxDateTimeVersion") testImplementation("org.junit.jupiter:junit-jupiter-api:$junitVersion") testImplementation("org.junit.jupiter:junit-jupiter-engine:$junitVersion") diff --git a/end2end-tests/models-kotlinx/build.gradle.kts b/end2end-tests/models-kotlinx/build.gradle.kts new file mode 100644 index 00000000..20b49d07 --- /dev/null +++ b/end2end-tests/models-kotlinx/build.gradle.kts @@ -0,0 +1,67 @@ +val fabrikt: Configuration by configurations.creating + +val generationDir = "$buildDir/generated" +val apiFile = "$projectDir/openapi/api.yaml" + +sourceSets { + main { java.srcDirs("$generationDir/src/main/kotlin") } + test { java.srcDirs("$generationDir/src/test/kotlin") } +} + +plugins { + id("org.jetbrains.kotlin.jvm") version "1.8.20" // Apply the Kotlin JVM plugin to add support for Kotlin. + kotlin("plugin.serialization") version "1.8.20" +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +val junitVersion: String by rootProject.extra +val kotlinxSerializationVersion: String by rootProject.extra +val kotlinxDateTimeVersion: String by rootProject.extra + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationVersion") + implementation("org.jetbrains.kotlinx:kotlinx-datetime:$kotlinxDateTimeVersion") + + testImplementation("org.junit.jupiter:junit-jupiter-api:$junitVersion") + testImplementation("org.junit.jupiter:junit-jupiter-engine:$junitVersion") + testImplementation("org.junit.jupiter:junit-jupiter-params:$junitVersion") + testImplementation("org.assertj:assertj-core:3.24.2") +} + +tasks { + + val generateCode by creating(JavaExec::class) { + inputs.files(apiFile) + outputs.dir(generationDir) + outputs.cacheIf { true } + classpath = rootProject.files("./build/libs/fabrikt-${rootProject.version}.jar") + mainClass.set("com.cjbooms.fabrikt.cli.CodeGen") + args = listOf( + "--output-directory", generationDir, + "--base-package", "com.example", + "--api-file", apiFile, + "--validation-library", "NO_VALIDATION", + "--targets", "http_models", + "--serialization-library", "KOTLINX_SERIALIZATION", + "--http-model-opts", "SEALED_INTERFACES_FOR_ONE_OF", + ) + dependsOn(":jar") + dependsOn(":shadowJar") + } + + withType { + kotlinOptions.jvmTarget = "17" + dependsOn(generateCode) + } + + + withType { + useJUnitPlatform() + jvmArgs = listOf("--add-opens=java.base/java.lang=ALL-UNNAMED", "--add-opens=java.base/java.util=ALL-UNNAMED") + + } +} \ No newline at end of file diff --git a/end2end-tests/models-kotlinx/openapi/api.yaml b/end2end-tests/models-kotlinx/openapi/api.yaml new file mode 100644 index 00000000..5b9e8586 --- /dev/null +++ b/end2end-tests/models-kotlinx/openapi/api.yaml @@ -0,0 +1,98 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: Swagger Petstore + license: + name: MIT +servers: + - url: http://petstore.swagger.io/v1 +paths: {} +components: + schemas: + TransportationDevice: + type: object + required: + - deviceType + - make + - model + properties: + deviceType: + type: string + enum: + - bike + - skateboard + - rollerskates + - Ho_ver-boaRD + make: + type: string + model: + type: string + format: uuid + Pet: + type: object + required: + - id + - name + - dateOfBirth + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + dateOfBirth: + type: string + format: date + lastFedAt: + type: string + format: date-time + earTagUuid: + type: string + format: uuid + imageUrl: + type: string + format: uri + Pets: + type: array + maxItems: 100 + items: + $ref: "#/components/schemas/Pet" + Phone: + oneOf: + - $ref: "#/components/schemas/LandlinePhone" + - $ref: "#/components/schemas/MobilePhone" + discriminator: + propertyName: type + mapping: + landline: '#/components/schemas/LandlinePhone' + mobile: '#/components/schemas/MobilePhone' + LandlinePhone: + type: object + required: + - number + - area_code + properties: + number: + type: string + area_code: + type: string + MobilePhone: + type: object + required: + - number + properties: + number: + type: string + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/end2end-tests/models-kotlinx/src/test/kotlin/com/cjbooms/fabrikt/models/kotlinx/KotlinxSerializationEnumTest.kt b/end2end-tests/models-kotlinx/src/test/kotlin/com/cjbooms/fabrikt/models/kotlinx/KotlinxSerializationEnumTest.kt new file mode 100644 index 00000000..32503649 --- /dev/null +++ b/end2end-tests/models-kotlinx/src/test/kotlin/com/cjbooms/fabrikt/models/kotlinx/KotlinxSerializationEnumTest.kt @@ -0,0 +1,91 @@ +package com.cjbooms.fabrikt.models.kotlinx + +import com.example.models.TransportationDevice +import com.example.models.TransportationDeviceDeviceType +import kotlinx.serialization.SerializationException +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class KotlinxSerializationEnumTest { + + @Test + fun `must serialize entity with enum field`() { + val device = TransportationDevice( + deviceType = TransportationDeviceDeviceType.BIKE, + make = "Specialized", + model = "Chisel" + ) + val json = Json.encodeToString(device) + assertThat(json).isEqualTo(""" + {"deviceType":"bike","make":"Specialized","model":"Chisel"} + """.trimIndent()) + } + + @Test + fun `must deserialize entity with enum field`() { + val json = """ + {"deviceType":"bike","make":"Specialized","model":"Chisel"} + """.trimIndent() + val device = Json.decodeFromString(TransportationDevice.serializer(), json) + assertThat(device).isEqualTo( + TransportationDevice( + deviceType = TransportationDeviceDeviceType.BIKE, + make = "Specialized", + model = "Chisel" + ) + ) + } + + @Test + fun `must fail with SerializationException if enum value is not valid`() { + val json = """ + {"deviceType":"car","make":"Specialized","model":"Chisel"} + """.trimIndent() + val exception = assertThrows { + Json.decodeFromString(json) + } + assertThat(exception.message).isEqualTo("com.example.models.TransportationDeviceDeviceType does not contain element with name 'car' at path \$.deviceType") + } + + @Test + fun `must fail with SerializationException if required fields are missing`() { + val json = """ + {"deviceType":"bike"} + """.trimIndent() + val exception = assertThrows { + Json.decodeFromString(json) + } + assertThat(exception.message).contains("Fields [make, model] are required for type with serial name 'com.example.models.TransportationDevice', but they were missing at path: \$") + } + + @Test + fun `must serialize entity with enum field with mixed case`() { + val device = TransportationDevice( + deviceType = TransportationDeviceDeviceType.HO_VER_BOA_RD, + make = "Hover", + model = "Board" + ) + val json = Json.encodeToString(device) + assertThat(json).isEqualTo(""" + {"deviceType":"Ho_ver-boaRD","make":"Hover","model":"Board"} + """.trimIndent()) + } + + @Test + fun `must deserialize entity with enum field with mixed case`() { + val json = """ + {"deviceType":"Ho_ver-boaRD","make":"Hover","model":"Board"} + """.trimIndent() + val device = Json.decodeFromString(TransportationDevice.serializer(), json) + assertThat(device).isEqualTo( + TransportationDevice( + deviceType = TransportationDeviceDeviceType.HO_VER_BOA_RD, + make = "Hover", + model = "Board" + ) + ) + } +} diff --git a/end2end-tests/models-kotlinx/src/test/kotlin/com/cjbooms/fabrikt/models/kotlinx/KotlinxSerializationOneOfPolymorphicTest.kt b/end2end-tests/models-kotlinx/src/test/kotlin/com/cjbooms/fabrikt/models/kotlinx/KotlinxSerializationOneOfPolymorphicTest.kt new file mode 100644 index 00000000..88cb0321 --- /dev/null +++ b/end2end-tests/models-kotlinx/src/test/kotlin/com/cjbooms/fabrikt/models/kotlinx/KotlinxSerializationOneOfPolymorphicTest.kt @@ -0,0 +1,52 @@ +package com.cjbooms.fabrikt.models.kotlinx + +import com.example.models.LandlinePhone +import com.example.models.Phone +import kotlinx.serialization.encodeToString +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class KotlinxSerializationOneOfPolymorphicTest { + + @Test + fun `must serialize Phone with type info`() { + val phone: Phone = LandlinePhone(number = "1234567890", areaCode = "123") + val json = kotlinx.serialization.json.Json.encodeToString(phone) + + // Note that "type" is added because we are serializing a subtype of Phone + // (See https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/polymorphism.md#sealed-classes) + assertThat(json).isEqualTo(""" + {"type":"landline","number":"1234567890","area_code":"123"} + """.trimIndent()) + } + + @Test + fun `must serialize LandlinePhone without type info`() { + val phone: LandlinePhone = LandlinePhone(number = "1234567890", areaCode = "123") + val json = kotlinx.serialization.json.Json.encodeToString(phone) + + // Note that "type" is not added because we are serializing the specific class LandlinePhone + // (See https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/polymorphism.md#sealed-classes) + assertThat(json).isEqualTo(""" + {"number":"1234567890","area_code":"123"} + """.trimIndent()) + } + + @Test + fun `must deserialize Phone into LandlinePhone`() { + val json = """ + {"type":"landline","number":"1234567890","area_code":"123"} + """.trimIndent() + val phone: Phone = kotlinx.serialization.json.Json.decodeFromString(json) + assertThat(phone).isEqualTo(LandlinePhone(number = "1234567890", areaCode = "123")) + } + + @Test + fun `must deserialize LandlinePhone specific class`() { + val json = """ + {"number":"1234567890","area_code":"123"} + """.trimIndent() + val phone: LandlinePhone = kotlinx.serialization.json.Json.decodeFromString(json) + assertThat(phone).isEqualTo(LandlinePhone(number = "1234567890", areaCode = "123")) + } +} diff --git a/end2end-tests/models-kotlinx/src/test/kotlin/com/cjbooms/fabrikt/models/kotlinx/KotlinxSerializationSimpleTest.kt b/end2end-tests/models-kotlinx/src/test/kotlin/com/cjbooms/fabrikt/models/kotlinx/KotlinxSerializationSimpleTest.kt new file mode 100644 index 00000000..f7f55376 --- /dev/null +++ b/end2end-tests/models-kotlinx/src/test/kotlin/com/cjbooms/fabrikt/models/kotlinx/KotlinxSerializationSimpleTest.kt @@ -0,0 +1,67 @@ +package com.cjbooms.fabrikt.models.kotlinx + +import com.example.models.Pet +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.serialization.encodeToString +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class KotlinxSerializationSimpleTest { + + @Test + fun `must serialize Pet`() { + val pet = Pet( + id = 1, + name = "Fido", + tag = "dog", + dateOfBirth = LocalDate.parse("2020-02-04"), + lastFedAt = Instant.parse("2024-11-04T12:00:00Z") + ) + val json = kotlinx.serialization.json.Json.encodeToString(pet) + assertThat(json).isEqualTo( + """ + {"id":1,"name":"Fido","tag":"dog","dateOfBirth":"2020-02-04","lastFedAt":"2024-11-04T12:00:00Z"} + """.trimIndent() + ) + } + + @Test + fun `must deserialize Pet`() { + val json = """ + {"id": 1, "name": "Fido", "tag": "dog", "dateOfBirth": "2009-02-13", "lastFedAt": "2011-02-04T10:00:00Z", "earTagUuid": "123e4567-e89b-12d3-a456-426614174000", "imageUrl": "https://example.org/image.jpg"} + """.trimIndent() + val pet: Pet = kotlinx.serialization.json.Json.decodeFromString(json) + assertThat(pet).isEqualTo( + Pet( + id = 1, + name = "Fido", + tag = "dog", + dateOfBirth = LocalDate.parse("2009-02-13"), + lastFedAt = Instant.parse("2011-02-04T10:00:00Z"), + earTagUuid = "123e4567-e89b-12d3-a456-426614174000", // string - no native UUIDin Kotlin (yet?) + imageUrl = "https://example.org/image.jpg" // string - no native URL in Kotlin (yet?) + ) + ) + } + + @Test + fun `must serialize Pet with no tag`() { + val pet = Pet(id = 1, name = "Whiskers", dateOfBirth = LocalDate.parse("2011-03-15")) + val json = kotlinx.serialization.json.Json.encodeToString(pet) + assertThat(json).isEqualTo( + """ + {"id":1,"name":"Whiskers","dateOfBirth":"2011-03-15"} + """.trimIndent() + ) + } + + @Test + fun `must deserialize Pet with no tag`() { + val json = """ + {"id": 1, "name": "Whiskers", "dateOfBirth": "2024-09-24"} + """.trimIndent() + val pet: Pet = kotlinx.serialization.json.Json.decodeFromString(json) + assertThat(pet).isEqualTo(Pet(id = 1, name = "Whiskers", dateOfBirth = LocalDate.parse("2024-09-24"))) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 670c74da..9e4aaaa7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,4 +5,5 @@ include( "end2end-tests:openfeign", "end2end-tests:ktor", "end2end-tests:models-jackson", + "end2end-tests:models-kotlinx", ) diff --git a/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGen.kt b/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGen.kt index 2c6a99ed..449a4164 100644 --- a/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGen.kt +++ b/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGen.kt @@ -35,7 +35,8 @@ object CodeGen { codeGenArgs.srcPath, codeGenArgs.resourcesPath, codeGenArgs.validationLibrary, - codeGenArgs.externalRefResolutionMode + codeGenArgs.externalRefResolutionMode, + codeGenArgs.serializationLibrary, ) } @@ -55,7 +56,8 @@ object CodeGen { srcPath: Path, resourcesPath: Path, validationLibrary: ValidationLibrary, - externalRefResolutionMode: ExternalReferencesResolutionMode + externalRefResolutionMode: ExternalReferencesResolutionMode, + serializationLibrary: SerializationLibrary, ) { MutableSettings.updateSettings( codeGenTypes, @@ -67,7 +69,8 @@ object CodeGen { clientTarget, typeOverrides, validationLibrary, - externalRefResolutionMode + externalRefResolutionMode, + serializationLibrary, ) val suppliedApi = pathToApi.toFile().readText() diff --git a/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenArgs.kt b/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenArgs.kt index c3027d11..2e25cdf7 100644 --- a/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenArgs.kt +++ b/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenArgs.kt @@ -153,6 +153,13 @@ class CodeGenArgs { converter = ExternalReferencesResolutionModeConverter::class ) var externalRefResolutionMode: ExternalReferencesResolutionMode = ExternalReferencesResolutionMode.TARGETED + + @Parameter( + names = ["--serialization-library"], + description = "Specify which serialization library to use for annotations in generated model classes. Default: JACKSON", + converter = SerializationLibraryOptionConverter::class + ) + var serializationLibrary: SerializationLibrary = SerializationLibrary.JACKSON } class CodeGenerationTypesConverter : IStringConverter { @@ -193,6 +200,10 @@ class ExternalReferencesResolutionModeConverter: IStringConverter { + override fun convert(value: String): SerializationLibrary = convertToEnumValue(value) +} + class PackageNameValidator : IValueValidator { override fun validate(name: String, value: String) { if (!value.isValidJavaPackage()) { diff --git a/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenOptions.kt b/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenOptions.kt index 8f1e3077..e0f45258 100644 --- a/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenOptions.kt +++ b/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenOptions.kt @@ -4,6 +4,9 @@ import com.cjbooms.fabrikt.generators.JakartaAnnotations import com.cjbooms.fabrikt.generators.JavaxValidationAnnotations import com.cjbooms.fabrikt.generators.NoValidationAnnotations import com.cjbooms.fabrikt.generators.ValidationAnnotations +import com.cjbooms.fabrikt.model.SerializationAnnotations +import com.cjbooms.fabrikt.model.JacksonAnnotations +import com.cjbooms.fabrikt.model.KotlinxSerializationAnnotations enum class CodeGenerationType(val description: String) { HTTP_MODELS( @@ -86,3 +89,10 @@ enum class ExternalReferencesResolutionMode(val description: String) { override fun toString() = "`${super.toString()}` - $description" } + +enum class SerializationLibrary(val description: String, val serializationAnnotations: SerializationAnnotations) { + JACKSON("Use Jackson for serialization and deserialization", JacksonAnnotations), + KOTLINX_SERIALIZATION("Use kotlinx.serialization for serialization and deserialization", KotlinxSerializationAnnotations); + + override fun toString() = "`${super.toString()}` - $description" +} diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/MutableSettings.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/MutableSettings.kt index 1184026f..2b2e1929 100644 --- a/src/main/kotlin/com/cjbooms/fabrikt/generators/MutableSettings.kt +++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/MutableSettings.kt @@ -13,6 +13,7 @@ object MutableSettings { private lateinit var typeOverrides: MutableSet private lateinit var validationLibrary: ValidationLibrary private lateinit var externalRefResolutionMode: ExternalReferencesResolutionMode + private lateinit var serializationLibrary: SerializationLibrary fun updateSettings( genTypes: Set = emptySet(), @@ -25,6 +26,7 @@ object MutableSettings { typeOverrides: Set = emptySet(), validationLibrary: ValidationLibrary = ValidationLibrary.JAVAX_VALIDATION, externalRefResolutionMode: ExternalReferencesResolutionMode = ExternalReferencesResolutionMode.TARGETED, + serializationLibrary: SerializationLibrary = SerializationLibrary.JACKSON, ) { this.generationTypes = genTypes.toMutableSet() this.controllerOptions = controllerOptions.toMutableSet() @@ -36,6 +38,7 @@ object MutableSettings { this.typeOverrides = typeOverrides.toMutableSet() this.validationLibrary = validationLibrary this.externalRefResolutionMode = externalRefResolutionMode + this.serializationLibrary = serializationLibrary } fun addOption(option: ModelCodeGenOptionType) = modelOptions.add(option) @@ -51,4 +54,5 @@ object MutableSettings { fun typeOverrides() = this.typeOverrides.toSet() fun validationLibrary() = this.validationLibrary fun externalRefResolutionMode() = this.externalRefResolutionMode + fun serializationLibrary() = this.serializationLibrary } diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/PropertyUtils.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/PropertyUtils.kt index 68360777..3ef4a4e9 100644 --- a/src/main/kotlin/com/cjbooms/fabrikt/generators/PropertyUtils.kt +++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/PropertyUtils.kt @@ -2,6 +2,8 @@ package com.cjbooms.fabrikt.generators import com.cjbooms.fabrikt.generators.TypeFactory.maybeMakeMapValueNullable import com.cjbooms.fabrikt.generators.model.JacksonMetadata +import com.cjbooms.fabrikt.model.SerializationAnnotations +import com.cjbooms.fabrikt.model.JacksonAnnotations import com.cjbooms.fabrikt.model.KotlinTypeInfo import com.cjbooms.fabrikt.model.PropertyInfo import com.squareup.kotlinpoet.AnnotationSpec @@ -40,7 +42,11 @@ object PropertyUtils { constructorBuilder: FunSpec.Builder, classSettings: ClassSettings = ClassSettings(ClassSettings.PolymorphyType.NONE), validationAnnotations: ValidationAnnotations = JavaxValidationAnnotations, + serializationAnnotations: SerializationAnnotations = JacksonAnnotations, ) { + if (this.typeInfo is KotlinTypeInfo.UntypedObject && !serializationAnnotations.supportsAdditionalProperties) + throw UnsupportedOperationException("Untyped objects not supported by selected serialization library (${this.oasKey}: ${this.schema})") + val wrappedType = if (classSettings.isMergePatchPattern && !this.isRequired) { ClassName( @@ -53,8 +59,11 @@ object PropertyUtils { val property = PropertySpec.builder(name, wrappedType) if (this is PropertyInfo.AdditionalProperties) { + if (!serializationAnnotations.supportsAdditionalProperties) + throw UnsupportedOperationException("Additional properties not supported by selected serialization library") + property.initializer(name) - property.addAnnotation(JacksonMetadata.ignore) + serializationAnnotations.addIgnore(property) val constructorParameter: ParameterSpec.Builder = ParameterSpec.builder(name, wrappedType) constructorParameter.defaultValue("mutableMapOf()") constructorBuilder.addParameter(constructorParameter.build()) @@ -66,21 +75,19 @@ object PropertyUtils { } else { parameterizedType }.maybeMakeMapValueNullable() - classBuilder.addFunction( - FunSpec.builder("get") - .returns(Map::class.asTypeName().parameterizedBy(String::class.asTypeName(), value)) - .addStatement("return $name") - .addAnnotation(JacksonMetadata.anyGetter) - .build(), - ) - classBuilder.addFunction( - FunSpec.builder("set") - .addParameter("name", String::class) - .addParameter("value", value) - .addStatement("$name[name] = value") - .addAnnotation(JacksonMetadata.anySetter) - .build(), - ) + + val getterSpecBuilder = FunSpec.builder("get") + .returns(Map::class.asTypeName().parameterizedBy(String::class.asTypeName(), value)) + .addStatement("return $name") + serializationAnnotations.addGetter(getterSpecBuilder) + classBuilder.addFunction(getterSpecBuilder.build()) + + val setterSpecBuilder = FunSpec.builder("set") + .addParameter("name", String::class) + .addParameter("value", value) + .addStatement("$name[name] = value") + serializationAnnotations.addSetter(setterSpecBuilder) + classBuilder.addFunction(setterSpecBuilder.build()) } else { when (classSettings.polymorphyType) { ClassSettings.PolymorphyType.SUPER -> { @@ -99,20 +106,20 @@ object PropertyUtils { property.addModifiers(KModifier.OVERRIDE) classBuilder.addSuperclassConstructorParameter(name) } - property.addAnnotation(JacksonMetadata.jacksonParameterAnnotation(oasKey)) + serializationAnnotations.addParameter(property, oasKey) } - property.addAnnotation(JacksonMetadata.jacksonPropertyAnnotation(oasKey)) + serializationAnnotations.addProperty(property, oasKey) property.addValidationAnnotations(this, validationAnnotations) } ClassSettings.PolymorphyType.NONE -> { - property.addAnnotation(JacksonMetadata.jacksonParameterAnnotation(oasKey)) - property.addAnnotation(JacksonMetadata.jacksonPropertyAnnotation(oasKey)) + serializationAnnotations.addParameter(property, oasKey) + serializationAnnotations.addProperty(property, oasKey) property.addValidationAnnotations(this, validationAnnotations) } ClassSettings.PolymorphyType.ONE_OF -> { - property.addAnnotation(JacksonMetadata.jacksonPropertyAnnotation(oasKey)) + serializationAnnotations.addProperty(property, oasKey) property.addValidationAnnotations(this, validationAnnotations) } } @@ -121,7 +128,7 @@ object PropertyUtils { this as PropertyInfo.Field if (classSettings.polymorphyType in listOf(ClassSettings.PolymorphyType.SUB, ClassSettings.PolymorphyType.ONE_OF)) { property.initializer(name) - property.addAnnotation(JacksonMetadata.jacksonParameterAnnotation(oasKey)) + serializationAnnotations.addParameter(property, oasKey) val constructorParameter: ParameterSpec.Builder = ParameterSpec.builder(name, wrappedType) val discriminators = maybeDiscriminator.getDiscriminatorMappings(schemaName) when (val discriminator = discriminators.first()) { diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/model/JacksonModelGenerator.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/model/JacksonModelGenerator.kt index 763f0865..077b7f4a 100644 --- a/src/main/kotlin/com/cjbooms/fabrikt/generators/model/JacksonModelGenerator.kt +++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/model/JacksonModelGenerator.kt @@ -16,9 +16,9 @@ import com.cjbooms.fabrikt.generators.TypeFactory.createMapOfStringToType import com.cjbooms.fabrikt.generators.TypeFactory.createMutableMapOfMapsStringToStringType import com.cjbooms.fabrikt.generators.TypeFactory.createMutableMapOfStringToType import com.cjbooms.fabrikt.generators.ValidationAnnotations -import com.cjbooms.fabrikt.generators.model.JacksonMetadata.JSON_VALUE import com.cjbooms.fabrikt.generators.model.JacksonMetadata.basePolymorphicType import com.cjbooms.fabrikt.generators.model.JacksonMetadata.polymorphicSubTypes +import com.cjbooms.fabrikt.model.SerializationAnnotations import com.cjbooms.fabrikt.model.Destinations.modelsPackage import com.cjbooms.fabrikt.model.GeneratedType import com.cjbooms.fabrikt.model.KotlinTypeInfo @@ -43,6 +43,7 @@ import com.cjbooms.fabrikt.util.KaizenParserExtensions.isOneOfSuperInterface import com.cjbooms.fabrikt.util.KaizenParserExtensions.isPolymorphicSubType import com.cjbooms.fabrikt.util.KaizenParserExtensions.isPolymorphicSuperType import com.cjbooms.fabrikt.util.KaizenParserExtensions.isSimpleType +import com.cjbooms.fabrikt.util.KaizenParserExtensions.mappingKeyForSchemaName import com.cjbooms.fabrikt.util.KaizenParserExtensions.mappingKeys import com.cjbooms.fabrikt.util.KaizenParserExtensions.safeName import com.cjbooms.fabrikt.util.ModelNameRegistry @@ -67,13 +68,15 @@ import java.io.Serializable import java.net.MalformedURLException import java.net.URL -class JacksonModelGenerator( +class JacksonModelGenerator( // TODO: Rename to ModelGenerator private val packages: Packages, private val sourceApi: SourceApi, ) { private val options = MutableSettings.modelOptions() private val validationAnnotations: ValidationAnnotations = MutableSettings.validationLibrary().annotations + private val serializationAnnotations: SerializationAnnotations = MutableSettings.serializationLibrary().serializationAnnotations private val externalRefResolutionMode: ExternalReferencesResolutionMode = MutableSettings.externalRefResolutionMode() + companion object { fun toModelType(basePackage: String, typeInfo: KotlinTypeInfo, isNullable: Boolean = false): TypeName { val className = @@ -397,20 +400,21 @@ class JacksonModelGenerator( .addQuarkusReflectionAnnotation() .addMicronautIntrospectedAnnotation() .addMicronautReflectionAnnotation() + enum.entries.forEach { + val enumConstantBuilder = TypeSpec.anonymousClassBuilder() + .addSuperclassConstructorParameter(CodeBlock.of("\"$it\"")) + serializationAnnotations.addEnumConstantAnnotation(enumConstantBuilder, it) classBuilder.addEnumConstant( it.toEnumName(), - TypeSpec.anonymousClassBuilder() - .addSuperclassConstructorParameter(CodeBlock.of("\"$it\"")) - .build(), + enumConstantBuilder.build(), ) } - classBuilder.addProperty( - PropertySpec.builder("value", String::class) - .addAnnotation(JSON_VALUE) - .initializer("value") - .build(), - ) + + val valuePropSpecBuilder = PropertySpec.builder("value", String::class).initializer("value") + serializationAnnotations.addEnumPropertyAnnotation(valuePropSpecBuilder) + classBuilder.addProperty(valuePropSpecBuilder.build()) + val companion = TypeSpec.companionObjectBuilder() .addProperty( PropertySpec.builder("mapping", createMapOfStringToNonNullType(enumType)) @@ -426,6 +430,7 @@ class JacksonModelGenerator( .build(), ) .build() + return classBuilder.addType(companion).build() } @@ -467,6 +472,12 @@ class JacksonModelGenerator( for (oneOfInterface in oneOfInterfaces) { classBuilder .addSuperinterface(generatedType(packages.base, ModelNameRegistry.getOrRegister(oneOfInterface))) + + // determine the mapping key for this schema as a subtype of the oneOf interface + val mappingKey = oneOfInterface.discriminator.mappingKeyForSchemaName(schemaName) + if (mappingKey != null) { + serializationAnnotations.addSubtypeMappingAnnotation(classBuilder, mappingKey) + } } if (!generateObject) { @@ -484,6 +495,9 @@ class JacksonModelGenerator( ) } } + + serializationAnnotations.addClassAnnotation(classBuilder) + return classBuilder.build() } @@ -530,7 +544,8 @@ class JacksonModelGenerator( .addModifiers(KModifier.SEALED) if (discriminator != null && discriminator.propertyName != null) { - interfaceBuilder.addAnnotation(basePolymorphicType(discriminator.propertyName)) + serializationAnnotations.addClassAnnotation(interfaceBuilder) + serializationAnnotations.addBasePolymorphicTypeAnnotation(interfaceBuilder, discriminator.propertyName) val membersAndMappingsConsistent = members.all { member -> discriminator.mappings.any { (_, ref) -> ref.endsWith("/${member.name}") } } @@ -544,7 +559,7 @@ class JacksonModelGenerator( .mapValues { (_, value) -> toModelType(packages.base, KotlinTypeInfo.from(value.schema, value.name)) } - interfaceBuilder.addAnnotation(polymorphicSubTypes(mappings, enumDiscriminator = null)) + serializationAnnotations.addPolymorphicSubTypesAnnotation(interfaceBuilder, mappings) } for (oneOfSuperInterface in oneOfSuperInterfaces) { @@ -704,6 +719,7 @@ class JacksonModelGenerator( constructorBuilder = constructorBuilder, classSettings = classType, validationAnnotations = validationAnnotations, + serializationAnnotations = serializationAnnotations, ) } if (constructorBuilder.parameters.isNotEmpty() && classBuilder.modifiers.isEmpty()) { diff --git a/src/main/kotlin/com/cjbooms/fabrikt/model/JacksonAnnotations.kt b/src/main/kotlin/com/cjbooms/fabrikt/model/JacksonAnnotations.kt new file mode 100644 index 00000000..126fe958 --- /dev/null +++ b/src/main/kotlin/com/cjbooms/fabrikt/model/JacksonAnnotations.kt @@ -0,0 +1,46 @@ +package com.cjbooms.fabrikt.model + +import com.cjbooms.fabrikt.generators.model.JacksonMetadata +import com.cjbooms.fabrikt.generators.model.JacksonMetadata.JSON_VALUE +import com.cjbooms.fabrikt.generators.model.JacksonMetadata.basePolymorphicType +import com.cjbooms.fabrikt.generators.model.JacksonMetadata.polymorphicSubTypes +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.TypeSpec + +object JacksonAnnotations : SerializationAnnotations { + override val supportsAdditionalProperties = true + override fun addIgnore(propertySpecBuilder: PropertySpec.Builder) = + propertySpecBuilder.addAnnotation(JacksonMetadata.ignore) + + override fun addGetter(funSpecBuilder: FunSpec.Builder) = + funSpecBuilder.addAnnotation(JacksonMetadata.anyGetter) + + override fun addSetter(funSpecBuilder: FunSpec.Builder) = + funSpecBuilder.addAnnotation(JacksonMetadata.anySetter) + + override fun addProperty(propertySpecBuilder: PropertySpec.Builder, oasKey: String) = + propertySpecBuilder.addAnnotation(JacksonMetadata.jacksonPropertyAnnotation(oasKey)) + + override fun addParameter(propertySpecBuilder: PropertySpec.Builder, oasKey: String) = + propertySpecBuilder.addAnnotation(JacksonMetadata.jacksonParameterAnnotation(oasKey)) + + override fun addClassAnnotation(typeSpecBuilder: TypeSpec.Builder) = + typeSpecBuilder + + override fun addBasePolymorphicTypeAnnotation(typeSpecBuilder: TypeSpec.Builder, propertyName: String) = + typeSpecBuilder.addAnnotation(basePolymorphicType(propertyName)) + + override fun addPolymorphicSubTypesAnnotation(typeSpecBuilder: TypeSpec.Builder, mappings: Map) = + typeSpecBuilder.addAnnotation(polymorphicSubTypes(mappings, enumDiscriminator = null)) + + override fun addSubtypeMappingAnnotation(typeSpecBuilder: TypeSpec.Builder, mapping: String) = + typeSpecBuilder + + override fun addEnumPropertyAnnotation(propSpecBuilder: PropertySpec.Builder) = + propSpecBuilder.addAnnotation(JSON_VALUE) + + override fun addEnumConstantAnnotation(enumSpecBuilder: TypeSpec.Builder, enumValue: String) = + enumSpecBuilder // not applicable +} diff --git a/src/main/kotlin/com/cjbooms/fabrikt/model/KotlinTypeInfo.kt b/src/main/kotlin/com/cjbooms/fabrikt/model/KotlinTypeInfo.kt index 47f62552..9e68721c 100644 --- a/src/main/kotlin/com/cjbooms/fabrikt/model/KotlinTypeInfo.kt +++ b/src/main/kotlin/com/cjbooms/fabrikt/model/KotlinTypeInfo.kt @@ -2,6 +2,7 @@ package com.cjbooms.fabrikt.model import com.cjbooms.fabrikt.cli.CodeGenTypeOverride import com.cjbooms.fabrikt.cli.CodeGenerationType +import com.cjbooms.fabrikt.cli.SerializationLibrary.KOTLINX_SERIALIZATION import com.cjbooms.fabrikt.generators.MutableSettings import com.cjbooms.fabrikt.model.OasType.Companion.toOasType import com.cjbooms.fabrikt.util.KaizenParserExtensions.getEnumValues @@ -10,7 +11,6 @@ import com.cjbooms.fabrikt.util.KaizenParserExtensions.isNotDefined import com.cjbooms.fabrikt.util.KaizenParserExtensions.isOneOfSuperInterfaceWithDiscriminator import com.cjbooms.fabrikt.util.ModelNameRegistry import com.reprezen.kaizen.oasparser.model3.Schema -import java.io.ByteArrayInputStream import java.math.BigDecimal import java.net.URI import java.time.LocalDate @@ -24,8 +24,10 @@ sealed class KotlinTypeInfo(val modelKClass: KClass<*>, val generatedModelClassN object Text : KotlinTypeInfo(String::class) object Date : KotlinTypeInfo(LocalDate::class) + object KotlinxLocalDate : KotlinTypeInfo(kotlinx.datetime.LocalDate::class) object DateTime : KotlinTypeInfo(OffsetDateTime::class) object Instant : KotlinTypeInfo(java.time.Instant::class) + object KotlinxInstant : KotlinTypeInfo(kotlinx.datetime.Instant::class) object LocalDateTime : KotlinTypeInfo(java.time.LocalDateTime::class) object Double : KotlinTypeInfo(kotlin.Double::class) object Float : KotlinTypeInfo(kotlin.Float::class) @@ -68,14 +70,26 @@ sealed class KotlinTypeInfo(val modelKClass: KClass<*>, val generatedModelClassN fun from(schema: Schema, oasKey: String = "", enclosingSchema: EnclosingSchemaInfo? = null): KotlinTypeInfo = when (schema.toOasType(oasKey)) { - OasType.Date -> Date - OasType.DateTime -> getOverridableDateTimeType() + OasType.Date -> { + if (MutableSettings.serializationLibrary() == KOTLINX_SERIALIZATION) KotlinxLocalDate + else Date + } + OasType.DateTime -> { + if (MutableSettings.serializationLibrary() == KOTLINX_SERIALIZATION) KotlinxInstant + else getOverridableDateTimeType() + } OasType.Text -> Text OasType.Enum -> Enum(schema.getEnumValues(), ModelNameRegistry.getOrRegister(schema, enclosingSchema)) - OasType.Uuid -> Uuid - OasType.Uri -> Uri + OasType.Uuid -> { + if (MutableSettings.serializationLibrary() == KOTLINX_SERIALIZATION) Text // could possibly be Kotlin native UUID once that becomes stable + else Uuid + } + OasType.Uri -> { + if (MutableSettings.serializationLibrary() == KOTLINX_SERIALIZATION) Text // no native URI in kotlin and thus not in kotlinx.serialization either + else Uri + } OasType.Base64String -> ByteArray OasType.Binary -> getOverridableByteArray() OasType.Double -> Double diff --git a/src/main/kotlin/com/cjbooms/fabrikt/model/KotlinxSerializationAnnotations.kt b/src/main/kotlin/com/cjbooms/fabrikt/model/KotlinxSerializationAnnotations.kt new file mode 100644 index 00000000..46264ca0 --- /dev/null +++ b/src/main/kotlin/com/cjbooms/fabrikt/model/KotlinxSerializationAnnotations.kt @@ -0,0 +1,53 @@ +package com.cjbooms.fabrikt.model + +import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.TypeSpec +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +object KotlinxSerializationAnnotations : SerializationAnnotations { + /** + * Supporting "additionalProperties: true" for kotlinx serialization requires additional + * research and work due to Any type in the map (val properties: MutableMap) + * + * Currently, the generated code does not support additional properties. + * + * See also https://github.com/Kotlin/kotlinx.serialization/issues/1978 + */ + override val supportsAdditionalProperties = false + override fun addIgnore(propertySpecBuilder: PropertySpec.Builder) = + propertySpecBuilder // not applicable + + override fun addGetter(funSpecBuilder: FunSpec.Builder) = + funSpecBuilder // not applicable + + override fun addSetter(funSpecBuilder: FunSpec.Builder) = + funSpecBuilder // not applicable + + override fun addProperty(propertySpecBuilder: PropertySpec.Builder, oasKey: String) = + propertySpecBuilder.addAnnotation(AnnotationSpec.builder(SerialName::class).addMember("%S", oasKey).build()) + + override fun addParameter(propertySpecBuilder: PropertySpec.Builder, oasKey: String) = + propertySpecBuilder // not applicable + + override fun addClassAnnotation(typeSpecBuilder: TypeSpec.Builder) = + typeSpecBuilder.addAnnotation(AnnotationSpec.builder(Serializable::class).build()) + + override fun addBasePolymorphicTypeAnnotation(typeSpecBuilder: TypeSpec.Builder, propertyName: String) = + typeSpecBuilder // not applicable + + override fun addPolymorphicSubTypesAnnotation(typeSpecBuilder: TypeSpec.Builder, mappings: Map) = + typeSpecBuilder // not applicable + + override fun addSubtypeMappingAnnotation(typeSpecBuilder: TypeSpec.Builder, mapping: String) = + typeSpecBuilder.addAnnotation(AnnotationSpec.builder(SerialName::class).addMember("%S", mapping).build()) + + override fun addEnumPropertyAnnotation(propSpecBuilder: PropertySpec.Builder) = + propSpecBuilder // not applicable + + override fun addEnumConstantAnnotation(enumSpecBuilder: TypeSpec.Builder, enumValue: String) = + enumSpecBuilder.addAnnotation(AnnotationSpec.builder(SerialName::class).addMember("%S", enumValue).build()) +} diff --git a/src/main/kotlin/com/cjbooms/fabrikt/model/SerializationAnnotations.kt b/src/main/kotlin/com/cjbooms/fabrikt/model/SerializationAnnotations.kt new file mode 100644 index 00000000..57e354f5 --- /dev/null +++ b/src/main/kotlin/com/cjbooms/fabrikt/model/SerializationAnnotations.kt @@ -0,0 +1,26 @@ +package com.cjbooms.fabrikt.model + +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.TypeSpec + +sealed interface SerializationAnnotations { + /** + * Whether the annotation supports OpenAPI's additional properties + * https://spec.openapis.org/oas/v3.0.0.html#model-with-map-dictionary-properties + */ + val supportsAdditionalProperties: Boolean + + fun addIgnore(propertySpecBuilder: PropertySpec.Builder): PropertySpec.Builder + fun addGetter(funSpecBuilder: FunSpec.Builder): FunSpec.Builder + fun addSetter(funSpecBuilder: FunSpec.Builder): FunSpec.Builder + fun addProperty(propertySpecBuilder: PropertySpec.Builder, oasKey: String): PropertySpec.Builder + fun addParameter(propertySpecBuilder: PropertySpec.Builder, oasKey: String): PropertySpec.Builder + fun addClassAnnotation(typeSpecBuilder: TypeSpec.Builder): TypeSpec.Builder + fun addBasePolymorphicTypeAnnotation(typeSpecBuilder: TypeSpec.Builder, propertyName: String): TypeSpec.Builder + fun addPolymorphicSubTypesAnnotation(typeSpecBuilder: TypeSpec.Builder, mappings: Map): TypeSpec.Builder + fun addSubtypeMappingAnnotation(typeSpecBuilder: TypeSpec.Builder, mapping: String): TypeSpec.Builder + fun addEnumPropertyAnnotation(propSpecBuilder: PropertySpec.Builder): PropertySpec.Builder + fun addEnumConstantAnnotation(enumSpecBuilder: TypeSpec.Builder, enumValue: String): TypeSpec.Builder +} diff --git a/src/main/kotlin/com/cjbooms/fabrikt/util/KaizenParserExtensions.kt b/src/main/kotlin/com/cjbooms/fabrikt/util/KaizenParserExtensions.kt index 9d1fe75a..623c1225 100644 --- a/src/main/kotlin/com/cjbooms/fabrikt/util/KaizenParserExtensions.kt +++ b/src/main/kotlin/com/cjbooms/fabrikt/util/KaizenParserExtensions.kt @@ -214,6 +214,9 @@ object KaizenParserExtensions { } } + fun Discriminator.mappingKeyForSchemaName(schemaName: String): String? = + mappings.filter { it.value.endsWith(schemaName) }.keys.firstOrNull() + fun Schema.isInLinedObjectUnderAllOf(): Boolean = Overlay.of(this).pathFromRoot .splitToSequence("/") diff --git a/src/test/kotlin/com/cjbooms/fabrikt/generators/KotlinSerializationModelGeneratorTest.kt b/src/test/kotlin/com/cjbooms/fabrikt/generators/KotlinSerializationModelGeneratorTest.kt new file mode 100644 index 00000000..208cfbfa --- /dev/null +++ b/src/test/kotlin/com/cjbooms/fabrikt/generators/KotlinSerializationModelGeneratorTest.kt @@ -0,0 +1,106 @@ +package com.cjbooms.fabrikt.generators + +import com.cjbooms.fabrikt.cli.CodeGenerationType +import com.cjbooms.fabrikt.cli.ModelCodeGenOptionType +import com.cjbooms.fabrikt.cli.SerializationLibrary +import com.cjbooms.fabrikt.configurations.Packages +import com.cjbooms.fabrikt.generators.model.JacksonModelGenerator +import com.cjbooms.fabrikt.model.KotlinSourceSet +import com.cjbooms.fabrikt.model.SourceApi +import com.cjbooms.fabrikt.util.ModelNameRegistry +import com.cjbooms.fabrikt.util.ResourceHelper.readFolder +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.stream.Stream + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class KotlinSerializationModelGeneratorTest { + + @Suppress("unused") + private fun testCases(): Stream = Stream.of( + "discriminatedOneOf", + ) + + @BeforeEach + fun init() { + MutableSettings.updateSettings( + genTypes = setOf(CodeGenerationType.HTTP_MODELS), + serializationLibrary = SerializationLibrary.KOTLINX_SERIALIZATION + ) + ModelNameRegistry.clear() + } + + // @Test + // fun `debug single test`() = `correct models are generated for different OpenApi Specifications`("insert test case") + + @ParameterizedTest + @MethodSource("testCases") + fun `correct models are generated for different OpenApi Specifications`(testCaseName: String) { + print("Testcase: $testCaseName") + if (testCaseName == "discriminatedOneOf" || testCaseName == "oneOfMarkerInterface") { + MutableSettings.addOption(ModelCodeGenOptionType.SEALED_INTERFACES_FOR_ONE_OF) + } + val basePackage = "examples.${testCaseName.replace("/", ".")}" + val apiLocation = javaClass.getResource("/examples/$testCaseName/api.yaml")!! + val sourceApi = SourceApi(apiLocation.readText(), baseDir = Paths.get(apiLocation.toURI())) + val expectedModels = readFolder(Path.of("src/test/resources/examples/$testCaseName/models/kotlinx/")) + + val models = JacksonModelGenerator( + Packages(basePackage), + sourceApi, + ).generate() + + val sourceSet = setOf(KotlinSourceSet(models.files, Paths.get(""))) + val tempDirectory = Files.createTempDirectory("model_generator_test_${testCaseName.replace("/", ".")}") + sourceSet.forEach { + it.writeFileTo(tempDirectory.toFile()) + } + + val tempFolderContents = + readFolder(tempDirectory.resolve(basePackage.replace(".", File.separator)).resolve("models")) + tempFolderContents.forEach { + if (expectedModels.containsKey(it.key)) { + assertThat((it.value)).isEqualTo(expectedModels[it.key]) + } else { + assertThat(it.value).isEqualTo("File not found in expected models") + } + } + + tempDirectory.toFile().deleteRecursively() + } + + @Test + fun `schemas configured with additionalProperties results in UnsupportedOperationException`() { + val basePackage = "examples.additionalProperties" + val apiLocation = javaClass.getResource("/examples/additionalProperties/api.yaml")!! + val sourceApi = SourceApi(apiLocation.readText(), baseDir = Paths.get(apiLocation.toURI())) + + val e = assertThrows { + JacksonModelGenerator(Packages(basePackage), sourceApi,).generate() + } + assertThat(e.message).isEqualTo("Additional properties not supported by selected serialization library") + } + + @Test + fun `schemas without properties result in UnsupportedOperationException`() { + val basePackage = "examples.untypedObject" + val apiLocation = javaClass.getResource("/examples/untypedObject/api.yaml")!! + val sourceApi = SourceApi(apiLocation.readText(), baseDir = Paths.get(apiLocation.toURI())) + + val e = assertThrows { + val models = JacksonModelGenerator(Packages(basePackage), sourceApi,).generate() + val sourceSet = setOf(KotlinSourceSet(models.files, Paths.get(""))) + println(sourceSet) + } + assertThat(e.message).isEqualTo("Untyped objects not supported by selected serialization library (data: {\"type\":\"object\",\"description\":\"Any data. Object has no schema.\"})") + } +} diff --git a/src/test/kotlin/com/cjbooms/fabrikt/generators/ModelGeneratorTest.kt b/src/test/kotlin/com/cjbooms/fabrikt/generators/ModelGeneratorTest.kt index 17554e04..dc9b5fd5 100644 --- a/src/test/kotlin/com/cjbooms/fabrikt/generators/ModelGeneratorTest.kt +++ b/src/test/kotlin/com/cjbooms/fabrikt/generators/ModelGeneratorTest.kt @@ -33,6 +33,7 @@ class ModelGeneratorTest { @Suppress("unused") private fun testCases(): Stream = Stream.of( + "additionalProperties", "arrays", "anyOfOneOfAllOf", "deepNestedSharingReferences", @@ -64,6 +65,7 @@ class ModelGeneratorTest { "binary", "oneOfMarkerInterface", "byteArrayStream", + "untypedObject", ) @BeforeEach diff --git a/src/test/kotlin/com/cjbooms/fabrikt/util/ResourceHelper.kt b/src/test/kotlin/com/cjbooms/fabrikt/util/ResourceHelper.kt index 7384d842..3f2087b6 100644 --- a/src/test/kotlin/com/cjbooms/fabrikt/util/ResourceHelper.kt +++ b/src/test/kotlin/com/cjbooms/fabrikt/util/ResourceHelper.kt @@ -2,6 +2,7 @@ package com.cjbooms.fabrikt.util import java.io.FileNotFoundException import java.nio.file.Path +import kotlin.io.path.isDirectory import kotlin.io.path.listDirectoryEntries import kotlin.io.path.name import kotlin.io.path.readText @@ -11,5 +12,5 @@ object ResourceHelper { (javaClass.getResource(path) ?: throw FileNotFoundException(path)).readText() fun readFolder(path: Path): Map = - path.listDirectoryEntries().associate { it.name to it.readText() } + path.listDirectoryEntries().filterNot { it.isDirectory() }.associate { it.name to it.readText() } } diff --git a/src/test/resources/examples/additionalProperties/api.yaml b/src/test/resources/examples/additionalProperties/api.yaml new file mode 100644 index 00000000..d0409a81 --- /dev/null +++ b/src/test/resources/examples/additionalProperties/api.yaml @@ -0,0 +1,12 @@ +openapi: 3.0.0 + +components: + schemas: + Result: + type: object + required: + - message + properties: + message: + type: string + additionalProperties: true diff --git a/src/test/resources/examples/additionalProperties/models/Result.kt b/src/test/resources/examples/additionalProperties/models/Result.kt new file mode 100644 index 00000000..b15edc60 --- /dev/null +++ b/src/test/resources/examples/additionalProperties/models/Result.kt @@ -0,0 +1,28 @@ +package examples.additionalProperties.models + +import com.fasterxml.jackson.`annotation`.JsonAnyGetter +import com.fasterxml.jackson.`annotation`.JsonAnySetter +import com.fasterxml.jackson.`annotation`.JsonIgnore +import com.fasterxml.jackson.`annotation`.JsonProperty +import javax.validation.constraints.NotNull +import kotlin.Any +import kotlin.String +import kotlin.collections.Map +import kotlin.collections.MutableMap + +public data class Result( + @param:JsonProperty("message") + @get:JsonProperty("message") + @get:NotNull + public val message: String, + @get:JsonIgnore + public val properties: MutableMap = mutableMapOf(), +) { + @JsonAnyGetter + public fun `get`(): Map = properties + + @JsonAnySetter + public fun `set`(name: String, `value`: Any?) { + properties[name] = value + } +} diff --git a/src/test/resources/examples/discriminatedOneOf/models/kotlinx/SomeObj.kt b/src/test/resources/examples/discriminatedOneOf/models/kotlinx/SomeObj.kt new file mode 100644 index 00000000..7f1d8b70 --- /dev/null +++ b/src/test/resources/examples/discriminatedOneOf/models/kotlinx/SomeObj.kt @@ -0,0 +1,14 @@ +package examples.discriminatedOneOf.models + +import javax.validation.Valid +import javax.validation.constraints.NotNull +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +public data class SomeObj( + @SerialName("state") + @get:NotNull + @get:Valid + public val state: State, +) diff --git a/src/test/resources/examples/discriminatedOneOf/models/kotlinx/State.kt b/src/test/resources/examples/discriminatedOneOf/models/kotlinx/State.kt new file mode 100644 index 00000000..884ad3d4 --- /dev/null +++ b/src/test/resources/examples/discriminatedOneOf/models/kotlinx/State.kt @@ -0,0 +1,6 @@ +package examples.discriminatedOneOf.models + +import kotlinx.serialization.Serializable + +@Serializable +public sealed interface State diff --git a/src/test/resources/examples/discriminatedOneOf/models/kotlinx/StateA.kt b/src/test/resources/examples/discriminatedOneOf/models/kotlinx/StateA.kt new file mode 100644 index 00000000..14d80492 --- /dev/null +++ b/src/test/resources/examples/discriminatedOneOf/models/kotlinx/StateA.kt @@ -0,0 +1,13 @@ +package examples.discriminatedOneOf.models + +import javax.validation.constraints.NotNull +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@SerialName("a") +@Serializable +public data class StateA( + @SerialName("status") + @get:NotNull + public val status: Status = Status.A, +) : State diff --git a/src/test/resources/examples/discriminatedOneOf/models/kotlinx/StateB.kt b/src/test/resources/examples/discriminatedOneOf/models/kotlinx/StateB.kt new file mode 100644 index 00000000..8498cfa6 --- /dev/null +++ b/src/test/resources/examples/discriminatedOneOf/models/kotlinx/StateB.kt @@ -0,0 +1,16 @@ +package examples.discriminatedOneOf.models + +import javax.validation.constraints.NotNull +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@SerialName("b") +@Serializable +public data class StateB( + @SerialName("mode") + @get:NotNull + public val mode: StateBMode, + @SerialName("status") + @get:NotNull + public val status: Status = Status.B, +) : State diff --git a/src/test/resources/examples/discriminatedOneOf/models/kotlinx/StateBMode.kt b/src/test/resources/examples/discriminatedOneOf/models/kotlinx/StateBMode.kt new file mode 100644 index 00000000..450fbddf --- /dev/null +++ b/src/test/resources/examples/discriminatedOneOf/models/kotlinx/StateBMode.kt @@ -0,0 +1,21 @@ +package examples.discriminatedOneOf.models + +import kotlin.String +import kotlin.collections.Map +import kotlinx.serialization.SerialName + +public enum class StateBMode( + public val `value`: String, +) { + @SerialName("mode1") + MODE1("mode1"), + @SerialName("mode2") + MODE2("mode2"), + ; + + public companion object { + private val mapping: Map = values().associateBy(StateBMode::value) + + public fun fromValue(`value`: String): StateBMode? = mapping[value] + } +} diff --git a/src/test/resources/examples/discriminatedOneOf/models/kotlinx/Status.kt b/src/test/resources/examples/discriminatedOneOf/models/kotlinx/Status.kt new file mode 100644 index 00000000..bd620861 --- /dev/null +++ b/src/test/resources/examples/discriminatedOneOf/models/kotlinx/Status.kt @@ -0,0 +1,21 @@ +package examples.discriminatedOneOf.models + +import kotlin.String +import kotlin.collections.Map +import kotlinx.serialization.SerialName + +public enum class Status( + public val `value`: String, +) { + @SerialName("a") + A("a"), + @SerialName("b") + B("b"), + ; + + public companion object { + private val mapping: Map = values().associateBy(Status::value) + + public fun fromValue(`value`: String): Status? = mapping[value] + } +} diff --git a/src/test/resources/examples/untypedObject/api.yaml b/src/test/resources/examples/untypedObject/api.yaml new file mode 100644 index 00000000..f1c21d0f --- /dev/null +++ b/src/test/resources/examples/untypedObject/api.yaml @@ -0,0 +1,10 @@ +openapi: 3.0.0 + +components: + schemas: + Result: + type: object + properties: + data: + type: object + description: Any data. Object has no schema. diff --git a/src/test/resources/examples/untypedObject/models/Result.kt b/src/test/resources/examples/untypedObject/models/Result.kt new file mode 100644 index 00000000..7a9ee82e --- /dev/null +++ b/src/test/resources/examples/untypedObject/models/Result.kt @@ -0,0 +1,12 @@ +package examples.untypedObject.models + +import com.fasterxml.jackson.`annotation`.JsonProperty +import kotlin.Any +import kotlin.String +import kotlin.collections.Map + +public data class Result( + @param:JsonProperty("data") + @get:JsonProperty("data") + public val `data`: Map? = null, +)