feat: Added SwaggerUI KTor Plugin (#215)
This commit is contained in:

committed by
GitHub

parent
1355d4dd75
commit
bae3a16e30
@ -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
|
||||||
|
@ -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")
|
||||||
|
@ -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()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
```
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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()
|
@ -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()
|
||||||
|
}
|
@ -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) }
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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()
|
||||||
|
)
|
Reference in New Issue
Block a user