diff --git a/end2end-tests/models-kotlinx/openapi/api.yaml b/end2end-tests/models-kotlinx/openapi/api.yaml index 912c5e82..d1ddc46e 100644 --- a/end2end-tests/models-kotlinx/openapi/api.yaml +++ b/end2end-tests/models-kotlinx/openapi/api.yaml @@ -59,6 +59,7 @@ components: maxItems: 100 items: $ref: "#/components/schemas/Pet" + # Polymorphic type with type as discriminator Phone: oneOf: - $ref: "#/components/schemas/LandlinePhone" @@ -91,6 +92,59 @@ components: type: string number: type: string + # Polymorphic type with moduleType as discriminator + Module: + oneOf: + - $ref: "#/components/schemas/ModuleA" + - $ref: "#/components/schemas/ModuleB" + discriminator: + propertyName: moduleType + mapping: + a: '#/components/schemas/ModuleA' + b: '#/components/schemas/ModuleB' + ModuleA: + type: object + required: + - moduleType + properties: + moduleType: + type: string + ModuleB: + type: object + required: + - moduleType + properties: + moduleType: + type: string + # Polymorphic type with enum as discriminator + State: + oneOf: + - $ref: "#/components/schemas/StateA" + - $ref: "#/components/schemas/StateB" + discriminator: + propertyName: status + mapping: + a: '#/components/schemas/StateA' + b: '#/components/schemas/StateB' + StateA: + type: object + required: + - status + properties: + status: + $ref: '#/components/schemas/Status' + StateB: + type: object + required: + - status + properties: + status: + $ref: '#/components/schemas/Status' + Status: + type: string + enum: + - a + - b Error: type: object required: 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 index 88cb0321..7269f90e 100644 --- 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 @@ -1,52 +1,153 @@ package com.cjbooms.fabrikt.models.kotlinx import com.example.models.LandlinePhone +import com.example.models.Module +import com.example.models.ModuleA import com.example.models.Phone +import com.example.models.State +import com.example.models.StateA +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.encodeToString import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested 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) + @Nested + inner class DefaultClassDiscriminator { - // 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(""" + /** + * "Phone" class hierarchy uses the default polymorphic discriminator "type" + */ + + @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) + @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(""" + // 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 = """ + @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")) - } + 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 = """ + @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")) + val phone: LandlinePhone = kotlinx.serialization.json.Json.decodeFromString(json) + assertThat(phone).isEqualTo(LandlinePhone(number = "1234567890", areaCode = "123")) + } + } + + @OptIn(ExperimentalSerializationApi::class) + @Nested + inner class ExplicitClassDiscriminator { + + /** + * "Module" class hierarchy uses "moduleType" as discriminator + */ + + @Test + fun `must serialize Module with type info`() { + val module: Module = ModuleA + val json = kotlinx.serialization.json.Json.encodeToString(module) + + // Note that "moduleType" is added because we are serializing a subtype of Module + // (See https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/polymorphism.md#sealed-classes) + assertThat(json).isEqualTo( + """ + {"moduleType":"a"} + """.trimIndent() + ) + } + + @Test + fun `must serialize ModuleA without type info`() { + val module: ModuleA = ModuleA + val json = kotlinx.serialization.json.Json.encodeToString(module) + + // Note that "moduleType" is not added because we are serializing the specific class ModuleA + // (See https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/polymorphism.md#sealed-classes) + assertThat(json).isEqualTo( + """ + {} + """.trimIndent() + ) + } + + @Test + fun `must deserialize Module into ModuleA`() { + val json = """ + {"moduleType":"a"} + """.trimIndent() + val module: Module = kotlinx.serialization.json.Json.decodeFromString(json) + assertThat(module).isEqualTo(ModuleA) + } + + @Test + fun `must deserialize ModuleA specific class`() { + val json = """ + {} + """.trimIndent() + val module: ModuleA = kotlinx.serialization.json.Json.decodeFromString(json) + assertThat(module).isEqualTo(ModuleA) + } + } + + @OptIn(ExperimentalSerializationApi::class) + @Nested + inner class EnumDiscriminator { + + /** + * "State" class hierarchy uses enum "status" as discriminator + */ + + @Test + fun `must serialize State with type info`() { + val module: State = StateA + val json = kotlinx.serialization.json.Json.encodeToString(module) + + // Note that "moduleType" is added because we are serializing a subtype of Module + // (See https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/polymorphism.md#sealed-classes) + assertThat(json).isEqualTo( + """ + {"status":"a"} + """.trimIndent() + ) + } + + @Test + fun `must deserialize State into StateA`() { + val json = """ + {"status":"a"} + """.trimIndent() + val state: State = kotlinx.serialization.json.Json.decodeFromString(json) + assertThat(state).isEqualTo(StateA) + } } } diff --git a/src/main/kotlin/com/cjbooms/fabrikt/model/KotlinxSerializationAnnotations.kt b/src/main/kotlin/com/cjbooms/fabrikt/model/KotlinxSerializationAnnotations.kt index a5057183..1cba70c0 100644 --- a/src/main/kotlin/com/cjbooms/fabrikt/model/KotlinxSerializationAnnotations.kt +++ b/src/main/kotlin/com/cjbooms/fabrikt/model/KotlinxSerializationAnnotations.kt @@ -1,14 +1,19 @@ package com.cjbooms.fabrikt.model import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.PropertySpec import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.TypeSpec +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonClassDiscriminator object KotlinxSerializationAnnotations : SerializationAnnotations { + private const val DEFAULT_JSON_CLASS_DISCRIMINATOR = "type" + /** * Polymorphic class discriminators are added as annotations in kotlinx serialization. * Including them in the class definition causes compilation errors since the property name @@ -44,8 +49,18 @@ object KotlinxSerializationAnnotations : SerializationAnnotations { override fun addClassAnnotation(typeSpecBuilder: TypeSpec.Builder) = typeSpecBuilder.addAnnotation(AnnotationSpec.builder(Serializable::class).build()) + @OptIn(ExperimentalSerializationApi::class) override fun addBasePolymorphicTypeAnnotation(typeSpecBuilder: TypeSpec.Builder, propertyName: String) = - typeSpecBuilder // not applicable + if (propertyName != DEFAULT_JSON_CLASS_DISCRIMINATOR) { + typeSpecBuilder.addAnnotation( + AnnotationSpec.builder(JsonClassDiscriminator::class).addMember("%S", propertyName).build() + ) + val experimentalSerializationApiAnnotation = AnnotationSpec.builder( + // necessary because ExperimentalSerializationApi "can only be used as an annotation or as an argument to @OptIn" + ClassName("kotlinx.serialization", "ExperimentalSerializationApi") + ).build() + typeSpecBuilder.addAnnotation(experimentalSerializationApiAnnotation) + } else typeSpecBuilder override fun addPolymorphicSubTypesAnnotation(typeSpecBuilder: TypeSpec.Builder, mappings: Map) = typeSpecBuilder // not applicable diff --git a/src/test/resources/examples/discriminatedOneOf/models/kotlinx/State.kt b/src/test/resources/examples/discriminatedOneOf/models/kotlinx/State.kt index 884ad3d4..27285758 100644 --- a/src/test/resources/examples/discriminatedOneOf/models/kotlinx/State.kt +++ b/src/test/resources/examples/discriminatedOneOf/models/kotlinx/State.kt @@ -1,6 +1,10 @@ package examples.discriminatedOneOf.models +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonClassDiscriminator @Serializable +@JsonClassDiscriminator("status") +@ExperimentalSerializationApi public sealed interface State