Swagger UI Launcher - A native command line tool built with Kotlin, Ktor and GraalVM
I created a small native command line tool to launch Swagger UI with provided OpenAPI specs. This tool is written in Kotlin with the following libraries:
- Ktor - Lightweight server to serve Swagger UI.
- Webjars - Provides Swagger UI
- Picocli - Command line parser
- GraalVM SDK - GraalVM build support
Ktor
Swagger UI and OpenAPI specs are served by Ktor.
Ktor has a server plugin for Webjars. All I need to do is to install this plugin. In the code below, Webjars plugin is installed. A route is created to redirect requests to Swagger UI page /webjars/swagger-ui/index.html
.
fun Application.configureRouting() {
install(Webjars) {
path = "/webjars"
}
routing {
get("/") {
call.respondRedirect("/webjars/swagger-ui/index.html")
}
}
}
To make Swagger UI shows the configured OpenAPI specs, I need to configure the urls
used by Swagger UI. This is done by rewriting the swagger-initializer.js
file in path /webjars/swagger-ui/swagger-initializer.js
.
I created a custom Ktor plugin RewriteSwaggerJsPlugin
to provide the content of the swagger-initializer.js
file. This plugin has its options declared in data class RewriteSwaggerJsPluginOptions
. The only supported option is a list of URLs of OpenAPI specs.
data class SwaggerUIUrl(
val url: String,
val name: String
)
data class RewriteSwaggerJsPluginOptions(
var urls: List<SwaggerUIUrl> = mutableListOf()
)
val RewriteSwaggerJsPlugin = createApplicationPlugin(name ="RewriteSwaggerJs", createConfiguration = ::RewriteSwaggerJsPluginOptions) {
val urls = pluginConfig.urls.joinToString(", ") {
"""{url: "${it.url}", name: "${it.name}"}"""
}
pluginConfig.apply {
onCallRespond { call ->
transformBody { body ->
if (call.request.uri == "/webjars/swagger-ui/swagger-initializer.js") {
"""
window.onload = function() {
window.ui = SwaggerUIBundle({
urls: [$urls],
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout"
});
};
"""
} else {
body
}
}
}
}
}
Two types of OpenAPI spec sources are supported.
- Local file
- URL
These two types of sources are declared as implementations of sealed interface ApiSpecSource
. LauncherOptions
is a data class with options parsed from command line arguments.
sealed interface ApiSpecSource {
fun name(): String
}
data class LocalFileSource(val file: File) : ApiSpecSource {
override fun name(): String {
return file.path
}
}
data class UrlSource(val url: String) : ApiSpecSource {
override fun name(): String {
return url
}
}
data class LauncherOptions(
val sources: List<ApiSpecSource>
)
The custom plugin RewriteSwaggerJsPlugin
is installed with options specified as LauncherOptions
. For different types of OpenAPI spec sources, a routing rule is created.
- For
LocalFileSource
, the file content is served. - For
UrlSource
, the server simply sends a redirect.
These OpenAPI spec sources are served with paths like /openapi/0
and /openapi/1
. These sources are passed to Swagger UI to configure the urls
of SwaggerUIBundle
.
fun Application.configureRouting(options: LauncherOptions) {
install(RewriteSwaggerJsPlugin) {
urls = options.sources.mapIndexed { index, source -> SwaggerUIUrl(
"/openapi/$index",
source.name()
) }
}
routing {
get("/openapi/{index}") {
val source = call.parameters["index"]?.toInt()?.run {
options.sources[this]
} ?: throw IllegalArgumentException("Invalid API spec")
when (source) {
is LocalFileSource -> call.respondFile(source.file)
is UrlSource -> call.respondRedirect(source.url)
}
}
}
}
Command line parser
I use picocli to parse command line arguments. Arguments are parsed into a LauncherOptions
object.
@CommandLine.Command(
name = "swagger-ui-launcher", mixinStandardHelpOptions = true, version = ["1.0.0"],
description = ["Launch Swagger UI"]
)
class Application : Callable<Void> {
@CommandLine.ArgGroup(exclusive = false, multiplicity = "1", heading = "Source of OpenAPI spec")
lateinit var apiSpecSources: ApiSpecSources
class ApiSpecSources {
@CommandLine.Option(names = ["-f", "--file"], description = ["Local file"])
lateinit var fileSources: List<String>
@CommandLine.Option(names = ["-u", "--url"], description = ["URL"])
lateinit var urlSources: List<String>
fun sources(): List<ApiSpecSource> {
return (if (this::fileSources.isInitialized) fileSources.map { LocalFileSource(Paths.get(it).toFile()) } else listOf()) +
(if (this::urlSources.isInitialized) urlSources.map { UrlSource(it) } else listOf())
}
}
private fun sources(): List<ApiSpecSource> {
return if (this::apiSpecSources.isInitialized) apiSpecSources.sources() else listOf()
}
override fun call(): Void? {
embeddedServer(CIO, port = 0, host = "localhost") {
configureRouting(
LauncherOptions(
sources = sources()
)
)
}.start(wait = true)
return null
}
}
fun main(args: Array<String>) {
CommandLine(Application()).execute(*args)
}
Now I can use command line option -f
to pass local files and -u
to pass URLs.
GraalVM support
Native binaries are created using GraalVM native-image
.
For Ktor, only CIO
engine is supported in native mode.
For Webjars, resources in webjars need to register explicitly. This is done by creating a GraalVM Feature
. org.webjars.WebJarAssetLocator
can scan the classpath to find all Webjars resources. I simply use the scan result and register found resources using ResourcesRegistry
of GraalVM. The scan result is also stored as WebJarInfoHolder.locator
.
@AutomaticFeature
class WebJarResourceFeature : Feature {
override fun beforeAnalysis(access: Feature.BeforeAnalysisAccess) {
WebJarInfoHolder.locator = OpenWebJarAssetLocator(WebJarAssetLocator())
val registry: ResourcesRegistry = ImageSingletons.lookup(ResourcesRegistry::class.java)
WebJarInfoHolder.locator.webJars().values.flatMap { it.contents }.forEach {
registry.addResources(ConfigurationCondition.alwaysTrue(), it)
}
}
}
Some classes used by WebJarAssetLocator
are package private. So I create a new class to open those classes.
I use a substitution to replace the scanForWebJars
method of WebJarAssetLocator
, otherwise the native image throws an exception. Since the scan of Webjars resources has been done during the native image generation, the scan result is used directly.
@TargetClass(WebJarAssetLocator::class)
class Target_org_webjars_WebJarAssetLocator {
@Substitute
private fun scanForWebJars(classGraph: ClassGraph): Map<String, WebJarInfo> {
return WebJarInfoHolder.locator.webJars().mapValues {
val (version, groupId, uri, contents) = it.value
WebJarInfo(
version, groupId, uri, contents
)
}
}
}
After these changes, the native image can be generated successfully.
Source code
The source code can be found on GitHub alexcheng1982/swagger-ui-launcher. Binaries can be downloaded from GitHub.