diff --git a/CHANGELOG.md b/CHANGELOG.md index 42853b3a2..56c420d79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/kompendium-playground/build.gradle.kts b/kompendium-playground/build.gradle.kts index b43e2a990..5556e384f 100644 --- a/kompendium-playground/build.gradle.kts +++ b/kompendium-playground/build.gradle.kts @@ -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") diff --git a/kompendium-playground/src/main/kotlin/io/bkbn/kompendium/playground/SwaggerPlayground.kt b/kompendium-playground/src/main/kotlin/io/bkbn/kompendium/playground/SwaggerPlayground.kt index a7affc8ec..12a936fb1 100644 --- a/kompendium-playground/src/main/kotlin/io/bkbn/kompendium/playground/SwaggerPlayground.kt +++ b/kompendium-playground/src/main/kotlin/io/bkbn/kompendium/playground/SwaggerPlayground.kt @@ -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())) } } diff --git a/kompendium-swagger-ui/Module.md b/kompendium-swagger-ui/Module.md index 19bcca090..2a63c2c42 100644 --- a/kompendium-swagger-ui/Module.md +++ b/kompendium-swagger-ui/Module.md @@ -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 +``` \ No newline at end of file diff --git a/kompendium-swagger-ui/build.gradle.kts b/kompendium-swagger-ui/build.gradle.kts index 4f285d909..abb59517c 100644 --- a/kompendium-swagger-ui/build.gradle.kts +++ b/kompendium-swagger-ui/build.gradle.kts @@ -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") } diff --git a/kompendium-swagger-ui/src/main/kotlin/io/bkbn/kompendium/swagger/JsConfig.kt b/kompendium-swagger-ui/src/main/kotlin/io/bkbn/kompendium/swagger/JsConfig.kt new file mode 100644 index 000000000..f43ad914b --- /dev/null +++ b/kompendium-swagger-ui/src/main/kotlin/io/bkbn/kompendium/swagger/JsConfig.kt @@ -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, + 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() diff --git a/kompendium-swagger-ui/src/main/kotlin/io/bkbn/kompendium/swagger/JsUtils.kt b/kompendium-swagger-ui/src/main/kotlin/io/bkbn/kompendium/swagger/JsUtils.kt new file mode 100644 index 000000000..0c6716165 --- /dev/null +++ b/kompendium-swagger-ui/src/main/kotlin/io/bkbn/kompendium/swagger/JsUtils.kt @@ -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() +} diff --git a/kompendium-swagger-ui/src/main/kotlin/io/bkbn/kompendium/swagger/ReflectionUtils.kt b/kompendium-swagger-ui/src/main/kotlin/io/bkbn/kompendium/swagger/ReflectionUtils.kt new file mode 100644 index 000000000..1d9f3fbd4 --- /dev/null +++ b/kompendium-swagger-ui/src/main/kotlin/io/bkbn/kompendium/swagger/ReflectionUtils.kt @@ -0,0 +1,6 @@ +package io.bkbn.kompendium.swagger + +import kotlin.reflect.full.memberProperties + +internal inline fun T.asMap(): Map = + T::class.memberProperties.associate { it.name to it.get(this) } diff --git a/kompendium-swagger-ui/src/main/kotlin/io/bkbn/kompendium/swagger/SwaggerUI.kt b/kompendium-swagger-ui/src/main/kotlin/io/bkbn/kompendium/swagger/SwaggerUI.kt index d8f62b9aa..16600c1e4 100644 --- a/kompendium-swagger-ui/src/main/kotlin/io/bkbn/kompendium/swagger/SwaggerUI.kt +++ b/kompendium-swagger-ui/src/main/kotlin/io/bkbn/kompendium/swagger/SwaggerUI.kt @@ -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 { + + 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 = 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) + } + } } } diff --git a/kompendium-swagger-ui/src/main/kotlin/io/bkbn/kompendium/swagger/SwaggerWebJarUtils.kt b/kompendium-swagger-ui/src/main/kotlin/io/bkbn/kompendium/swagger/SwaggerWebJarUtils.kt new file mode 100644 index 000000000..1b3632042 --- /dev/null +++ b/kompendium-swagger-ui/src/main/kotlin/io/bkbn/kompendium/swagger/SwaggerWebJarUtils.kt @@ -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() + )