From a9949d43723d6e4d856ec63ffc63ad04974581f9 Mon Sep 17 00:00:00 2001 From: Lukas Ruegner Date: Thu, 25 Apr 2024 22:12:36 +0200 Subject: [PATCH] add option for manual routing --- .../smiley4/ktorswaggerui/SwaggerPlugin.kt | 85 +++++------- .../ktorswaggerui/data/SwaggerUIData.kt | 2 + .../dsl/DocumentedRouteSelector.kt | 15 +++ .../smiley4/ktorswaggerui/dsl/SwaggerUIDsl.kt | 7 + .../smiley4/ktorswaggerui/routing/ApiSpec.kt | 24 ++++ .../ktorswaggerui/routing/manualRouting.kt | 81 ++++++++++++ .../ktorswaggerui/examples/ManualRouting.kt | 43 +++++++ .../ManualRoutingMultipleSpecsExample.kt | 121 ++++++++++++++++++ 8 files changed, 326 insertions(+), 52 deletions(-) create mode 100644 src/main/kotlin/io/github/smiley4/ktorswaggerui/routing/ApiSpec.kt create mode 100644 src/main/kotlin/io/github/smiley4/ktorswaggerui/routing/manualRouting.kt create mode 100644 src/test/kotlin/io/github/smiley4/ktorswaggerui/examples/ManualRouting.kt create mode 100644 src/test/kotlin/io/github/smiley4/ktorswaggerui/examples/ManualRoutingMultipleSpecsExample.kt diff --git a/src/main/kotlin/io/github/smiley4/ktorswaggerui/SwaggerPlugin.kt b/src/main/kotlin/io/github/smiley4/ktorswaggerui/SwaggerPlugin.kt index 16abfa8..b3977bf 100644 --- a/src/main/kotlin/io/github/smiley4/ktorswaggerui/SwaggerPlugin.kt +++ b/src/main/kotlin/io/github/smiley4/ktorswaggerui/SwaggerPlugin.kt @@ -1,35 +1,9 @@ package io.github.smiley4.ktorswaggerui import com.fasterxml.jackson.databind.ObjectMapper -import io.github.smiley4.ktorswaggerui.data.PluginConfigData -import io.github.smiley4.ktorswaggerui.dsl.PluginConfigDsl -import io.github.smiley4.ktorswaggerui.routing.ForwardRouteController -import io.github.smiley4.ktorswaggerui.routing.SwaggerController import io.github.smiley4.ktorswaggerui.builder.example.ExampleContext import io.github.smiley4.ktorswaggerui.builder.example.ExampleContextBuilder -import io.github.smiley4.ktorswaggerui.builder.openapi.ComponentsBuilder -import io.github.smiley4.ktorswaggerui.builder.openapi.ContactBuilder -import io.github.smiley4.ktorswaggerui.builder.openapi.ContentBuilder -import io.github.smiley4.ktorswaggerui.builder.openapi.ExampleBuilder -import io.github.smiley4.ktorswaggerui.builder.openapi.ExternalDocumentationBuilder -import io.github.smiley4.ktorswaggerui.builder.openapi.HeaderBuilder -import io.github.smiley4.ktorswaggerui.builder.openapi.InfoBuilder -import io.github.smiley4.ktorswaggerui.builder.openapi.LicenseBuilder -import io.github.smiley4.ktorswaggerui.builder.openapi.OAuthFlowsBuilder -import io.github.smiley4.ktorswaggerui.builder.openapi.OpenApiBuilder -import io.github.smiley4.ktorswaggerui.builder.openapi.OperationBuilder -import io.github.smiley4.ktorswaggerui.builder.openapi.OperationTagsBuilder -import io.github.smiley4.ktorswaggerui.builder.openapi.ParameterBuilder -import io.github.smiley4.ktorswaggerui.builder.openapi.PathBuilder -import io.github.smiley4.ktorswaggerui.builder.openapi.PathsBuilder -import io.github.smiley4.ktorswaggerui.builder.openapi.RequestBodyBuilder -import io.github.smiley4.ktorswaggerui.builder.openapi.ResponseBuilder -import io.github.smiley4.ktorswaggerui.builder.openapi.ResponsesBuilder -import io.github.smiley4.ktorswaggerui.builder.openapi.SecurityRequirementsBuilder -import io.github.smiley4.ktorswaggerui.builder.openapi.SecuritySchemesBuilder -import io.github.smiley4.ktorswaggerui.builder.openapi.ServerBuilder -import io.github.smiley4.ktorswaggerui.builder.openapi.TagBuilder -import io.github.smiley4.ktorswaggerui.builder.openapi.TagExternalDocumentationBuilder +import io.github.smiley4.ktorswaggerui.builder.openapi.* import io.github.smiley4.ktorswaggerui.builder.route.RouteCollector import io.github.smiley4.ktorswaggerui.builder.route.RouteDocumentationMerger import io.github.smiley4.ktorswaggerui.builder.route.RouteMeta @@ -37,17 +11,20 @@ import io.github.smiley4.ktorswaggerui.builder.schema.SchemaBuilder import io.github.smiley4.ktorswaggerui.builder.schema.SchemaContext import io.github.smiley4.ktorswaggerui.builder.schema.SchemaContextBuilder import io.github.smiley4.ktorswaggerui.builder.schema.TypeOverwrites -import io.ktor.server.application.Application -import io.ktor.server.application.ApplicationStarted -import io.ktor.server.application.createApplicationPlugin -import io.ktor.server.application.hooks.MonitoringEvent -import io.ktor.server.application.install -import io.ktor.server.application.plugin -import io.ktor.server.application.pluginOrNull -import io.ktor.server.routing.Routing -import io.ktor.server.webjars.Webjars +import io.github.smiley4.ktorswaggerui.data.PluginConfigData +import io.github.smiley4.ktorswaggerui.dsl.PluginConfigDsl +import io.github.smiley4.ktorswaggerui.routing.ApiSpec +import io.github.smiley4.ktorswaggerui.routing.ForwardRouteController +import io.github.smiley4.ktorswaggerui.routing.SwaggerController +import io.ktor.server.application.* +import io.ktor.server.application.hooks.* +import io.ktor.server.routing.* +import io.ktor.server.webjars.* import io.swagger.v3.core.util.Json import mu.KotlinLogging +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.set /** * This version must match the version of the gradle dependency @@ -66,27 +43,27 @@ val SwaggerUI = createApplicationPlugin(name = "SwaggerUI", createConfiguration application.install(Webjars) } - val apiSpecsJson = mutableMapOf() try { val routes = routes(application, config) - apiSpecsJson.putAll(buildOpenApiSpecs(config, routes)) + ApiSpec.setAll(buildOpenApiSpecs(config, routes)) } catch (e: Exception) { logger.error("Error during application startup in swagger-ui-plugin", e) } - apiSpecsJson.forEach { (specId, json) -> - val specConfig = config.specConfigs[specId] ?: config - SwaggerController( - applicationConfig!!, - specConfig, - SWAGGER_UI_WEBJARS_VERSION, - if (apiSpecsJson.size > 1) specId else null, - json - ).setup(application) - } - - if (apiSpecsJson.size == 1 && config.swaggerUI.forwardRoot) { - ForwardRouteController(applicationConfig!!, config).setup(application) + if (config.swaggerUI.automaticRouter) { + ApiSpec.getAll().forEach { (specId, json) -> + val specConfig = config.specConfigs[specId] ?: config + SwaggerController( + applicationConfig!!, + specConfig, + SWAGGER_UI_WEBJARS_VERSION, + if (ApiSpec.getAll().size > 1) specId else null, + json + ).setup(application) + if (ApiSpec.getAll().size == 1 && config.swaggerUI.forwardRoot) { + ForwardRouteController(applicationConfig!!, config).setup(application) + } + } } } @@ -146,7 +123,11 @@ private fun exampleContext(config: PluginConfigData, routes: List): E ).build(routes.toList()) } -private fun builder(config: PluginConfigData, schemaContext: SchemaContext, exampleContext: ExampleContext): OpenApiBuilder { +private fun builder( + config: PluginConfigData, + schemaContext: SchemaContext, + exampleContext: ExampleContext +): OpenApiBuilder { return OpenApiBuilder( config = config, schemaContext = schemaContext, diff --git a/src/main/kotlin/io/github/smiley4/ktorswaggerui/data/SwaggerUIData.kt b/src/main/kotlin/io/github/smiley4/ktorswaggerui/data/SwaggerUIData.kt index 34ccde6..7bb2b67 100644 --- a/src/main/kotlin/io/github/smiley4/ktorswaggerui/data/SwaggerUIData.kt +++ b/src/main/kotlin/io/github/smiley4/ktorswaggerui/data/SwaggerUIData.kt @@ -1,6 +1,7 @@ package io.github.smiley4.ktorswaggerui.data data class SwaggerUIData( + val automaticRouter: Boolean, val forwardRoot: Boolean, val swaggerUrl: String, val rootHostPath: String, @@ -15,6 +16,7 @@ data class SwaggerUIData( companion object { val DEFAULT = SwaggerUIData( + automaticRouter = true, forwardRoot = false, swaggerUrl = "swagger-ui", rootHostPath = "", diff --git a/src/main/kotlin/io/github/smiley4/ktorswaggerui/dsl/DocumentedRouteSelector.kt b/src/main/kotlin/io/github/smiley4/ktorswaggerui/dsl/DocumentedRouteSelector.kt index a58c8f4..f15f99d 100644 --- a/src/main/kotlin/io/github/smiley4/ktorswaggerui/dsl/DocumentedRouteSelector.kt +++ b/src/main/kotlin/io/github/smiley4/ktorswaggerui/dsl/DocumentedRouteSelector.kt @@ -46,6 +46,21 @@ fun Route.documentation( // ROUTING // //============================// +fun Route.route( + builder: OpenApiRoute.() -> Unit = { }, + build: Route.() -> Unit +): Route { + return documentation(builder) { route("", build) } +} + +fun Route.route( + method: HttpMethod, + builder: OpenApiRoute.() -> Unit = { }, + build: Route.() -> Unit +): Route { + return documentation(builder) { route("", method, build) } +} + fun Route.route( path: String, builder: OpenApiRoute.() -> Unit = { }, diff --git a/src/main/kotlin/io/github/smiley4/ktorswaggerui/dsl/SwaggerUIDsl.kt b/src/main/kotlin/io/github/smiley4/ktorswaggerui/dsl/SwaggerUIDsl.kt index b41d5f0..22c7de2 100644 --- a/src/main/kotlin/io/github/smiley4/ktorswaggerui/dsl/SwaggerUIDsl.kt +++ b/src/main/kotlin/io/github/smiley4/ktorswaggerui/dsl/SwaggerUIDsl.kt @@ -9,6 +9,12 @@ import io.github.smiley4.ktorswaggerui.data.SwaggerUIData @OpenApiDslMarker class SwaggerUIDsl { + /** + * Whether to use the automatic swagger-ui router or create swagger-ui router manually. + * 'false' results in [forwardRoot], [swaggerUrl], [rootHostPath], [authentication] being ignored. + */ + var automaticRouter: Boolean = SwaggerUIData.DEFAULT.automaticRouter + /** * Whether to forward the root-url to the swagger-url */ @@ -87,6 +93,7 @@ class SwaggerUIDsl { internal fun build(base: SwaggerUIData): SwaggerUIData { return SwaggerUIData( + automaticRouter = automaticRouter, forwardRoot = mergeBoolean(base.forwardRoot, this.forwardRoot), swaggerUrl = mergeDefault(base.swaggerUrl, this.swaggerUrl, SwaggerUIData.DEFAULT.swaggerUrl), rootHostPath = mergeDefault(base.rootHostPath, this.rootHostPath, SwaggerUIData.DEFAULT.rootHostPath), diff --git a/src/main/kotlin/io/github/smiley4/ktorswaggerui/routing/ApiSpec.kt b/src/main/kotlin/io/github/smiley4/ktorswaggerui/routing/ApiSpec.kt new file mode 100644 index 0000000..5d97431 --- /dev/null +++ b/src/main/kotlin/io/github/smiley4/ktorswaggerui/routing/ApiSpec.kt @@ -0,0 +1,24 @@ +package io.github.smiley4.ktorswaggerui.routing + +object ApiSpec { + + private val apiSpecs = mutableMapOf() + + fun setAll(specs: Map) { + apiSpecs.clear() + apiSpecs.putAll(specs) + } + + fun set(name: String, spec: String) { + apiSpecs[name] = spec + } + + fun get(name: String): String { + return apiSpecs[name] ?: throw NoSuchElementException("No api-spec with name $name registered.") + } + + fun getAll(): Map { + return apiSpecs + } + +} diff --git a/src/main/kotlin/io/github/smiley4/ktorswaggerui/routing/manualRouting.kt b/src/main/kotlin/io/github/smiley4/ktorswaggerui/routing/manualRouting.kt new file mode 100644 index 0000000..1e90969 --- /dev/null +++ b/src/main/kotlin/io/github/smiley4/ktorswaggerui/routing/manualRouting.kt @@ -0,0 +1,81 @@ +package io.github.smiley4.ktorswaggerui.routing + +import io.github.smiley4.ktorswaggerui.SWAGGER_UI_WEBJARS_VERSION +import io.github.smiley4.ktorswaggerui.SwaggerUI +import io.github.smiley4.ktorswaggerui.data.SwaggerUIData +import io.github.smiley4.ktorswaggerui.data.SwaggerUiSort +import io.github.smiley4.ktorswaggerui.dsl.PluginConfigDsl +import io.github.smiley4.ktorswaggerui.dsl.route +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + + +fun Route.openApiSpec(specId: String = PluginConfigDsl.DEFAULT_SPEC_ID) { + route({ hidden = true }) { + get { + call.respondText(ContentType.Application.Json, HttpStatusCode.OK) { ApiSpec.get(specId) } + } + } +} + +fun Route.swaggerUI(apiUrl: String) { + route({ hidden = true }) { + get { + call.respondRedirect("${call.request.uri}/index.html") + } + get("{filename}") { + serveStaticResource(call.parameters["filename"]!!, SWAGGER_UI_WEBJARS_VERSION, call) + } + get("swagger-initializer.js") { + serveSwaggerInitializer(call, SwaggerUIData.DEFAULT, apiUrl) + } + } +} + +private suspend fun serveSwaggerInitializer(call: ApplicationCall, swaggerUiConfig: SwaggerUIData, apiUrl: String) { + // see https://github.com/swagger-api/swagger-ui/blob/master/docs/usage/configuration.md for reference + val propValidatorUrl = swaggerUiConfig.validatorUrl?.let { "validatorUrl: \"$it\"" } ?: "validatorUrl: false" + val propDisplayOperationId = "displayOperationId: ${swaggerUiConfig.displayOperationId}" + val propFilter = "filter: ${swaggerUiConfig.showTagFilterInput}" + val propSort = "operationsSorter: " + + if (swaggerUiConfig.sort == SwaggerUiSort.NONE) "undefined" + else "\"${swaggerUiConfig.sort.value}\"" + val propSyntaxHighlight = "syntaxHighlight: { theme: \"${swaggerUiConfig.syntaxHighlight.value}\" }" + val content = """ + window.onload = function() { + window.ui = SwaggerUIBundle({ + url: "$apiUrl", + dom_id: '#swagger-ui', + deepLinking: true, + presets: [ + SwaggerUIBundle.presets.apis, + SwaggerUIStandalonePreset + ], + plugins: [ + SwaggerUIBundle.plugins.DownloadUrl + ], + layout: "StandaloneLayout", + withCredentials: ${swaggerUiConfig.withCredentials}, + $propValidatorUrl, + $propDisplayOperationId, + $propFilter, + $propSort, + $propSyntaxHighlight + }); + }; + """.trimIndent() + call.respondText(ContentType.Application.JavaScript, HttpStatusCode.OK) { content } +} + +private suspend fun serveStaticResource(filename: String, swaggerWebjarVersion: String, call: ApplicationCall) { + val resourceName = "/META-INF/resources/webjars/swagger-ui/$swaggerWebjarVersion/$filename" + val resource = SwaggerUI::class.java.getResource(resourceName) + if (resource != null) { + call.respond(ResourceContent(resource)) + } else { + call.respond(HttpStatusCode.NotFound, "$filename could not be found") + } +} diff --git a/src/test/kotlin/io/github/smiley4/ktorswaggerui/examples/ManualRouting.kt b/src/test/kotlin/io/github/smiley4/ktorswaggerui/examples/ManualRouting.kt new file mode 100644 index 0000000..50247ea --- /dev/null +++ b/src/test/kotlin/io/github/smiley4/ktorswaggerui/examples/ManualRouting.kt @@ -0,0 +1,43 @@ +package io.github.smiley4.ktorswaggerui.examples + +import io.github.smiley4.ktorswaggerui.SwaggerUI +import io.github.smiley4.ktorswaggerui.dsl.get +import io.github.smiley4.ktorswaggerui.routing.openApiSpec +import io.github.smiley4.ktorswaggerui.routing.swaggerUI +import io.ktor.server.application.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +/** + * An example showcasing manual swaggerui-routing + */ +fun main() { + embeddedServer(Netty, port = 8080, host = "localhost", module = Application::myModule).start(wait = true) +} + +private fun Application.myModule() { + + install(SwaggerUI) { + swagger { + automaticRouter = false + } + } + + routing { + + route("swagger") { + swaggerUI("/api.json") + } + route("api.json") { + openApiSpec() + } + + get("hello", { + description = "Simple 'Hello World'- Route" + }) { + call.respondText("Hello World!") + } + } +} diff --git a/src/test/kotlin/io/github/smiley4/ktorswaggerui/examples/ManualRoutingMultipleSpecsExample.kt b/src/test/kotlin/io/github/smiley4/ktorswaggerui/examples/ManualRoutingMultipleSpecsExample.kt new file mode 100644 index 0000000..a736eb1 --- /dev/null +++ b/src/test/kotlin/io/github/smiley4/ktorswaggerui/examples/ManualRoutingMultipleSpecsExample.kt @@ -0,0 +1,121 @@ +package io.github.smiley4.ktorswaggerui.examples + +import io.github.smiley4.ktorswaggerui.SwaggerUI +import io.github.smiley4.ktorswaggerui.dsl.get +import io.github.smiley4.ktorswaggerui.dsl.route +import io.github.smiley4.ktorswaggerui.routing.openApiSpec +import io.github.smiley4.ktorswaggerui.routing.swaggerUI +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +/** + * An example showcasing manual routing with multiple openapi-specs in a single application + * - localhost:8080/swagger-ui/v1/index.html + * * /v1/hello + * - localhost:8080/swagger-ui/v2/index.html + * * /v2/hello + * * /hi + */ +fun main() { + embeddedServer(Netty, port = 8080, host = "localhost", module = Application::myModule).start(wait = true) +} + +private fun Application.myModule() { + + install(Authentication) { + basic("auth-swagger") { + realm = "Access to the Swagger UI" + validate { credentials -> + if (credentials.name == "user" && credentials.password == "pass") { + UserIdPrincipal(credentials.name) + } else { + null + } + } + } + } + + install(SwaggerUI) { + swagger { + automaticRouter = false + } + // general configuration + info { + title = "Example API" + } + specAssigner = { _, _ -> "v2" } // assign all unassigned routes to spec "v2" (here e.g. '/hi') + + // configuration specific for spec "v1" + spec("v1") { + info { + version = "1.0" + } + } + + // configuration specific for spec "v2" + spec("v2") { + info { + version = "2.0" + } + swagger { + authentication = "auth-swagger" + } + } + } + + + routing { + + route("api") { + route("version-1.json") { + openApiSpec("v1") + } + route("version-2.json") { + openApiSpec("v2") + } + } + + route("swagger") { + route("version-1") { + swaggerUI("/api/version-1.json") + } + route("version-2") { + swaggerUI("/api/version-2.json") + } + } + + // version 1.0 routes + route("v1", { + specId = "v1" // assign all sub-routes to spec "v1" + }) { + get("hello", { + description = "Simple version 1 'Hello World'-Route" + }) { + call.respondText("Hello World!") + } + } + + // version 2.0 routes + route("v2", { + specId = "v2" // assign all sub-routes to spec "v2" + }) { + get("hello", { + description = "Simple version 2 'Hello World'-Route" + }) { + call.respondText("Improved Hello World!") + } + } + + // other routes + get("hi", { + description = "Alternative version of 'Hello World'-Route" + }) { + call.respondText("Alternative Hello World!") + } + + } +}