Swagger UI Launcher - A native command line tool built with Kotlin, Ktor and GraalVM

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.

© 2022 VividCode