feat: Added SwaggerUI KTor Plugin (#215)

This commit is contained in:
Jevgeni Goloborodko
2022-03-01 19:47:51 +02:00
committed by GitHub
parent 1355d4dd75
commit bae3a16e30
10 changed files with 241 additions and 18 deletions

View File

@ -3,10 +3,13 @@
## Unreleased ## Unreleased
### Added ### Added
- Brand new SwaggerUI support as a KTor plugin with WebJar under the hood and flexible configuration
### Changed ### Changed
- Playground example `SwaggerPlaygound` now demonstrates new SwaggerUI KTor plugin usage (including OAuth security)
### Remove ### Remove
- Deprecated Swagger Webjar approach was removed from codebase
--- ---
@ -18,7 +21,7 @@
## [2.2.0] - February 25th, 2022 ## [2.2.0] - February 25th, 2022
### Changed ### Changed
- Fixed support Location classes located in other non-location classes - Fixed support Location classes located in other non-location classes
- Fixed formatting of a custom SimpleSchema - Fixed formatting of a custom `SimpleSchema`
- Multipart form-data multiple file request support - Multipart form-data multiple file request support
## [2.1.1] - February 19th, 2022 ## [2.1.1] - February 19th, 2022

View File

@ -14,6 +14,7 @@ dependencies {
implementation(projects.kompendiumCore) implementation(projects.kompendiumCore)
implementation(projects.kompendiumAuth) implementation(projects.kompendiumAuth)
implementation(projects.kompendiumLocations) implementation(projects.kompendiumLocations)
implementation(projects.kompendiumSwaggerUi)
// Ktor // Ktor
val ktorVersion: String by project val ktorVersion: String by project
@ -26,7 +27,6 @@ dependencies {
implementation(group = "io.ktor", name = "ktor-jackson", version = ktorVersion) implementation(group = "io.ktor", name = "ktor-jackson", version = ktorVersion)
implementation(group = "io.ktor", name = "ktor-gson", version = ktorVersion) implementation(group = "io.ktor", name = "ktor-gson", version = ktorVersion)
implementation(group = "io.ktor", name = "ktor-locations", version = ktorVersion) implementation(group = "io.ktor", name = "ktor-locations", version = ktorVersion)
implementation(group = "io.ktor", name = "ktor-webjars", version = ktorVersion)
// Logging // Logging
implementation("org.apache.logging.log4j:log4j-api-kotlin:1.1.0") implementation("org.apache.logging.log4j:log4j-api-kotlin:1.1.0")

View File

@ -2,9 +2,12 @@ package io.bkbn.kompendium.playground
import io.bkbn.kompendium.core.Kompendium import io.bkbn.kompendium.core.Kompendium
import io.bkbn.kompendium.core.Notarized.notarizedGet import io.bkbn.kompendium.core.Notarized.notarizedGet
import io.bkbn.kompendium.core.routes.swagger import io.bkbn.kompendium.oas.component.Components
import io.bkbn.kompendium.oas.security.OAuth
import io.bkbn.kompendium.oas.serialization.KompendiumSerializersModule import io.bkbn.kompendium.oas.serialization.KompendiumSerializersModule
import io.bkbn.kompendium.playground.util.Util import io.bkbn.kompendium.playground.util.Util
import io.bkbn.kompendium.swagger.JsConfig
import io.bkbn.kompendium.swagger.SwaggerUI
import io.ktor.application.Application import io.ktor.application.Application
import io.ktor.application.call import io.ktor.application.call
import io.ktor.application.install import io.ktor.application.install
@ -15,14 +18,16 @@ import io.ktor.routing.routing
import io.ktor.serialization.json import io.ktor.serialization.json
import io.ktor.server.engine.embeddedServer import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty import io.ktor.server.netty.Netty
import io.ktor.webjars.Webjars import java.net.URI
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.util.UUID import java.util.UUID
import kotlinx.serialization.ExperimentalSerializationApi
/** /**
* Application entrypoint. Run this and head on over to `localhost:8081/docs` * Application entrypoint. Run this and head on over to `localhost:8081/docs`
* to see a very simple yet beautifully documented API * to see a very simple yet beautifully documented API
*/ */
@ExperimentalSerializationApi
fun main() { fun main() {
embeddedServer( embeddedServer(
Netty, Netty,
@ -31,7 +36,10 @@ fun main() {
).start(wait = true) ).start(wait = true)
} }
const val securitySchemaName = "oauth"
// Application Module // Application Module
@ExperimentalSerializationApi
private fun Application.mainModule() { private fun Application.mainModule() {
// Installs Simple JSON Content Negotiation // Installs Simple JSON Content Negotiation
install(ContentNegotiation) { install(ContentNegotiation) {
@ -41,17 +49,45 @@ private fun Application.mainModule() {
explicitNulls = false explicitNulls = false
}) })
} }
install(Webjars)
// Installs the Kompendium Plugin and sets up baseline server metadata // Installs the Kompendium Plugin and sets up baseline server metadata
install(Kompendium) { install(Kompendium) {
spec = Util.baseSpec spec = Util.baseSpec.copy(components = Components(securitySchemes = mutableMapOf(
securitySchemaName to OAuth(description = "OAuth Auth", flows = OAuth.Flows(
authorizationCode = OAuth.Flows.AuthorizationCode(
authorizationUrl = "http://localhost/auth",
tokenUrl = "http://localhost/token"
)
))
)))
} }
install(SwaggerUI) {
swaggerUrl = "/swagger-ui"
jsConfig = JsConfig(
specs = mapOf(
"My API v1" to URI("/openapi.json"),
"My API v2" to URI("/openapi.json")
),
// This part will be inserted after Swagger UI is loaded in Browser.
// Example is prepared according to this documentation: https://swagger.io/docs/open-source-tools/swagger-ui/usage/oauth2/
jsInit = {
"""
ui.initOAuth({
clientId: 'CLIENT_ID',
clientSecret: 'CLIENT_SECRET',
realm: 'MY REALM',
appName: 'TEST APP',
useBasicAuthenticationWithAccessCodeGrant: true
});
"""
}
)
}
// Configures the routes for our API // Configures the routes for our API
routing { routing {
// This is all you need to do to add Swagger! Reachable at `/swagger-ui`
swagger()
// Kompendium infers the route path from the Ktor Route. This will show up as the root path `/` // Kompendium infers the route path from the Ktor Route. This will show up as the root path `/`
notarizedGet(BasicPlaygroundToC.simpleGetExample) { notarizedGet(BasicPlaygroundToC.simpleGetExample.copy(securitySchemes = setOf(securitySchemaName))) {
call.respond(HttpStatusCode.OK, BasicModels.BasicResponse(c = UUID.randomUUID().toString())) call.respond(HttpStatusCode.OK, BasicModels.BasicResponse(c = UUID.randomUUID().toString()))
} }
} }

View File

@ -1,3 +1,52 @@
# Module kompendium-swagger-ui # Module kompendium-swagger-ui
Contains the code necessary to launch `swagger` as your documentation frontend. This module is responsible for frontend part of `SwaggerUI` built on top on WebJar.
Solution is wrapped into KTor plugin that may be tuned with configuration properties according to
Swagger UI official documentation (`JsConfig` is responsible for that):
https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/
Current implementation covers only most important part of specification properties (we'll be adding more time to time)
# Module configuration
Minimal SwaggerUI plugin configuration:
```kotlin
import io.bkbn.kompendium.swagger.JsConfig
import io.bkbn.kompendium.swagger.SwaggerUI
import io.ktor.application.install
install(SwaggerUI) {
swaggerUrl = "/swagger-ui"
jsConfig = JsConfig(
specs = mapOf(
"Your API name" to URI("/openapi.json")
)
)
}
```
Additionally, there is a way to add additional initialization code in SwaggerUI JS.
`JsConfig.jsInit` is responsible for that:
```kotlin
JsConfig(
//...
jsInit = {
"""
ui.initOAuth(...)
"""
}
)
```
# Playground example
There is an example that demonstrates how this plugin is working in `kompendium-playground` module:
```
io.bkbn.kompendium.playground.SwaggerPlayground.kt
```

View File

@ -17,7 +17,7 @@ sourdough {
dependencies { dependencies {
val ktorVersion: String by project val ktorVersion: String by project
implementation(group = "io.ktor", name = "ktor-server-core", version = ktorVersion) implementation(group = "io.ktor", name = "ktor-server-core", version = ktorVersion)
implementation(group = "io.ktor", name = "ktor-webjars", version = ktorVersion) implementation(group = "org.webjars", name = "webjars-locator-core", version = "0.50")
implementation(group = "org.webjars", name = "swagger-ui", version = "4.5.2") implementation(group = "org.webjars", name = "swagger-ui", version = "4.5.2")
} }

View File

@ -0,0 +1,29 @@
package io.bkbn.kompendium.swagger
import java.net.URI
// This class represents this specification:
// https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/
data class JsConfig(
val specs: Map<String, URI>,
val deepLinking: Boolean = true,
val displayOperationId: Boolean = true,
val displayRequestDuration: Boolean = true,
val docExpansion: String = "none",
val operationsSorter: String = "alpha",
val defaultModelExpandDepth: Int = 4,
val defaultModelsExpandDepth: Int = 4,
val persistAuthorization: Boolean = true,
val tagsSorter: String = "alpha",
val tryItOutEnabled: Boolean = false,
val validatorUrl: String? = null,
val jsInit: () -> String? = { null }
)
internal fun JsConfig.toJsProps(): String = asMap()
.filterKeys { !setOf("specs", "jsInit").contains(it) }
.map{ "${it.key}: ${it.value.toJs()}" }
.joinToString(separator = ",\n\t\t")
internal fun JsConfig.getSpecUrlsProps(): String =
if (specs.isEmpty()) "[]" else specs.map { "{url: ${it.value.toJs()}, name: ${it.key.toJs()}}" }.toString()

View File

@ -0,0 +1,14 @@
package io.bkbn.kompendium.swagger
internal const val JS_UNDEFINED = "undefined"
internal fun String?.toJs(): String = this?.let { "'$it'" } ?: JS_UNDEFINED
internal fun Boolean?.toJs(): String = this?.toString() ?: JS_UNDEFINED
internal fun Int?.toJs(): String = this?.toString() ?: JS_UNDEFINED
internal fun Any?.toJs(): String = when(this) {
is String? -> toJs()
is Int? -> toJs()
is Boolean? -> toJs()
else -> toString().toJs()
}

View File

@ -0,0 +1,6 @@
package io.bkbn.kompendium.swagger
import kotlin.reflect.full.memberProperties
internal inline fun<reified T: Any> T.asMap(): Map<String, Any?> =
T::class.memberProperties.associate { it.name to it.get(this) }

View File

@ -1,16 +1,78 @@
package io.bkbn.kompendium.swagger package io.bkbn.kompendium.swagger
import io.ktor.application.Application
import io.ktor.application.ApplicationFeature
import io.ktor.application.call import io.ktor.application.call
import io.ktor.http.HttpStatusCode
import io.ktor.response.respond
import io.ktor.response.respondRedirect import io.ktor.response.respondRedirect
import io.ktor.routing.Routing import io.ktor.routing.Routing
import io.ktor.routing.get import io.ktor.routing.get
import io.ktor.routing.routing
import io.ktor.util.AttributeKey
import java.net.URI
import org.webjars.WebJarAssetLocator
@Deprecated( @Suppress("unused")
"Webjar approach is deprecated", class SwaggerUI(val config: Configuration) {
replaceWith = ReplaceWith("swagger()", "io.bkbn.kompendium.core.routes.swagger")
) class Configuration {
fun Routing.swaggerUI(openApiJsonUrl: String = "/openapi.json") { // The primary Swagger-UI page (will redirect to target page)
get("/swagger-ui") { var swaggerUrl: String = "/swagger-ui"
call.respondRedirect("/webjars/swagger-ui/index.html?url=$openApiJsonUrl", true) // The Root path to Swagger resources (in most cases a path to the webjar resources)
var swaggerBaseUrl: String = "/webjars/swagger-ui"
// Configuration for SwaggerUI JS initialization
lateinit var jsConfig: JsConfig
// Application context path (for example if application have the following path: http://domain.com/app, use: "/app")
var contextPath: String = ""
}
companion object Feature: ApplicationFeature<Application, Configuration, SwaggerUI> {
private fun Configuration.toInternal(): InternalConfiguration = InternalConfiguration(
swaggerUrl = URI(swaggerUrl),
swaggerBaseUrl = URI(swaggerBaseUrl),
jsConfig = jsConfig,
contextPath = contextPath
)
private data class InternalConfiguration(
val swaggerUrl: URI,
val swaggerBaseUrl: URI,
val jsConfig: JsConfig,
val contextPath: String
) {
val redirectIndexUrl: String = "${contextPath}${swaggerBaseUrl}/index.html"
}
private val locator = WebJarAssetLocator()
private val installRoutes: Routing.(InternalConfiguration) -> Unit = { config ->
get(config.swaggerUrl.toString()) {
call.respondRedirect(config.redirectIndexUrl)
}
get("${config.swaggerBaseUrl}/{filename}") {
call.parameters["filename"]!!.let { filename ->
when(filename) {
"index.html" ->
locator.getSwaggerIndexContent(jsConfig = config.jsConfig)
else ->
locator.getSwaggerResourceContent(path = filename)
}
}.let { call.respond(HttpStatusCode.OK, it) }
}
}
override val key: AttributeKey<SwaggerUI> = AttributeKey("SwaggerUI")
override fun install(pipeline: Application, configure: Configuration.() -> Unit): SwaggerUI {
pipeline.routing { }.let { routing ->
val configuration: Configuration = Configuration().apply(configure)
installRoutes(routing, configuration.toInternal())
return SwaggerUI(config = configuration)
}
}
} }
} }

View File

@ -0,0 +1,24 @@
package io.bkbn.kompendium.swagger
import io.ktor.features.NotFoundException
import io.ktor.http.content.ByteArrayContent
import java.net.URL
import org.webjars.WebJarAssetLocator
internal fun WebJarAssetLocator.getSwaggerResource(path: String): URL =
this::class.java.getResource(getFullPath("swagger-ui", path).let { if (it.startsWith("/")) it else "/$it" })
?: throw NotFoundException("Resource not found: $path")
internal fun WebJarAssetLocator.getSwaggerResourceContent(path: String): ByteArrayContent =
ByteArrayContent(getSwaggerResource(path).readBytes())
internal fun WebJarAssetLocator.getSwaggerIndexContent(jsConfig: JsConfig): ByteArrayContent = ByteArrayContent(
getSwaggerResource(path = "index.html").readText()
.replaceFirst("url: \"https://petstore.swagger.io/v2/swagger.json\",", "urls: ${jsConfig.getSpecUrlsProps()},")
.replaceFirst("deepLinking: true", jsConfig.toJsProps())
.let { content ->
jsConfig.jsInit()?.let {
content.replaceFirst("window.ui = ui", "$it\n\twindow.ui = ui")
} ?: content
}.toByteArray()
)