Skip to content

Commit

Permalink
Add support for custom discriminator name
Browse files Browse the repository at this point in the history
Kotlin Serialization by default uses `type` for the JSON class discriminator property and thus we need to specify the property name explicitly when it is not `type`.

Note that `JsonClassDiscriminator` is part of the experimental serialization API and requires opt-in from the user.
  • Loading branch information
ulrikandersen committed Dec 20, 2024
1 parent 63176bf commit cd2c6e4
Show file tree
Hide file tree
Showing 4 changed files with 202 additions and 28 deletions.
54 changes: 54 additions & 0 deletions end2end-tests/models-kotlinx/openapi/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ components:
maxItems: 100
items:
$ref: "#/components/schemas/Pet"
# Polymorphic type with type as discriminator
Phone:
oneOf:
- $ref: "#/components/schemas/LandlinePhone"
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<String, TypeName>) =
typeSpecBuilder // not applicable
Expand Down
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit cd2c6e4

Please sign in to comment.