javalin / javalin

A simple and modern Java and Kotlin web framework
https://javalin.io
Apache License 2.0
7.62k stars 578 forks source link

How to deal with inner Sealed / Algebraic Data Types in OpenAPI DSL? #1032

Closed n3phtys closed 3 years ago

n3phtys commented 4 years ago

Assume entities are built using sealed classes to model algebraic data types in Kotlin:

sealed class MyValues {
    object Aa : MyValues()
    object Bb : MyValues()
    data class Cc(val str: String) : MyValues()
}
data class UserWithInnerValue(val id: Long, val theValue: MyValues)

While the OpenAPI plugin's DSL correctly identifies the classes Aa, Bb, Cc, as well as MyValues, and adds them as OAS Schemata, the inheritance relationship between 'MyValues' and its children is not. In a perfect world, MyValues would be recognized as a oneOf() composition of its three children. I know Kotlin sealed class are compile time knowledge, therefore making automatic retrieval of this type of relationship during runtime nearly impossible.

Can I give the OpenAPI plugin some manual help / pointers via the DSL? As in: Can I manually overwrite the definition it outputs for MyValues; adding the oneOf relationship manually to it, while keeping the rest of the automatically generated specification?

Full source code for example:

import io.javalin.Javalin
import io.javalin.http.Context
import io.javalin.plugin.openapi.OpenApiOptions
import io.javalin.plugin.openapi.OpenApiPlugin
import io.javalin.plugin.openapi.dsl.document
import io.javalin.plugin.openapi.dsl.documented
import io.javalin.plugin.openapi.ui.SwaggerOptions
import io.swagger.v3.oas.models.info.Info

fun main() {
    val app = Javalin.create {
        it.registerPlugin(OpenApiPlugin(getOpenApiOptions()))
    }.start(8080)
    app.post("/myentity", documented(entityDocs, ::entityEchoHandler))
}

sealed class MyValues {
    object Aa : MyValues()
    object Bb : MyValues()
    data class Cc(val str: String) : MyValues()
}

data class UserWithInnerValue(val id: Long, val theValue: MyValues)

val entityDocs = document()
    .body<UserWithInnerValue>()
    .result<Unit>("400")
    .result<UserWithInnerValue>("200")

fun entityEchoHandler(ctx: Context) {
    ctx.json(ctx.body<UserWithInnerValue>())
}

private fun getOpenApiOptions(): OpenApiOptions {
    val applicationInfo: Info = Info()
        .version("1.0")
        .description("My Application")
    return OpenApiOptions(applicationInfo).path("/openapi")
        .swagger(SwaggerOptions("/swagger").title("My Swagger Documentation"))
}

Leads to following OpenAPI spec:

{
  "openapi": "3.0.1",
  "info": {
    "description": "My Application",
    "version": "1.0"
  },
  "paths": {
    "/myentity": {
      "post": {
        "summary": "Post myentity",
        "operationId": "postMyentity",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UserWithInnerValue"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UserWithInnerValue"
                }
              }
            }
          },
          "400": {
            "description": "Bad Request"
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Aa": {
        "type": "object",
        "allOf": [
          {
            "$ref": "#/components/schemas/MyValues"
          }
        ]
      },
      "Bb": {
        "type": "object",
        "allOf": [
          {
            "$ref": "#/components/schemas/MyValues"
          }
        ]
      },
      "Cc": {
        "required": [
          "str"
        ],
        "type": "object",
        "allOf": [
          {
            "$ref": "#/components/schemas/MyValues"
          },
          {
            "type": "object",
            "properties": {
              "str": {
                "type": "string"
              }
            }
          }
        ]
      },
      "MyValues": {
        "type": "object"
      },
      "UserWithInnerValue": {
        "required": [
          "id",
          "theValue"
        ],
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "format": "int64"
          },
          "theValue": {
            "$ref": "#/components/schemas/MyValues"
          }
        }
      }
    }
  }
}
n3phtys commented 4 years ago

After debugging the plugin down to swagger-core, there is a half acceptable possibility OOTB:

@io.swagger.v3.oas.annotations.media.Schema(
    oneOf = [MyValues.Aa::class, MyValues.Bb::class, MyValues::Cc::class]
)
sealed class MyValues {
    object Aa : MyValues()
    object Bb : MyValues()
    data class Cc(val str: String) : MyValues()
}

This does add the oneOf() definition to the MyValues schema, but

  1. this is horribly duplication of code for Kotlin types (I will look into this deeper, it should be possible to retroactively add these annotations dynamically inside JavalinModelResolver.kt by using something like annotatedType.getRawClass().kotlin.sealedSubclasses() )
  2. The annotation is inherited, therefore the schema for Aa is now also oneOf(Aa, Bb, Cc) (it names itself ' ' in this case by the way). This is obviously not ideal because Aa is definitely not Bb. Does anyone know how to solve this part of the problem smartly?