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
### Added
- Brand new SwaggerUI support as a KTor plugin with WebJar under the hood and flexible configuration
### Changed
- Playground example `SwaggerPlaygound` now demonstrates new SwaggerUI KTor plugin usage (including OAuth security)
### Remove
- Deprecated Swagger Webjar approach was removed from codebase
---
@ -18,7 +21,7 @@
## [2.2.0] - February 25th, 2022
### Changed
- 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
## [2.1.1] - February 19th, 2022

View File

@ -14,6 +14,7 @@ dependencies {
implementation(projects.kompendiumCore)
implementation(projects.kompendiumAuth)
implementation(projects.kompendiumLocations)
implementation(projects.kompendiumSwaggerUi)
// Ktor
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-gson", version = ktorVersion)
implementation(group = "io.ktor", name = "ktor-locations", version = ktorVersion)
implementation(group = "io.ktor", name = "ktor-webjars", version = ktorVersion)
// Logging
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.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.playground.util.Util
import io.bkbn.kompendium.swagger.JsConfig
import io.bkbn.kompendium.swagger.SwaggerUI
import io.ktor.application.Application
import io.ktor.application.call
import io.ktor.application.install
@ -15,14 +18,16 @@ import io.ktor.routing.routing
import io.ktor.serialization.json
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.webjars.Webjars
import java.net.URI
import kotlinx.serialization.json.Json
import java.util.UUID
import kotlinx.serialization.ExperimentalSerializationApi
/**
* Application entrypoint. Run this and head on over to `localhost:8081/docs`
* to see a very simple yet beautifully documented API
*/
@ExperimentalSerializationApi
fun main() {
embeddedServer(
Netty,
@ -31,7 +36,10 @@ fun main() {
).start(wait = true)
}
const val securitySchemaName = "oauth"
// Application Module
@ExperimentalSerializationApi
private fun Application.mainModule() {
// Installs Simple JSON Content Negotiation
install(ContentNegotiation) {
@ -41,17 +49,45 @@ private fun Application.mainModule() {
explicitNulls = false
})
}
install(Webjars)
// Installs the Kompendium Plugin and sets up baseline server metadata
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
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 `/`
notarizedGet(BasicPlaygroundToC.simpleGetExample) {
notarizedGet(BasicPlaygroundToC.simpleGetExample.copy(securitySchemes = setOf(securitySchemaName))) {
call.respond(HttpStatusCode.OK, BasicModels.BasicResponse(c = UUID.randomUUID().toString()))
}
}

View File

@ -1,3 +1,52 @@
# 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 {
val ktorVersion: String by project
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")
}

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
import io.ktor.application.Application
import io.ktor.application.ApplicationFeature
import io.ktor.application.call
import io.ktor.http.HttpStatusCode
import io.ktor.response.respond
import io.ktor.response.respondRedirect
import io.ktor.routing.Routing
import io.ktor.routing.get
import io.ktor.routing.routing
import io.ktor.util.AttributeKey
import java.net.URI
import org.webjars.WebJarAssetLocator
@Deprecated(
"Webjar approach is deprecated",
replaceWith = ReplaceWith("swagger()", "io.bkbn.kompendium.core.routes.swagger")
)
fun Routing.swaggerUI(openApiJsonUrl: String = "/openapi.json") {
get("/swagger-ui") {
call.respondRedirect("/webjars/swagger-ui/index.html?url=$openApiJsonUrl", true)
@Suppress("unused")
class SwaggerUI(val config: Configuration) {
class Configuration {
// The primary Swagger-UI page (will redirect to target page)
var swaggerUrl: String = "/swagger-ui"
// 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()
)