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

committed by
GitHub

parent
1355d4dd75
commit
bae3a16e30
@ -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
|
||||
|
@ -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")
|
||||
|
@ -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()))
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
```
|
@ -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")
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
||||
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")
|
||||
@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
|
||||
)
|
||||
fun Routing.swaggerUI(openApiJsonUrl: String = "/openapi.json") {
|
||||
get("/swagger-ui") {
|
||||
call.respondRedirect("/webjars/swagger-ui/index.html?url=$openApiJsonUrl", true)
|
||||
|
||||
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