SMILEY4 / ktor-swagger-ui

Kotlin Ktor plugin to generate OpenAPI and provide Swagger UI
Apache License 2.0
156 stars 25 forks source link

OneOf use case for request/response body #55

Closed jwinger-intel closed 10 months ago

jwinger-intel commented 1 year ago

Hey! Loving the library, but running into a a problem.

One of my backend's return types can be a list containing one of 3 different parameterized types (List<Wrap<A>>, List<Wrap<B>>, ...). From my understanding the final OpenAPI type would be something like:

"ResponseObjectName": {
  "oneOf": [
    { "type": "array", "$ref": "#/components/schemas/Wrap(A)" },
    { "type": "array", "$ref": "#/components/schemas/Wrap(B)" },
    { "type": "array", "$ref": "#/components/schemas/Wrap(C)" }
  ]
}

I'm having trouble defining that reasonably with the library.

I've tried a bunch of stuff, but right now I'm trying to define using the CustomSchemas config. I'm self-creating the list as a OpenAPI Schema object fine, and I can set the reference. But I can't figure out how to establish the definitions for Wrap<T>. That object isn't used anywhere else, so it doesn't get auto-populated by the generator. If I add the definitions to the CustomSchemas config, they're not directly referenced by a request/response so it seems like they don't get populated because it's assumed that custom schemas are self-contained.

Here's roughly what I'm doing now:

  // This tries to use the builtin SchemaBuilder to build the objects and register them as CustomSchemas.
  val oneOfSchemaTypeNames =
    listOf(
      getSchemaType<Wrap<A>>(),
      getSchemaType<Wrap<B>>(),
      getSchemaType<Wrap<C>>()
    ).map { schemaType ->
      Pair(schemaType.getSimpleTypeName(), schemaBuilder.create(schemaType))
    }.map { (schemaName, schema) ->
      val root = schema.root as Schema<Any>
      schema.definitions.map {
        openApi(it.key) { it.value as Schema<Any> }
      }
      // From debugging, this seems to register the custom type, but it never gets written because it's not used in a route def.
      openApi(schemaName) { root }
      schemaName
    }
  // Create the list of oneOf possible return types.
  // This is the part that is actually working.
  openApi("oneOfResponse") {
    Schema<Any>().apply {
      oneOfSchemaTypeNames.map { schemaName ->
        addOneOfItem(
          ArraySchema().apply {
            items(
              Schema<Any>().apply {
                `$ref`(schemaName)
              },
            )
          },
        )
      }
    }
  }

The output I get. There are no entries in #/components/schemas for any Wrap objects.

"oneOfResponse" : {
        "oneOf" : [ {
          "type" : "array",
          "items" : {
            "$ref" : "#/components/schemas/Wrap(A)"
          }
        }, {
          "type" : "array",
          "items" : {
            "$ref" : "#/components/schemas/Wrap(B)"
          }
        }, {
          "type" : "array",
          "items" : {
            "$ref" : "#/components/schemas/Wrap(C)"
          }
        } ]
      }

Am I going about this all wrong? I don't see any way to force schemas to be instantiated, and I don't really want to have to build the whole type in the CustomSchema def or create dummy endpoints to have the objects recognized. Having the refs to the smaller objects is nice.


Also, I just noticed that body types generate different component paths depending on if they are in a list:

body<List<com.foo.Outer<com.bar.Inner>() -> #/components/schemas/Outer(Inner)
body<com.foo.Outer<com.bar.Inner>() ->  #/components/schemas/com.foo.Outer<com.bar.Inner>

Maybe due to the underlying Library?

jwinger-intel commented 1 year ago

Not sure how hard this would be to implement, but in CustomSchemas another method like:

install(SwaggerUI) {
  customSchemas {
    // Blah, make all the regular customs
    // ...
    refKey = includeClass<List<Foo>>()
  }
}

It would be totally awesome and fix my issue.

I think it would take an extra member in the CustomSchema config that can be pulled by the SchemaContext.finalize. I'm working on some other stuff right now, but might try to submit a patch in the next couple days.

SMILEY4 commented 1 year ago

Hi, you are right, the schemas are not getting added to the spec, because they aren't used in any route.

I think another possible quick fix for your situation could be to allow generating all custom schemas, no matter if they are used or not (maybe as a toggle in the plugin-config).

However, imho a future-proof solution would be a proper support of "oneOf" and not just for custom schemas. Maybe with a syntax like this ... ?

body(anyOf(TypeA::class, TypeB::class)) {
    //...
}

I'll try looking into this aswell 👍

mschaller commented 1 year ago

I think another possible quick fix for your situation could be to allow generating all custom schemas, no matter if they are used or not (maybe as a toggle in the plugin-config)

Hi @SMILEY4, how would that quickfix look like?

fyi: I also realized that the schemaEncoder only overwrites 'used' Types but I managed to configure the schema-builder to serialize my OffsetDateTimes with the '$ref'-field and point it to '#/components/schemas/DateTime'. But unfortunately my customSchema is not merged into the oas:

here my code

            customSchemas {
                // https://github.com/SMILEY4/ktor-swagger-ui/wiki/Request-and-Response-Bodies-Custom-Schemas#defining-custom-schemas
                json("DateTime") {
                    """
                    {   
                        "type": "string",
                        "format": "date-time",
                        "description: "A timestamp as defined by RFC 3339, section 5.6.",
                        "example: "2017-07-21T17:32:28Z"
                    }
                    """.trimIndent()
                }
            }
            encoding {
                EncodingConfig.DEFAULT_SCHEMA_GENERATOR = SchemaGenerator(
                    schemaGeneratorConfigBuilder().also {
                        it.forTypesInGeneral()
                        .withTypeAttributeOverride { objectNode: ObjectNode, typeScope: TypeScope, _: SchemaGenerationContext ->
                            if (typeScope.type.erasedType == OffsetDateTime::class.java) {
                                objectNode
                                    .put("\$ref", "#/components/schemas/DateTime")

                            }
                        }
                    } .build()
                )
            }
SMILEY4 commented 1 year ago

Currently, the plugin iterates over all configured routes and extracts the used schemas from the documentation-block. So for example, this ...

install(SwaggerUI) {
    customSchemas {
        json("customModel") { "..." }
    }
}

post("pets", {
    request {
        body<NewPet>()
    }
    response {
        HttpStatusCode.OK to {
            body<Pet>()
        }
    }
}) {
    // handle request ...
}

... would only generate and add schemas for NewPet and Pet, not for customModel since its not referenced in any route. Same with your example. DateTime is not used anywhere (it does not parse the $ref-fields) so its not included.

My proposed quick solution would be to add a flag e.g. "alwaysInclude " in the plugin-config like this:

install(SwaggerUI) {
    customSchemas {
        alwaysInclude = true
        json("customModel") { "..." }
    }
}

post("pets", {
    request {
        body<NewPet>()
    }
    response {
        HttpStatusCode.OK to {
            body<Pet>()
        }
    }
}) {
    // handle request ...
}

... which would result in all custom schemas in to be always added to the spec, no matter if used in any route or not. This would result in the custom customModel-Schema (or DateTime in your example) to be added at #/components/schemas/customModel (or #/components/schemas/DateTime).

SMILEY4 commented 10 months ago

refined api for simple body-schema-customization with version 2.6.0