swagger-api / swagger-core

Examples and server integrations for generating the Swagger API Specification, which enables easy access to your REST API
http://swagger.io
Apache License 2.0
7.37k stars 2.17k forks source link

Sealed kotlin class with oneOf annotation generates a type: object #4156

Open JanC opened 2 years ago

JanC commented 2 years ago

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

stianste commented 9 months ago

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

JanC commented 9 months ago

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

lluistfc commented 9 months ago

@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

Matej-Hlatky commented 9 months ago

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

ed-curran commented 5 months ago

@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
}
gearhand commented 2 months ago

@ed-curran I'm using similar method (but without class nesting) and I have a problem, that IntResult and StringResult generates allOf, which breaks generation, when used in pair with oneOf

@Schema(
    discriminatorProperty = "type",
    discriminatorMapping = [
        DiscriminatorMapping(value = "string", schema = StringResult::class),
        DiscriminatorMapping(value = "integer", schema = IntResult::class),
    ],
    oneOf = [
        StringResult::class,
        IntResult::class
    ]
)
@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type", visible = false
)
@JsonSubTypes(
    JsonSubTypes.Type(value = IntResult::class, name = "integer"),
    JsonSubTypes.Type(value = StringResult::class, name = "string"),
)
sealed interface FeatureResult

data class StringResult(val value: String) : FeatureResult
data class IntResult(val value: Int) : FeatureResult

Resulting spec (via springdoc)

    FeatureResult:
      required:
      - type
      type: object
      properties:
        type:
          type: string
      discriminator:
        propertyName: type
        mapping:
          string: '#/components/schemas/StringResult'
          integer: '#/components/schemas/IntResult'
      oneOf:
      - $ref: '#/components/schemas/IntResult'
      - $ref: '#/components/schemas/StringResult'
    StringResult:
      required:
      - value
      type: object
      allOf:
      - $ref: '#/components/schemas/FeatureResult'
      - type: object
        properties:
          value:
            type: string
    IntResult:
      required:
      - value
      type: object
      allOf:
      - $ref: '#/components/schemas/FeatureResult'
      - type: object
        properties:
          value:
            type: integer
            format: int32

this scheme breaks generation with openapi-codegen for spring and kotlin-spring generators (maybe it's the problem of codegen tool after all)