Skip to content

Commit f94a2f6

Browse files
authored
Merge pull request #90 from pixellos/feature/fix-discriminator-of-subtypes
Fix for OneOf Subtyping - Generators were failing to generate proper discriminators
2 parents 1a70a7d + ffe6658 commit f94a2f6

File tree

8 files changed

+478
-285
lines changed

8 files changed

+478
-285
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Currently Supported:
1717
- Exception handling (use `.throws(ex) {}` in the routes with an APIException object) with Status pages interop (with .withAPI in the StatusPages configuration)
1818
- tags (`.tag(tag) {}` in route with a tag object, currently must be an enum, but may be subject to change)
1919
- Spec compliant Parameter Parsing (see basic example)
20+
- Legacy Polymorphism with use of `@DiscriminatorAnnotation()` attribute and sealed classes
2021

2122
Extra Features:
2223
- Includes Swagger-UI (enabled by default, can be managed in the `install(OpenAPIGen) { ... }` section)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package com.papsign.ktor.openapigen.annotations.type.string.example
2+
3+
import com.papsign.ktor.openapigen.model.schema.DataFormat
4+
import com.papsign.ktor.openapigen.model.schema.DataType
5+
import com.papsign.ktor.openapigen.model.schema.Discriminator
6+
import com.papsign.ktor.openapigen.model.schema.SchemaModel
7+
import com.papsign.ktor.openapigen.schema.processor.SchemaProcessor
8+
import com.papsign.ktor.openapigen.schema.processor.SchemaProcessorAnnotation
9+
import kotlin.reflect.KType
10+
11+
@Target(AnnotationTarget.CLASS)
12+
@SchemaProcessorAnnotation(LegacyDiscriminatorProcessor::class)
13+
annotation class DiscriminatorAnnotation(val fieldName: String = "type")
14+
15+
// Difference between legacy mode and current
16+
// https://github.com/OpenAPITools/openapi-generator/blob/master/docs/generators/typescript.md
17+
// For non-legacy mapping is from sub-types to base-type by allOf
18+
// This implementation follow previous implementation, so we need to have
19+
// - discriminatorName in each type Parameters array
20+
// - { discriminator: { propertyName: discriminatorName } } in each type
21+
object LegacyDiscriminatorProcessor : SchemaProcessor<DiscriminatorAnnotation> {
22+
override fun process(model: SchemaModel<*>, type: KType, annotation: DiscriminatorAnnotation): SchemaModel<*> {
23+
val mapElement = (annotation.fieldName to SchemaModel.SchemaModelLitteral<String>(
24+
DataType.string,
25+
DataFormat.string,
26+
false
27+
))
28+
29+
if (model is SchemaModel.OneSchemaModelOf<*>) {
30+
return SchemaModel.OneSchemaModelOf(
31+
model.oneOf,
32+
mapOf(mapElement),
33+
Discriminator(annotation.fieldName)
34+
)
35+
}
36+
37+
if (model is SchemaModel.SchemaModelObj<*>) {
38+
39+
return SchemaModel.SchemaModelObj(
40+
model.properties + mapElement,
41+
model.required,
42+
model.nullable,
43+
model.example,
44+
model.examples,
45+
model.type,
46+
model.description,
47+
Discriminator(annotation.fieldName)
48+
)
49+
}
50+
51+
return model
52+
}
53+
54+
}
Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package com.papsign.ktor.openapigen.content.type.ktor
22

3-
import com.papsign.ktor.openapigen.unitKType
43
import com.papsign.ktor.openapigen.OpenAPIGen
54
import com.papsign.ktor.openapigen.OpenAPIGenModuleExtension
65
import com.papsign.ktor.openapigen.annotations.encodings.APIRequestFormat
@@ -13,15 +12,13 @@ import com.papsign.ktor.openapigen.model.schema.SchemaModel
1312
import com.papsign.ktor.openapigen.modules.ModuleProvider
1413
import com.papsign.ktor.openapigen.modules.ofType
1514
import com.papsign.ktor.openapigen.schema.builder.provider.FinalSchemaBuilderProviderModule
16-
import io.ktor.application.ApplicationCall
17-
import io.ktor.application.call
18-
import io.ktor.application.featureOrNull
19-
import io.ktor.features.ContentNegotiation
20-
import io.ktor.http.ContentType
21-
import io.ktor.http.HttpStatusCode
22-
import io.ktor.request.receive
23-
import io.ktor.response.respond
24-
import io.ktor.util.pipeline.PipelineContext
15+
import com.papsign.ktor.openapigen.unitKType
16+
import io.ktor.application.*
17+
import io.ktor.features.*
18+
import io.ktor.http.*
19+
import io.ktor.request.*
20+
import io.ktor.response.*
21+
import io.ktor.util.pipeline.*
2522
import kotlin.reflect.KType
2623
import kotlin.reflect.full.findAnnotation
2724
import kotlin.reflect.jvm.jvmErasure
@@ -40,45 +37,65 @@ object KtorContentProvider : ContentTypeProvider, BodyParser, ResponseSerializer
4037
return contentTypes
4138
}
4239

43-
override fun <T> getMediaType(type: KType, apiGen: OpenAPIGen, provider: ModuleProvider<*>, example: T?, usage: ContentTypeProvider.Usage):Map<ContentType, MediaTypeModel<T>>? {
40+
override fun <T> getMediaType(
41+
type: KType,
42+
apiGen: OpenAPIGen,
43+
provider: ModuleProvider<*>,
44+
example: T?,
45+
usage: ContentTypeProvider.Usage
46+
): Map<ContentType, MediaTypeModel<T>>? {
4447
if (type == unitKType) return null
4548
val clazz = type.jvmErasure
4649
when (usage) { // check if it is explicitly declared or none is present
4750
ContentTypeProvider.Usage.PARSE -> when {
48-
clazz.findAnnotation<KtorRequest>() != null -> {}
49-
clazz.annotations.none { it.annotationClass.findAnnotation<APIRequestFormat>() != null } -> {}
51+
clazz.findAnnotation<KtorRequest>() != null -> {
52+
}
53+
clazz.annotations.none { it.annotationClass.findAnnotation<APIRequestFormat>() != null } -> {
54+
}
5055
else -> return null
5156
}
5257
ContentTypeProvider.Usage.SERIALIZE -> when {
53-
clazz.findAnnotation<KtorResponse>() != null -> {}
54-
clazz.annotations.none { it.annotationClass.findAnnotation<APIResponseFormat>() != null } -> {}
58+
clazz.findAnnotation<KtorResponse>() != null -> {
59+
}
60+
clazz.annotations.none { it.annotationClass.findAnnotation<APIResponseFormat>() != null } -> {
61+
}
5562
else -> return null
5663
}
5764
}
5865
val contentTypes = initContentTypes(apiGen) ?: return null
5966
val schemaBuilder = provider.ofType<FinalSchemaBuilderProviderModule>().last().provide(apiGen, provider)
67+
6068
@Suppress("UNCHECKED_CAST")
61-
val media = MediaTypeModel(schemaBuilder.build(type) as SchemaModel<T>, example)
69+
val media = MediaTypeModel(schemaBuilder.build(type) as SchemaModel<T>, example)
6270
return contentTypes.associateWith { media.copy() }
6371
}
6472

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

69-
override suspend fun <T: Any> parseBody(clazz: KType, request: PipelineContext<Unit, ApplicationCall>): T {
77+
override suspend fun <T : Any> parseBody(clazz: KType, request: PipelineContext<Unit, ApplicationCall>): T {
7078
return request.call.receive(clazz)
7179
}
7280

73-
override fun <T: Any> getSerializableContentTypes(type: KType): List<ContentType> {
81+
override fun <T : Any> getSerializableContentTypes(type: KType): List<ContentType> {
7482
return contentTypes!!.toList()
7583
}
7684

77-
override suspend fun <T: Any> respond(response: T, request: PipelineContext<Unit, ApplicationCall>, contentType: ContentType) {
78-
request.call.respond(response)
85+
override suspend fun <T : Any> respond(
86+
response: T,
87+
request: PipelineContext<Unit, ApplicationCall>,
88+
contentType: ContentType
89+
) {
90+
request.call.respond(response as Any)
7991
}
8092

81-
override suspend fun <T: Any> respond(statusCode: HttpStatusCode, response: T, request: PipelineContext<Unit, ApplicationCall>, contentType: ContentType) {
82-
request.call.respond(statusCode, response)
93+
override suspend fun <T : Any> respond(
94+
statusCode: HttpStatusCode,
95+
response: T,
96+
request: PipelineContext<Unit, ApplicationCall>,
97+
contentType: ContentType
98+
) {
99+
request.call.respond(statusCode, response as Any)
83100
}
84101
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package com.papsign.ktor.openapigen.model.schema
2+
3+
data class Discriminator<T>(val propertyName: String)

src/main/kotlin/com/papsign/ktor/openapigen/model/schema/SchemaModel.kt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package com.papsign.ktor.openapigen.model.schema
33
import com.papsign.ktor.openapigen.model.DataModel
44
import com.papsign.ktor.openapigen.model.base.RefModel
55

6-
sealed class SchemaModel<T>: DataModel {
6+
sealed class SchemaModel<T> : DataModel {
77

88
abstract var example: T?
99
abstract var examples: List<T>?
@@ -16,7 +16,8 @@ sealed class SchemaModel<T>: DataModel {
1616
override var example: T? = null,
1717
override var examples: List<T>? = null,
1818
var type: DataType = DataType.`object`,
19-
override var description: String? = null
19+
override var description: String? = null,
20+
var discriminator: Discriminator<T>? = null
2021
) : SchemaModel<T>()
2122

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

72-
data class OneSchemaModelOf<T>(val oneOf: List<SchemaModel<out T>>) : SchemaModel<T>() {
73+
data class OneSchemaModelOf<T>(
74+
val oneOf: List<SchemaModel<out T>>,
75+
var properties: Map<String, SchemaModel<*>>? = null,
76+
val discriminator: Discriminator<T>? = null
77+
) :
78+
SchemaModel<T>() {
7379
override var example: T? = null
7480
override var examples: List<T>? = null
7581
override var description: String? = null
Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
package origo.booking
22

33
import TestServerWithJwtAuth.testServerWithJwtAuth
4-
import io.ktor.http.HttpMethod
5-
import io.ktor.http.HttpStatusCode
6-
import io.ktor.server.testing.handleRequest
7-
import io.ktor.server.testing.withTestApplication
4+
import io.ktor.http.*
5+
import io.ktor.server.testing.*
6+
import org.junit.Assert.assertEquals
7+
import org.junit.Assert.assertTrue
88
import org.junit.Test
9-
import org.junit.Assert.*
109

1110

1211
internal class JwtAuthDocumentationGenerationTest {
@@ -17,17 +16,24 @@ internal class JwtAuthDocumentationGenerationTest {
1716
}) {
1817
with(handleRequest(HttpMethod.Get, "//openapi.json")) {
1918
assertEquals(HttpStatusCode.OK, response.status())
20-
assertTrue(response.content!!.contains("\"securitySchemes\" : {\n" +
21-
" \"jwtAuth\" : {\n" +
22-
" \"bearerFormat\" : \"JWT\",\n" +
23-
" \"scheme\" : \"bearer\",\n" +
24-
" \"type\" : \"http\"\n" +
25-
" }\n" +
26-
" }"))
27-
assertTrue(response.content!!.contains("\"security\" : [ {\n" +
28-
" \"jwtAuth\" : [ ]\n" +
29-
" } ]"))
19+
assertTrue(
20+
response.content!!.contains(
21+
"\"securitySchemes\" : {\n" +
22+
" \"jwtAuth\" : {\n" +
23+
" \"bearerFormat\" : \"JWT\",\n" +
24+
" \"scheme\" : \"bearer\",\n" +
25+
" \"type\" : \"http\"\n" +
26+
" }\n" +
27+
" }"
28+
)
29+
)
30+
assertTrue(
31+
response.content!!.contains(
32+
"\"security\" : [ {\n" +
33+
" \"jwtAuth\" : [ ]\n" +
34+
" } ]"
35+
)
36+
)
3037
}
3138
}
32-
3339
}

src/test/kotlin/OneOf.kt

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import TestServer.Setup
2+
import com.fasterxml.jackson.annotation.JsonTypeInfo
3+
import com.fasterxml.jackson.annotation.JsonTypeName
4+
import com.papsign.ktor.openapigen.annotations.type.`object`.example.ExampleProvider
5+
import com.papsign.ktor.openapigen.annotations.type.`object`.example.WithExample
6+
import com.papsign.ktor.openapigen.annotations.type.number.integer.clamp.Clamp
7+
import com.papsign.ktor.openapigen.annotations.type.number.integer.max.Max
8+
import com.papsign.ktor.openapigen.annotations.type.number.integer.min.Min
9+
import com.papsign.ktor.openapigen.annotations.type.string.example.DiscriminatorAnnotation
10+
import com.papsign.ktor.openapigen.route.apiRouting
11+
import com.papsign.ktor.openapigen.route.info
12+
import com.papsign.ktor.openapigen.route.path.normal.NormalOpenAPIRoute
13+
import com.papsign.ktor.openapigen.route.path.normal.post
14+
import com.papsign.ktor.openapigen.route.response.respond
15+
import com.papsign.ktor.openapigen.route.route
16+
import io.ktor.http.*
17+
import io.ktor.server.testing.*
18+
import org.junit.Assert
19+
import org.junit.Test
20+
21+
fun NormalOpenAPIRoute.SealedRoute() {
22+
route("sealed") {
23+
post<Unit, Base, Base>(
24+
info("Sealed class Endpoint", "This is a Sealed class Endpoint"),
25+
exampleRequest = Base.A("Hi"),
26+
exampleResponse = Base.A("Hi")
27+
) { params, base ->
28+
respond(base)
29+
}
30+
}
31+
}
32+
33+
34+
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME)
35+
@DiscriminatorAnnotation()
36+
sealed class Base {
37+
@JsonTypeName("A")
38+
@DiscriminatorAnnotation()
39+
class A(val str: String) : Base()
40+
41+
@JsonTypeName("B")
42+
@DiscriminatorAnnotation()
43+
class B(@Min(0) @Max(2) val i: Int) : Base()
44+
45+
@WithExample
46+
@JsonTypeName("C")
47+
@DiscriminatorAnnotation()
48+
class C(@Clamp(0, 10) val l: Long) : Base() {
49+
companion object : ExampleProvider<C> {
50+
override val example: C = C(5)
51+
}
52+
}
53+
}
54+
55+
val ref = "\$ref"
56+
57+
internal class OneOfLegacyGenerationTests {
58+
@Test
59+
fun willDiscriminatorsBePresent() = withTestApplication({
60+
Setup()
61+
apiRouting {
62+
SealedRoute()
63+
}
64+
}) {
65+
with(handleRequest(HttpMethod.Get, "//openapi.json")) {
66+
Assert.assertEquals(HttpStatusCode.OK, response.status())
67+
Assert.assertTrue(
68+
response.content!!.contains(
69+
""""Base" : {
70+
"discriminator" : {
71+
"propertyName" : "type"
72+
},
73+
"oneOf" : [ {
74+
"$ref" : "#/components/schemas/A"
75+
}, {
76+
"$ref" : "#/components/schemas/B"
77+
}, {
78+
"$ref" : "#/components/schemas/C"
79+
} ],
80+
"properties" : {
81+
"type" : {
82+
"format" : "string",
83+
"nullable" : false,
84+
"type" : "string"
85+
}
86+
}"""
87+
)
88+
)
89+
Assert.assertTrue(
90+
response.content!!.contains(
91+
""""A" : {
92+
"discriminator" : {
93+
"propertyName" : "type"
94+
},
95+
"nullable" : false,
96+
"properties" : {
97+
"str" : {
98+
"nullable" : false,
99+
"type" : "string"
100+
},
101+
"type" : {
102+
"format" : "string",
103+
"nullable" : false,
104+
"type" : "string"
105+
}
106+
},
107+
"required" : [ "str" ],
108+
"type" : "object"
109+
}"""
110+
)
111+
)
112+
}
113+
}
114+
}

0 commit comments

Comments
 (0)