swagger-core icon indicating copy to clipboard operation
swagger-core copied to clipboard

Sealed kotlin class with oneOf annotation generates a type: object

Open JanC opened this issue 3 years ago • 7 comments

Describe the bug Given a Kotlin sealed class annotated with @Schema(oneOf = ..), swagger-core resolves a schema which contains both type: object and oneOf

To Reproduce Model classes:

@Schema(
    oneOf = [AnyShape.Square::class, AnyShape.Circle::class],
    discriminatorProperty = "type"
)
sealed class AnyShape {
    data class Square(val size: Float) : AnyShape() {
        val type = ShapeType.Square
    }

    data class Circle(val radius: Float) : AnyShape() {
        val type = ShapeType.Circle
    }
}

@Schema(enumAsRef = true)
enum class ShapeType { Square, Circle }

Unit test to reproduce:

    @Test
    fun `oneOf annotation generates a oneOf schema`() {
        val resolvedSchema: ResolvedSchema = ModelConverters.getInstance()
            .resolveAsResolvedSchema(AnnotatedType(AnyShape::class.java))

        assertNotEquals("object", resolvedSchema.schema.type)

        val composed = resolvedSchema.schema as ComposedSchema
        assertEquals(composed.oneOf.size, 2)

    }

The issue with the both oneOf and type: object being present is that other swagger libraries have to make the assumption as to how to parse such a schema.

See my other ticket https://github.com/yonaskolb/SwagGen/issues/302

I wonder if the type should be simply omitted for composed schemes here https://github.com/swagger-api/swagger-core/blob/0a16eb7c9be4475e90957e3b91b6e03ace5124f6/modules/swagger-core/src/main/java/io/swagger/v3/core/jackson/ModelResolver.java#L491-L494

JanC avatar Apr 04 '22 07:04 JanC

@JanC any update on this? Did you figure out any workaround?

stianste avatar Dec 07 '23 09:12 stianste

hey, nope :) I gave up on trying. It seems Kotlin sealed classes and openapi generation (or the other way round) is a bit problematic.

JanC avatar Dec 08 '23 16:12 JanC

@JanC I tried the following and kinda got the result I believe you need:

@GetMapping("/test")
    @ApiResponses(value = [
        ApiResponse(responseCode = "200", description = "OK", content = [
            (Content(mediaType = "application/json", schema = Schema(implementation = Property::class)))
        ])])
    fun test() = Property(Attributes.TypeA("title"))
    data class Property(val attributes: Attributes)
    @Schema(name = "Property.Attributes", oneOf = [Attributes.TypeA::class, Attributes.TypeB::class])
    sealed interface Attributes {
        data class TypeA(val title: String): Attributes
        data class TypeB(val name: String): Attributes
    }

image

image

Not sure if the difference is that I am using a sealed interface and you a sealed class, but you could try

lluistfc avatar Dec 14 '23 11:12 lluistfc

Hi @lluistfc, It looks like, Kotlin sealed interface and also sealed class from the Swagger perspective works the same.

However, the issue is that the crucial "discriminator" property is not present in generated JSON schema, which makes it useless for code gen. See this example:

@Schema(
    description = "Root model.",
    subTypes = [RootModel.Child1::class, RootModel.Child2::class],
    discriminatorProperty = "type", // same as default value in KotlinX Json serialization
)
@Serializable
sealed interface RootModel {

    @Serializable
    @SerialName("Child1")
    @Schema(description = "Child1")
    data class Child1(
        val prop1: String,
    ) : RootModel

    @Serializable
    @SerialName("Child2")
    @Schema(description = "Child2")
    data class Child2(
        val prop1: String,
    ) : RootModel
}

Please can someone direct us to a working example of Kotlin sealed interface / class (or anything) using KotlinX Serialization (so the type property is actually not present in root model) with working discriminator property generated in JSON Schema? Thanks

Matej-Hlatky avatar Dec 15 '23 12:12 Matej-Hlatky

@Matej-Hlatky I think this works. I'm using jackson for serialisation, not sure if that helps you, but swagger-core uses jackson so I think its the easiest way.

//tell jackson how to serialise and deserialise with discriminator included
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type")
@JsonSubTypes(
    JsonSubTypes.Type(name = "a", value = DiscriminatedOptions.OptionsA::class),
    JsonSubTypes.Type(name = "b", value = DiscriminatedOptions.OptionsB::class),
)
//configure the json schema to use a discriminated oneof with the same mapping as we told jackson to use
@Schema(
    required = false,
    oneOf = [
        DiscriminatedOptions.OptionsA::class,
        DiscriminatedOptions.OptionsB::class,
    ],
    //important that these match the subtype names in @JsonSubTypes
    discriminatorMapping = [
        DiscriminatorMapping(value = "a", schema = DiscriminatedOptions.OptionsA::class),
        DiscriminatorMapping(value = "b", schema = DiscriminatedOptions.OptionsB::class),
])
sealed interface DiscriminatedOptions {
    data class OptionsA(val property1: String) : DiscriminatedOptions
    data class OptionsB(val property2: String): DiscriminatedOptions
}

ed-curran avatar Apr 10 '24 11:04 ed-curran