diff --git a/README.md b/README.md index 7cba98e47..2bb81a80d 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/src/main/kotlin/com/papsign/ktor/openapigen/annotations/type/string/discriminator/LegacyDiscriminatorProcessor.kt b/src/main/kotlin/com/papsign/ktor/openapigen/annotations/type/string/discriminator/LegacyDiscriminatorProcessor.kt new file mode 100644 index 000000000..efff65986 --- /dev/null +++ b/src/main/kotlin/com/papsign/ktor/openapigen/annotations/type/string/discriminator/LegacyDiscriminatorProcessor.kt @@ -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 { + override fun process(model: SchemaModel<*>, type: KType, annotation: DiscriminatorAnnotation): SchemaModel<*> { + val mapElement = (annotation.fieldName to SchemaModel.SchemaModelLitteral( + 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 + } + +} diff --git a/src/main/kotlin/com/papsign/ktor/openapigen/content/type/ktor/KtorContentProvider.kt b/src/main/kotlin/com/papsign/ktor/openapigen/content/type/ktor/KtorContentProvider.kt index 787065ea9..83ddc9885 100644 --- a/src/main/kotlin/com/papsign/ktor/openapigen/content/type/ktor/KtorContentProvider.kt +++ b/src/main/kotlin/com/papsign/ktor/openapigen/content/type/ktor/KtorContentProvider.kt @@ -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 @@ -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 @@ -40,25 +37,36 @@ object KtorContentProvider : ContentTypeProvider, BodyParser, ResponseSerializer return contentTypes } - override fun getMediaType(type: KType, apiGen: OpenAPIGen, provider: ModuleProvider<*>, example: T?, usage: ContentTypeProvider.Usage):Map>? { + override fun getMediaType( + type: KType, + apiGen: OpenAPIGen, + provider: ModuleProvider<*>, + example: T?, + usage: ContentTypeProvider.Usage + ): Map>? { 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() != null -> {} - clazz.annotations.none { it.annotationClass.findAnnotation() != null } -> {} + clazz.findAnnotation() != null -> { + } + clazz.annotations.none { it.annotationClass.findAnnotation() != null } -> { + } else -> return null } ContentTypeProvider.Usage.SERIALIZE -> when { - clazz.findAnnotation() != null -> {} - clazz.annotations.none { it.annotationClass.findAnnotation() != null } -> {} + clazz.findAnnotation() != null -> { + } + clazz.annotations.none { it.annotationClass.findAnnotation() != null } -> { + } else -> return null } } val contentTypes = initContentTypes(apiGen) ?: return null val schemaBuilder = provider.ofType().last().provide(apiGen, provider) + @Suppress("UNCHECKED_CAST") - val media = MediaTypeModel(schemaBuilder.build(type) as SchemaModel, example) + val media = MediaTypeModel(schemaBuilder.build(type) as SchemaModel, example) return contentTypes.associateWith { media.copy() } } @@ -66,19 +74,28 @@ object KtorContentProvider : ContentTypeProvider, BodyParser, ResponseSerializer return contentTypes!!.toList() } - override suspend fun parseBody(clazz: KType, request: PipelineContext): T { + override suspend fun parseBody(clazz: KType, request: PipelineContext): T { return request.call.receive(clazz) } - override fun getSerializableContentTypes(type: KType): List { + override fun getSerializableContentTypes(type: KType): List { return contentTypes!!.toList() } - override suspend fun respond(response: T, request: PipelineContext, contentType: ContentType) { - request.call.respond(response) + override suspend fun respond( + response: T, + request: PipelineContext, + contentType: ContentType + ) { + request.call.respond(response as Any) } - override suspend fun respond(statusCode: HttpStatusCode, response: T, request: PipelineContext, contentType: ContentType) { - request.call.respond(statusCode, response) + override suspend fun respond( + statusCode: HttpStatusCode, + response: T, + request: PipelineContext, + contentType: ContentType + ) { + request.call.respond(statusCode, response as Any) } } diff --git a/src/main/kotlin/com/papsign/ktor/openapigen/model/schema/Discriminator.kt b/src/main/kotlin/com/papsign/ktor/openapigen/model/schema/Discriminator.kt new file mode 100644 index 000000000..3193b677e --- /dev/null +++ b/src/main/kotlin/com/papsign/ktor/openapigen/model/schema/Discriminator.kt @@ -0,0 +1,3 @@ +package com.papsign.ktor.openapigen.model.schema + +data class Discriminator(val propertyName: String) \ No newline at end of file diff --git a/src/main/kotlin/com/papsign/ktor/openapigen/model/schema/SchemaModel.kt b/src/main/kotlin/com/papsign/ktor/openapigen/model/schema/SchemaModel.kt index 05f743b24..0d72b2e50 100644 --- a/src/main/kotlin/com/papsign/ktor/openapigen/model/schema/SchemaModel.kt +++ b/src/main/kotlin/com/papsign/ktor/openapigen/model/schema/SchemaModel.kt @@ -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: DataModel { +sealed class SchemaModel : DataModel { abstract var example: T? abstract var examples: List? @@ -16,7 +16,8 @@ sealed class SchemaModel: DataModel { override var example: T? = null, override var examples: List? = null, var type: DataType = DataType.`object`, - override var description: String? = null + override var description: String? = null, + var discriminator: Discriminator? = null ) : SchemaModel() data class SchemaModelMap, U>( @@ -69,7 +70,12 @@ sealed class SchemaModel: DataModel { override var description: String? = null } - data class OneSchemaModelOf(val oneOf: List>) : SchemaModel() { + data class OneSchemaModelOf( + val oneOf: List>, + var properties: Map>? = null, + val discriminator: Discriminator? = null + ) : + SchemaModel() { override var example: T? = null override var examples: List? = null override var description: String? = null diff --git a/src/test/kotlin/JwtAuthDocumentationGenerationTest.kt b/src/test/kotlin/JwtAuthDocumentationGenerationTest.kt index 97fda9108..619933d04 100644 --- a/src/test/kotlin/JwtAuthDocumentationGenerationTest.kt +++ b/src/test/kotlin/JwtAuthDocumentationGenerationTest.kt @@ -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 { @@ -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" + + " } ]" + ) + ) } } - } \ No newline at end of file diff --git a/src/test/kotlin/OneOf.kt b/src/test/kotlin/OneOf.kt new file mode 100644 index 000000000..ee3062b86 --- /dev/null +++ b/src/test/kotlin/OneOf.kt @@ -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( + 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 { + 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" + }""" + ) + ) + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/TestServer.kt b/src/test/kotlin/TestServer.kt index 703580676..4c400c6ee 100644 --- a/src/test/kotlin/TestServer.kt +++ b/src/test/kotlin/TestServer.kt @@ -1,6 +1,4 @@ import com.fasterxml.jackson.annotation.JsonInclude -import com.fasterxml.jackson.annotation.JsonSubTypes -import com.fasterxml.jackson.annotation.JsonTypeInfo import com.fasterxml.jackson.core.util.DefaultIndenter import com.fasterxml.jackson.core.util.DefaultPrettyPrinter import com.fasterxml.jackson.databind.DeserializationFeature @@ -17,13 +15,10 @@ import com.papsign.ktor.openapigen.annotations.parameters.HeaderParam import com.papsign.ktor.openapigen.annotations.parameters.PathParam import com.papsign.ktor.openapigen.annotations.parameters.QueryParam import com.papsign.ktor.openapigen.annotations.properties.description.Description -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.common.ConstraintViolation import com.papsign.ktor.openapigen.annotations.type.number.floating.clamp.FClamp import com.papsign.ktor.openapigen.annotations.type.number.floating.max.FMax 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.StringExample import com.papsign.ktor.openapigen.annotations.type.string.length.Length @@ -50,7 +45,6 @@ import io.ktor.routing.* import io.ktor.server.engine.* import io.ktor.server.netty.* import java.time.* -import java.util.* import kotlin.reflect.KType object TestServer { @@ -59,268 +53,284 @@ object TestServer { class ProperException(msg: String, val id: String = "proper.exception") : Exception(msg) - @JvmStatic - fun main(args: Array) { - embeddedServer(Netty, 8080, "localhost") { - //define basic OpenAPI info - val api = install(OpenAPIGen) { - info { - version = "0.1" - title = "Test API" - description = "The Test API" - contact { - name = "Support" - email = "support@test.com" - } - } - server("https://api.test.com/") { - description = "Main production server" - } - replaceModule(DefaultSchemaNamer, object: SchemaNamer { - val regex = Regex("[A-Za-z0-9_.]+") - override fun get(type: KType): String { - return type.toString().replace(regex) { it.value.split(".").last() }.replace(Regex(">|<|, "), "_") - } - }) - } + fun Application.testServer() { + Setup() - install(ContentNegotiation) { - jackson { - enable( - DeserializationFeature.WRAP_EXCEPTIONS, - DeserializationFeature.USE_BIG_INTEGER_FOR_INTS, - DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS - ) + apiRouting { - enable(SerializationFeature.WRAP_EXCEPTIONS, SerializationFeature.INDENT_OUTPUT) - - disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) - disable(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE) - - setSerializationInclusion(JsonInclude.Include.NON_NULL) - - setDefaultPrettyPrinter(DefaultPrettyPrinter().apply { - indentArraysWith(DefaultPrettyPrinter.FixedSpaceIndenter.instance) - indentObjectsWith(DefaultIndenter(" ", "\n")) - }) + get( + info("String Param Endpoint", "This is a String Param Endpoint"), + example = StringResponse("Hi") + ) { params -> + respond(StringResponse(params.a)) + } - registerModule(JavaTimeModule()) + route("header") { + get( + info("Header Param Endpoint", "This is a Header Param Endpoint"), + example = NameGreetingResponse("Hi, openapi!") + ) { params -> + respond(NameGreetingResponse("Hi, ${params.name}!")) } } - // StatusPage interop, can also define exceptions per-route - install(StatusPages) { - withAPI(api) { - exception(HttpStatusCode.BadRequest) { - it.printStackTrace() - Error("mapping.json", it.localizedMessage) - } - exception(HttpStatusCode.BadRequest) { - Error("violation.constraint", it.localizedMessage) - } - exception(HttpStatusCode.BadRequest) { - it.printStackTrace() - Error(it.id, it.localizedMessage) - } + route("list") { + get>( + info("String Param Endpoint", "This is a String Param Endpoint"), + example = listOf(StringResponse("Hi")) + ) { params -> + respond(listOf(StringResponse(params.a))) } } + SealedRoute() - val scopes = Scopes.values().asList() - - // serve OpenAPI and redirect from root - routing { - get("/openapi.json") { - val host = ServerModel( - call.request.origin.scheme + "://" + call.request.host() + if (setOf( - 80, - 443 - ).contains(call.request.port()) - ) "" else ":${call.request.port()}" - ) - application.openAPIGen.api.servers.add(0, host) - call.respond(application.openAPIGen.api.serialize()) - application.openAPIGen.api.servers.remove(host) - } + route("long").get( + info("Long Param Endpoint", "This is a String Param Endpoint"), + example = LongResponse(Long.MAX_VALUE) + ) { params -> + respond(LongResponse(params.a)) + } - get("/") { - call.respondRedirect("/swagger-ui/index.html?url=/openapi.json", true) - } + route("validate-string").post( + info( + "This endpoint demonstrates the usage of String validators", + "This endpoint demonstrates the usage of String validators" + ), + exampleRequest = StringValidatorsExample( + "A string that is at least 2 characters long", + "A short string", + "Between 2 and 20", + "5a21be2" + ), + exampleResponse = StringResponse("All of the fields were valid") + ) { params, body -> + respond(StringResponse("All of the fields were valid")) } - apiRouting { + route("validate-number").post( + info( + "This endpoint demonstrates the usage of number validators", + "This endpoint demonstrates the usage of number validators" + ), + exampleRequest = NumberValidatorsExample( + 1, + 56, + 15.02f, + 0.023f + ), + exampleResponse = StringResponse("All of the fields were valid") + ) { params, body -> + respond(StringResponse("All of the fields were valid")) + } - get( - info("String Param Endpoint", "This is a String Param Endpoint"), - example = StringResponse("Hi") - ) { params -> - respond(StringResponse(params.a)) - } + route("status/codes") { + route("201").status(201) { + // all endpoints in this block respond a 201 status code unless specified otherwise - route("header") { - get( - info("Header Param Endpoint", "This is a Header Param Endpoint"), - example = NameGreetingResponse("Hi, openapi!") + get( + info( + "201 String Param Endpoint", + "This is a String Param Endpoint that has a 201 status code" + ), + example = StringResponse("Hi") ) { params -> - respond(NameGreetingResponse("Hi, ${params.name}!")) + respond(StringResponse(params.a)) } - } - route("list") { - get>( - info("String Param Endpoint", "This is a String Param Endpoint"), - example = listOf(StringResponse("Hi")) + route("reset").get( + info( + "String Param Endpoint with @response based status code", + "This is a String Param Endpoint that resets the status code back to the one provided by @Response" + ), + responseAnnotationStatus(), + example = StringResponse("Hi") ) { params -> - respond(listOf(StringResponse(params.a))) - } - } - - route("sealed") { - post( - info("Sealed class Endpoint", "This is a Sealed class Endpoint"), - exampleRequest = Base.A("Hi"), - exampleResponse = Base.A("Hi") - ) { params, base -> - respond(base) + respond(StringResponse(params.a)) } } - route("long").get( - info("Long Param Endpoint", "This is a String Param Endpoint"), - example = LongResponse(Long.MAX_VALUE) + route("202").get( + info( + "String Param Endpoint with inline 202 response", + "This is a String Param Endpoint that has a 202 response code" + ), + status(HttpStatusCode.Accepted), + example = StringResponse("Hi") ) { params -> - respond(LongResponse(params.a)) - } - - route("validate-string").post( - info("This endpoint demonstrates the usage of String validators", "This endpoint demonstrates the usage of String validators"), - exampleRequest = StringValidatorsExample( - "A string that is at least 2 characters long", - "A short string", - "Between 2 and 20", - "5a21be2"), - exampleResponse = StringResponse("All of the fields were valid") - ) { params, body -> - respond(StringResponse("All of the fields were valid")) - } - - route("validate-number").post( - info("This endpoint demonstrates the usage of number validators", "This endpoint demonstrates the usage of number validators"), - exampleRequest = NumberValidatorsExample( - 1, - 56, - 15.02f, - 0.023f), - exampleResponse = StringResponse("All of the fields were valid") - ) { params, body -> - respond(StringResponse("All of the fields were valid")) + respond(StringResponse(params.a)) } + } - route("status/codes") { - route("201").status(201) { - // all endpoints in this block respond a 201 status code unless specified otherwise + route("again") { + tag(Tags.EXAMPLE) { + route("exception").throws(HttpStatusCode.ExpectationFailed, "example", CustomException::class) { get( - info("201 String Param Endpoint", "This is a String Param Endpoint that has a 201 status code"), - example = StringResponse("Hi") - ) { params -> - respond(StringResponse(params.a)) - } - - route("reset").get( - info("String Param Endpoint with @response based status code", "This is a String Param Endpoint that resets the status code back to the one provided by @Response"), - responseAnnotationStatus(), + info("String Param Endpoint", "This is a String Param Endpoint"), example = StringResponse("Hi") ) { params -> - respond(StringResponse(params.a)) + throw CustomException() } } - route("202").get( - info("String Param Endpoint with inline 202 response", "This is a String Param Endpoint that has a 202 response code"), - status(HttpStatusCode.Accepted), + get( + info("String Param Endpoint", "This is a String Param Endpoint"), example = StringResponse("Hi") ) { params -> respond(StringResponse(params.a)) } - } - - route("again") { - tag(TestServer.Tags.EXAMPLE) { - - route("exception").throws(HttpStatusCode.ExpectationFailed, "example", CustomException::class) { - get( - info("String Param Endpoint", "This is a String Param Endpoint"), - example = StringResponse("Hi") - ) { params -> - throw CustomException() - } - } - get( - info("String Param Endpoint", "This is a String Param Endpoint"), - example = StringResponse("Hi") - ) { params -> - respond(StringResponse(params.a)) - } - - route("long").get( - info("Long Param Endpoint", "This is a String Param Endpoint"), - example = LongResponse(Long.MAX_VALUE) - ) { params -> - respond(LongResponse(params.a)) - } + route("long").get( + info("Long Param Endpoint", "This is a String Param Endpoint"), + example = LongResponse(Long.MAX_VALUE) + ) { params -> + respond(LongResponse(params.a)) } } + } - route("datetime") { - route("date") { - get { params -> + route("datetime") { + route("date") { + get { params -> + respond(LocalDateResponse(params.date)) + } + route("optional") { + get { params -> + println(params) respond(LocalDateResponse(params.date)) } - route("optional") { - get { params -> - println(params) - respond(LocalDateResponse(params.date)) - } - } } - route("local-time") { - get { params -> - respond(LocalTimeResponse(params.time)) - } + } + route("local-time") { + get { params -> + respond(LocalTimeResponse(params.time)) } - route("offset-time") { - get { params -> - respond(OffsetTimeResponse(params.time)) - } + } + route("offset-time") { + get { params -> + respond(OffsetTimeResponse(params.time)) } + } - route("local-date-time") { - get { params -> - respond(LocalDateTimeResponse(params.date)) - } + route("local-date-time") { + get { params -> + respond(LocalDateTimeResponse(params.date)) } - route("offset-date-time") { - get { params -> - respond(OffsetDateTimeResponse(params.date)) - } + } + route("offset-date-time") { + get { params -> + respond(OffsetDateTimeResponse(params.date)) } - route("zoned-date-time") { - get { params -> - println(ZonedDateTime.now()) - respond(ZonedDateTimeResponse(params.date)) - } + } + route("zoned-date-time") { + get { params -> + println(ZonedDateTime.now()) + respond(ZonedDateTimeResponse(params.date)) } - route("instant") { - get { params -> - respond(InstantResponse(params.date)) - } + } + route("instant") { + get { params -> + respond(InstantResponse(params.date)) } } } - }.start(true) + } + } + + + fun Application.Setup() { + //define basic OpenAPI info + val api = install(OpenAPIGen) { + info { + version = "0.1" + title = "Test API" + description = "The Test API" + contact { + name = "Support" + email = "support@test.com" + } + } + server("https://api.test.com/") { + description = "Main production server" + } + replaceModule(DefaultSchemaNamer, object : SchemaNamer { + val regex = Regex("[A-Za-z0-9_.]+") + override fun get(type: KType): String { + return type.toString().replace(regex) { it.value.split(".").last() } + .replace(Regex(">|<|, "), "_") + } + }) + } + + install(ContentNegotiation) { + jackson { + enable( + DeserializationFeature.WRAP_EXCEPTIONS, + DeserializationFeature.USE_BIG_INTEGER_FOR_INTS, + DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS + ) + + enable(SerializationFeature.WRAP_EXCEPTIONS, SerializationFeature.INDENT_OUTPUT) + + disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + disable(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE) + + setSerializationInclusion(JsonInclude.Include.NON_NULL) + + setDefaultPrettyPrinter(DefaultPrettyPrinter().apply { + indentArraysWith(DefaultPrettyPrinter.FixedSpaceIndenter.instance) + indentObjectsWith(DefaultIndenter(" ", "\n")) + }) + + registerModule(JavaTimeModule()) + } + } + + // StatusPage interop, can also define exceptions per-route + install(StatusPages) { + withAPI(api) { + exception(HttpStatusCode.BadRequest) { + it.printStackTrace() + Error("mapping.json", it.localizedMessage) + } + exception(HttpStatusCode.BadRequest) { + Error("violation.constraint", it.localizedMessage) + } + exception(HttpStatusCode.BadRequest) { + it.printStackTrace() + Error(it.id, it.localizedMessage) + } + } + } + + val scopes = Scopes.values().asList() + + // serve OpenAPI and redirect from root + routing { + get("/openapi.json") { + val host = ServerModel( + call.request.origin.scheme + "://" + call.request.host() + if (setOf( + 80, + 443 + ).contains(call.request.port()) + ) "" else ":${call.request.port()}" + ) + application.openAPIGen.api.servers.add(0, host) + call.respond(application.openAPIGen.api.serialize()) + application.openAPIGen.api.servers.remove(host) + } + + get("/") { + call.respondRedirect("/swagger-ui/index.html?url=/openapi.json", true) + } + } + } + + @JvmStatic + fun main(args: Array) { + embeddedServer(Netty, 8080, "localhost") { testServer() }.start(true) } class CustomException : Exception() @@ -338,18 +348,21 @@ object TestServer { @Request("A Request with String fields validated for length or pattern") data class StringValidatorsExample( - @MinLength(2,"Optional custom error message") val strWithMinLength: String, - @MaxLength( 20 ) val strWithMaxLength: String, - @Length(2, 20 ) val strWithLength: String, - @RegularExpression("^[0-9a-fA-F]*$", "The field strHexaDec should only contain hexadecimal digits") val strHexaDec: String + @MinLength(2, "Optional custom error message") val strWithMinLength: String, + @MaxLength(20) val strWithMaxLength: String, + @Length(2, 20) val strWithLength: String, + @RegularExpression( + "^[0-9a-fA-F]*$", + "The field strHexaDec should only contain hexadecimal digits" + ) val strHexaDec: String ) @Request("A Request with validated number fields") data class NumberValidatorsExample( - @Min(0, "The value of field intWithMin should be a positive integer") val intWithMin: Int, - @Clamp( 1, 90 ) val intBetween: Int, - @FMax(100.0) val floatMax: Float, - @FClamp(0.0, 1.0, "The value of field floatBetween should be a between 0 and 1") val floatBetween: Float + @Min(0, "The value of field intWithMin should be a positive integer") val intWithMin: Int, + @Clamp(1, 90) val intBetween: Int, + @FMax(100.0) val floatMax: Float, + @FClamp(0.0, 1.0, "The value of field floatBetween should be a between 0 and 1") val floatBetween: Float ) @Response("A String Response") @@ -362,27 +375,6 @@ object TestServer { @Response("A Long Response") data class LongResponse(val str: Long) - @JsonTypeInfo(use = JsonTypeInfo.Id.NAME) - @JsonSubTypes( - JsonSubTypes.Type(Base.A::class, name = "a"), - JsonSubTypes.Type(Base.B::class, name = "b"), - JsonSubTypes.Type(Base.C::class, name = "c") - ) - sealed class Base { - - class A(val str: String) : Base() - - class B(@Min(0) @Max(2) val i: Int) : Base() - - @WithExample - class C( @Clamp(0, 10) val l: Long) : Base() { - companion object: ExampleProvider { - override val example: C? = C(5) - } - } - } - - enum class Tags(override val description: String) : APITag { EXAMPLE("Wow this is a tag?!") }