Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix for OneOf Subtyping - Generators were failing to generate proper discriminators #90

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Currently Supported:
- Exception handling (use `.throws(ex) {}` in the routes with an APIException object) with Status pages interop (with .withAPI in the StatusPages configuration)
- tags (`.tag(tag) {}` in route with a tag object, currently must be an enum, but may be subject to change)
- Spec compliant Parameter Parsing (see basic example)
- Legacy Polymorphism with use of `@DiscriminatorAnnotation()` attribute and sealed classes

Extra Features:
- Includes Swagger-UI (enabled by default, can be managed in the `install(OpenAPIGen) { ... }` section)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.papsign.ktor.openapigen.annotations.type.string.example

import com.papsign.ktor.openapigen.model.schema.DataFormat
import com.papsign.ktor.openapigen.model.schema.DataType
import com.papsign.ktor.openapigen.model.schema.Discriminator
import com.papsign.ktor.openapigen.model.schema.SchemaModel
import com.papsign.ktor.openapigen.schema.processor.SchemaProcessor
import com.papsign.ktor.openapigen.schema.processor.SchemaProcessorAnnotation
import kotlin.reflect.KType

@Target(AnnotationTarget.CLASS)
@SchemaProcessorAnnotation(LegacyDiscriminatorProcessor::class)
annotation class DiscriminatorAnnotation(val fieldName: String = "type")

// Difference between legacy mode and current
// https://github.com/OpenAPITools/openapi-generator/blob/master/docs/generators/typescript.md
// For non-legacy mapping is from sub-types to base-type by allOf
// This implementation follow previous implementation, so we need to have
// - discriminatorName in each type Parameters array
// - { discriminator: { propertyName: discriminatorName } } in each type
object LegacyDiscriminatorProcessor : SchemaProcessor<DiscriminatorAnnotation> {
override fun process(model: SchemaModel<*>, type: KType, annotation: DiscriminatorAnnotation): SchemaModel<*> {
val mapElement = (annotation.fieldName to SchemaModel.SchemaModelLitteral<String>(
DataType.string,
DataFormat.string,
false
))

if (model is SchemaModel.OneSchemaModelOf<*>) {
return SchemaModel.OneSchemaModelOf(
model.oneOf,
mapOf(mapElement),
Discriminator(annotation.fieldName)
)
}

if (model is SchemaModel.SchemaModelObj<*>) {

return SchemaModel.SchemaModelObj(
model.properties + mapElement,
model.required,
model.nullable,
model.example,
model.examples,
model.type,
model.description,
Discriminator(annotation.fieldName)
)
}

return model
}

}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.papsign.ktor.openapigen.content.type.ktor

import com.papsign.ktor.openapigen.unitKType
import com.papsign.ktor.openapigen.OpenAPIGen
import com.papsign.ktor.openapigen.OpenAPIGenModuleExtension
import com.papsign.ktor.openapigen.annotations.encodings.APIRequestFormat
Expand All @@ -13,15 +12,13 @@ import com.papsign.ktor.openapigen.model.schema.SchemaModel
import com.papsign.ktor.openapigen.modules.ModuleProvider
import com.papsign.ktor.openapigen.modules.ofType
import com.papsign.ktor.openapigen.schema.builder.provider.FinalSchemaBuilderProviderModule
import io.ktor.application.ApplicationCall
import io.ktor.application.call
import io.ktor.application.featureOrNull
import io.ktor.features.ContentNegotiation
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.request.receive
import io.ktor.response.respond
import io.ktor.util.pipeline.PipelineContext
import com.papsign.ktor.openapigen.unitKType
import io.ktor.application.*
import io.ktor.features.*
import io.ktor.http.*
import io.ktor.request.*
import io.ktor.response.*
import io.ktor.util.pipeline.*
import kotlin.reflect.KType
import kotlin.reflect.full.findAnnotation
import kotlin.reflect.jvm.jvmErasure
Expand All @@ -40,45 +37,65 @@ object KtorContentProvider : ContentTypeProvider, BodyParser, ResponseSerializer
return contentTypes
}

override fun <T> getMediaType(type: KType, apiGen: OpenAPIGen, provider: ModuleProvider<*>, example: T?, usage: ContentTypeProvider.Usage):Map<ContentType, MediaTypeModel<T>>? {
override fun <T> getMediaType(
type: KType,
apiGen: OpenAPIGen,
provider: ModuleProvider<*>,
example: T?,
usage: ContentTypeProvider.Usage
): Map<ContentType, MediaTypeModel<T>>? {
if (type == unitKType) return null
val clazz = type.jvmErasure
when (usage) { // check if it is explicitly declared or none is present
ContentTypeProvider.Usage.PARSE -> when {
clazz.findAnnotation<KtorRequest>() != null -> {}
clazz.annotations.none { it.annotationClass.findAnnotation<APIRequestFormat>() != null } -> {}
clazz.findAnnotation<KtorRequest>() != null -> {
}
clazz.annotations.none { it.annotationClass.findAnnotation<APIRequestFormat>() != null } -> {
}
else -> return null
}
ContentTypeProvider.Usage.SERIALIZE -> when {
clazz.findAnnotation<KtorResponse>() != null -> {}
clazz.annotations.none { it.annotationClass.findAnnotation<APIResponseFormat>() != null } -> {}
clazz.findAnnotation<KtorResponse>() != null -> {
}
clazz.annotations.none { it.annotationClass.findAnnotation<APIResponseFormat>() != null } -> {
}
else -> return null
}
}
val contentTypes = initContentTypes(apiGen) ?: return null
val schemaBuilder = provider.ofType<FinalSchemaBuilderProviderModule>().last().provide(apiGen, provider)

@Suppress("UNCHECKED_CAST")
val media = MediaTypeModel(schemaBuilder.build(type) as SchemaModel<T>, example)
val media = MediaTypeModel(schemaBuilder.build(type) as SchemaModel<T>, example)
return contentTypes.associateWith { media.copy() }
}

override fun <T : Any> getParseableContentTypes(type: KType): List<ContentType> {
return contentTypes!!.toList()
}

override suspend fun <T: Any> parseBody(clazz: KType, request: PipelineContext<Unit, ApplicationCall>): T {
override suspend fun <T : Any> parseBody(clazz: KType, request: PipelineContext<Unit, ApplicationCall>): T {
return request.call.receive(clazz)
}

override fun <T: Any> getSerializableContentTypes(type: KType): List<ContentType> {
override fun <T : Any> getSerializableContentTypes(type: KType): List<ContentType> {
return contentTypes!!.toList()
}

override suspend fun <T: Any> respond(response: T, request: PipelineContext<Unit, ApplicationCall>, contentType: ContentType) {
request.call.respond(response)
override suspend fun <T : Any> respond(
response: T,
request: PipelineContext<Unit, ApplicationCall>,
contentType: ContentType
) {
request.call.respond(response as Any)
}

override suspend fun <T: Any> respond(statusCode: HttpStatusCode, response: T, request: PipelineContext<Unit, ApplicationCall>, contentType: ContentType) {
request.call.respond(statusCode, response)
override suspend fun <T : Any> respond(
statusCode: HttpStatusCode,
response: T,
request: PipelineContext<Unit, ApplicationCall>,
contentType: ContentType
) {
request.call.respond(statusCode, response as Any)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.papsign.ktor.openapigen.model.schema

data class Discriminator<T>(val propertyName: String)
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package com.papsign.ktor.openapigen.model.schema
import com.papsign.ktor.openapigen.model.DataModel
import com.papsign.ktor.openapigen.model.base.RefModel

sealed class SchemaModel<T>: DataModel {
sealed class SchemaModel<T> : DataModel {

abstract var example: T?
abstract var examples: List<T>?
Expand All @@ -16,7 +16,8 @@ sealed class SchemaModel<T>: DataModel {
override var example: T? = null,
override var examples: List<T>? = null,
var type: DataType = DataType.`object`,
override var description: String? = null
override var description: String? = null,
var discriminator: Discriminator<T>? = null
) : SchemaModel<T>()

data class SchemaModelMap<T : Map<String, U>, U>(
Expand Down Expand Up @@ -69,7 +70,12 @@ sealed class SchemaModel<T>: DataModel {
override var description: String? = null
}

data class OneSchemaModelOf<T>(val oneOf: List<SchemaModel<out T>>) : SchemaModel<T>() {
data class OneSchemaModelOf<T>(
val oneOf: List<SchemaModel<out T>>,
var properties: Map<String, SchemaModel<*>>? = null,
val discriminator: Discriminator<T>? = null
) :
SchemaModel<T>() {
override var example: T? = null
override var examples: List<T>? = null
override var description: String? = null
Expand Down
38 changes: 22 additions & 16 deletions src/test/kotlin/JwtAuthDocumentationGenerationTest.kt
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
package origo.booking

import TestServerWithJwtAuth.testServerWithJwtAuth
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.handleRequest
import io.ktor.server.testing.withTestApplication
import io.ktor.http.*
import io.ktor.server.testing.*
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.Assert.*


internal class JwtAuthDocumentationGenerationTest {
Expand All @@ -17,17 +16,24 @@ internal class JwtAuthDocumentationGenerationTest {
}) {
with(handleRequest(HttpMethod.Get, "//openapi.json")) {
assertEquals(HttpStatusCode.OK, response.status())
assertTrue(response.content!!.contains("\"securitySchemes\" : {\n" +
" \"jwtAuth\" : {\n" +
" \"bearerFormat\" : \"JWT\",\n" +
" \"scheme\" : \"bearer\",\n" +
" \"type\" : \"http\"\n" +
" }\n" +
" }"))
assertTrue(response.content!!.contains("\"security\" : [ {\n" +
" \"jwtAuth\" : [ ]\n" +
" } ]"))
assertTrue(
response.content!!.contains(
"\"securitySchemes\" : {\n" +
" \"jwtAuth\" : {\n" +
" \"bearerFormat\" : \"JWT\",\n" +
" \"scheme\" : \"bearer\",\n" +
" \"type\" : \"http\"\n" +
" }\n" +
" }"
)
)
assertTrue(
response.content!!.contains(
"\"security\" : [ {\n" +
" \"jwtAuth\" : [ ]\n" +
" } ]"
)
)
}
}

}
114 changes: 114 additions & 0 deletions src/test/kotlin/OneOf.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import TestServer.Setup
import com.fasterxml.jackson.annotation.JsonTypeInfo
import com.fasterxml.jackson.annotation.JsonTypeName
import com.papsign.ktor.openapigen.annotations.type.`object`.example.ExampleProvider
import com.papsign.ktor.openapigen.annotations.type.`object`.example.WithExample
import com.papsign.ktor.openapigen.annotations.type.number.integer.clamp.Clamp
import com.papsign.ktor.openapigen.annotations.type.number.integer.max.Max
import com.papsign.ktor.openapigen.annotations.type.number.integer.min.Min
import com.papsign.ktor.openapigen.annotations.type.string.example.DiscriminatorAnnotation
import com.papsign.ktor.openapigen.route.apiRouting
import com.papsign.ktor.openapigen.route.info
import com.papsign.ktor.openapigen.route.path.normal.NormalOpenAPIRoute
import com.papsign.ktor.openapigen.route.path.normal.post
import com.papsign.ktor.openapigen.route.response.respond
import com.papsign.ktor.openapigen.route.route
import io.ktor.http.*
import io.ktor.server.testing.*
import org.junit.Assert
import org.junit.Test

fun NormalOpenAPIRoute.SealedRoute() {
route("sealed") {
post<Unit, Base, Base>(
info("Sealed class Endpoint", "This is a Sealed class Endpoint"),
exampleRequest = Base.A("Hi"),
exampleResponse = Base.A("Hi")
) { params, base ->
respond(base)
}
}
}


@JsonTypeInfo(use = JsonTypeInfo.Id.NAME)
@DiscriminatorAnnotation()
sealed class Base {
@JsonTypeName("A")
@DiscriminatorAnnotation()
class A(val str: String) : Base()

@JsonTypeName("B")
@DiscriminatorAnnotation()
class B(@Min(0) @Max(2) val i: Int) : Base()

@WithExample
@JsonTypeName("C")
@DiscriminatorAnnotation()
class C(@Clamp(0, 10) val l: Long) : Base() {
companion object : ExampleProvider<C> {
override val example: C = C(5)
}
}
}

val ref = "\$ref"

internal class OneOfLegacyGenerationTests {
@Test
fun willDiscriminatorsBePresent() = withTestApplication({
Setup()
apiRouting {
SealedRoute()
}
}) {
with(handleRequest(HttpMethod.Get, "//openapi.json")) {
Assert.assertEquals(HttpStatusCode.OK, response.status())
Assert.assertTrue(
response.content!!.contains(
""""Base" : {
"discriminator" : {
"propertyName" : "type"
},
"oneOf" : [ {
"$ref" : "#/components/schemas/A"
}, {
"$ref" : "#/components/schemas/B"
}, {
"$ref" : "#/components/schemas/C"
} ],
"properties" : {
"type" : {
"format" : "string",
"nullable" : false,
"type" : "string"
}
}"""
)
)
Assert.assertTrue(
response.content!!.contains(
""""A" : {
"discriminator" : {
"propertyName" : "type"
},
"nullable" : false,
"properties" : {
"str" : {
"nullable" : false,
"type" : "string"
},
"type" : {
"format" : "string",
"nullable" : false,
"type" : "string"
}
},
"required" : [ "str" ],
"type" : "object"
}"""
)
)
}
}
}
Loading