aboutsummaryrefslogtreecommitdiff
path: root/server/swagger
diff options
context:
space:
mode:
Diffstat (limited to 'server/swagger')
-rw-r--r--server/swagger/build.gradle.kts17
-rw-r--r--server/swagger/src/main/kotlin/moe/nea/ledger/server/core/api/OpenApiModel.kt116
-rw-r--r--server/swagger/src/main/kotlin/moe/nea/ledger/server/core/api/docs.kt323
3 files changed, 456 insertions, 0 deletions
diff --git a/server/swagger/build.gradle.kts b/server/swagger/build.gradle.kts
new file mode 100644
index 0000000..76e5f78
--- /dev/null
+++ b/server/swagger/build.gradle.kts
@@ -0,0 +1,17 @@
+plugins {
+ `java-library`
+ kotlin("jvm")
+ kotlin("plugin.serialization")
+}
+
+
+dependencies {
+ declareKtorVersion()
+ api("io.ktor:ktor-server-core")
+ api("sh.ondr:kotlin-json-schema:0.1.1")
+ implementation("org.webjars:swagger-ui:5.18.2")
+}
+
+java {
+ toolchain.languageVersion.set(JavaLanguageVersion.of(21))
+}
diff --git a/server/swagger/src/main/kotlin/moe/nea/ledger/server/core/api/OpenApiModel.kt b/server/swagger/src/main/kotlin/moe/nea/ledger/server/core/api/OpenApiModel.kt
new file mode 100644
index 0000000..8392c5c
--- /dev/null
+++ b/server/swagger/src/main/kotlin/moe/nea/ledger/server/core/api/OpenApiModel.kt
@@ -0,0 +1,116 @@
+package moe.nea.ledger.server.core.api
+
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import sh.ondr.jsonschema.JsonSchema
+
+@Serializable
+data class OpenApiModel(
+ val openapi: String = "3.0.0",
+ val info: Info,
+ val servers: List<Server>,
+ val paths: Map<OpenApiPath, OpenApiRoute>,
+)
+
+@Serializable // TODO: custom serializer
+@JvmInline
+value class OpenApiPath(val name: String)
+
+@Serializable
+data class OpenApiRoute(
+ val summary: String,
+ val description: String,
+ val get: OpenApiOperation?,
+ val put: OpenApiOperation?,
+ val patch: OpenApiOperation?,
+ val post: OpenApiOperation?,
+ val delete: OpenApiOperation?,
+)
+
+@Serializable
+data class OpenApiOperation(
+ val tags: List<Tag>,
+ val summary: String,
+ val description: String,
+ val operationId: String,
+ val deprecated: Boolean,
+ val parameters: List<OpenApiParameter>,
+ val responses: Map<@Serializable(HttpStatusCodeIntAsString::class) HttpStatusCode, OpenApiResponse>
+)
+
+@Serializable
+data class OpenApiParameter(
+ @SerialName("in") val location: ParameterLocation,
+ val name: String,
+ val description: String,
+ val schema: JsonSchema?,
+)
+
+@Serializable
+enum class ParameterLocation {
+ @SerialName("query")
+ QUERY,
+ @SerialName("path")
+ PATH,
+}
+
+object HttpStatusCodeIntAsString : KSerializer<HttpStatusCode> {
+ override val descriptor: SerialDescriptor =
+ PrimitiveSerialDescriptor("HttpStatusCodeIntAsString", PrimitiveKind.STRING)
+
+ override fun deserialize(decoder: Decoder): HttpStatusCode {
+ return HttpStatusCode.fromValue(decoder.decodeString().toInt())
+ }
+
+ override fun serialize(encoder: Encoder, value: HttpStatusCode) {
+ encoder.encodeString(value.value.toString())
+ }
+}
+
+object ContentTypeSerializer : KSerializer<ContentType> {
+ override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ContentTypeSerializer", PrimitiveKind.STRING)
+
+ override fun deserialize(decoder: Decoder): ContentType {
+ return ContentType.parse(decoder.decodeString())
+ }
+
+ override fun serialize(encoder: Encoder, value: ContentType) {
+ encoder.encodeString(value.contentType + "/" + value.contentSubtype)
+ }
+}
+
+@Serializable
+data class OpenApiResponse(
+ val description: String,
+ val content: Map<@Serializable(ContentTypeSerializer::class) ContentType, OpenApiResponseContentType>
+)
+
+@Serializable
+data class OpenApiResponseContentType(
+ val schema: JsonSchema?
+)
+
+@Serializable
+@JvmInline
+value class Tag(val name: String)
+
+@Serializable
+data class Info(
+ val title: String,
+ val description: String,
+ val version: String,
+)
+
+@Serializable
+data class Server(
+ val url: String,
+ val description: String,
+)
diff --git a/server/swagger/src/main/kotlin/moe/nea/ledger/server/core/api/docs.kt b/server/swagger/src/main/kotlin/moe/nea/ledger/server/core/api/docs.kt
new file mode 100644
index 0000000..c1b550d
--- /dev/null
+++ b/server/swagger/src/main/kotlin/moe/nea/ledger/server/core/api/docs.kt
@@ -0,0 +1,323 @@
+package moe.nea.ledger.server.core.api
+
+import io.ktor.http.ContentType
+import io.ktor.http.HttpMethod
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.content.OutgoingContent
+import io.ktor.http.defaultForFilePath
+import io.ktor.server.application.ApplicationCallPipeline
+import io.ktor.server.application.BaseApplicationPlugin
+import io.ktor.server.application.host
+import io.ktor.server.application.port
+import io.ktor.server.response.respond
+import io.ktor.server.response.respondText
+import io.ktor.server.routing.HttpMethodRouteSelector
+import io.ktor.server.routing.PathSegmentConstantRouteSelector
+import io.ktor.server.routing.RootRouteSelector
+import io.ktor.server.routing.Route
+import io.ktor.server.routing.RoutingNode
+import io.ktor.server.routing.TrailingSlashRouteSelector
+import io.ktor.server.routing.get
+import io.ktor.server.routing.route
+import io.ktor.util.AttributeKey
+import io.ktor.util.cio.KtorDefaultPool
+import io.ktor.utils.io.ByteReadChannel
+import io.ktor.utils.io.jvm.javaio.toByteReadChannel
+import kotlinx.serialization.json.JsonPrimitive
+import sh.ondr.jsonschema.JsonSchema
+import sh.ondr.jsonschema.jsonSchema
+import java.io.File
+import java.io.InputStream
+
+
+fun Route.openApiDocsJson() {
+ get {
+ val docs = plugin(Documentation)
+ val model = docs.finalizeJson()
+ call.respond(model)
+ }
+}
+
+fun Route.openApiUi(apiJsonUrl: String) {
+ get("swagger-initializer.js") {
+ call.respondText(
+ //language=JavaScript
+ """
+ window.onload = function() {
+ //<editor-fold desc="Changeable Configuration Block">
+
+ // the following lines will be replaced by docker/configurator, when it runs in a docker-container
+ window.ui = SwaggerUIBundle({
+ url: ${JsonPrimitive(apiJsonUrl)},
+ dom_id: '#swagger-ui',
+ deepLinking: true,
+ presets: [
+ SwaggerUIBundle.presets.apis,
+ SwaggerUIStandalonePreset
+ ],
+ plugins: [
+ SwaggerUIBundle.plugins.DownloadUrl
+ ],
+ layout: "StandaloneLayout"
+ });
+
+ //</editor-fold>
+ };
+ """.trimIndent())
+ }
+// val swaggerUiProperties =
+// environment.classLoader.getResource("/META-INF/maven/org.webjars/swagger-ui/pom.properties")
+// ?: error("Could not find swagger webjar")
+// val swaggerUiZip = swaggerUiProperties.toString().substringBefore("!")
+ val pathParameterName = "static-content-path-parameter"
+ route("{$pathParameterName...}") {
+ get {
+ var requestedPath = call.parameters.getAll(pathParameterName)?.joinToString(File.separator) ?: ""
+ requestedPath = requestedPath.replace("\\", "/")
+ if (requestedPath.isEmpty()) requestedPath = "index.html"
+ if (requestedPath.contains("..")) {
+ call.respondText("Forbidden", status = HttpStatusCode.Forbidden)
+ return@get
+ }
+ //TODO: I mean i should read out the version properties but idc
+ val version = "5.18.2"
+ val resource =
+ environment.classLoader.getResourceAsStream("META-INF/resources/webjars/swagger-ui/$version/$requestedPath")
+
+ if (resource == null) {
+ call.respondText("Not Found", status = HttpStatusCode.NotFound)
+ return@get
+ }
+
+ call.respond(InputStreamContent(resource, ContentType.defaultForFilePath(requestedPath)))
+ }
+ }
+}
+
+internal class InputStreamContent(
+ private val input: InputStream,
+ override val contentType: ContentType
+) : OutgoingContent.ReadChannelContent() {
+
+ override fun readFrom(): ByteReadChannel = input.toByteReadChannel(pool = KtorDefaultPool)
+}
+
+class DocumentationPath(val path: String)
+
+class DocumentationEndpoint private constructor() {
+ var method: HttpMethod = HttpMethod.Get
+ private set
+ lateinit var path: DocumentationPath
+ private set
+
+ private fun initFromPath(
+ baseRoute: Route?,
+ route: RoutingNode,
+ ) {
+ path = DocumentationPath(createRoutePath(baseRoute, route))
+ }
+
+ private fun createRoutePath(
+ baseRoute: Route?,
+ route: RoutingNode,
+ ): String {
+ if (baseRoute == route)
+ return "/"
+ val parent = route.parent
+ if (parent == null) {
+ if (baseRoute != null)
+ error("Could not find $route in $baseRoute")
+ return "/"
+ }
+ val parentPath = createRoutePath(baseRoute, parent)
+ var parentPathAppendable = parentPath
+ if (!parentPathAppendable.endsWith("/"))
+ parentPathAppendable += "/"
+ return when (val selector = route.selector) {
+ is TrailingSlashRouteSelector -> parentPathAppendable
+ is RootRouteSelector -> parentPath
+ is PathSegmentConstantRouteSelector -> parentPathAppendable + selector.value
+ is HttpMethodRouteSelector -> {
+ method = selector.method
+ parentPath
+ }
+
+ else -> error("Could not comprehend $selector (${selector.javaClass})")
+ }
+ }
+
+ companion object {
+ fun createDocumentationPath(baseRoute: Route?, route: Route): DocumentationEndpoint {
+ val path = DocumentationEndpoint()
+ path.initFromPath(baseRoute, route as RoutingNode)
+ return path
+ }
+ }
+}
+
+class Response {
+ var schema: JsonSchema? = null
+ inline fun <reified T : Any> schema() {
+ schema = jsonSchema<T>()
+ }
+
+ fun intoJson(): OpenApiResponse {
+ return OpenApiResponse(
+ "",
+ mapOf(
+ ContentType.Application.Json to OpenApiResponseContentType(schema)
+ )
+ )
+ }
+}
+
+interface IntoTag {
+ fun intoTag(): String
+}
+
+class DocumentationOperationContext(val route: DocumentationContext) {
+ val responses = mutableMapOf<HttpStatusCode, Response>()
+ fun responds(statusCode: HttpStatusCode, block: Response.() -> Unit) {
+ responses.getOrPut(statusCode) { Response() }.also(block)
+ }
+
+ fun respondsOk(block: Response.() -> Unit) {
+ responds(HttpStatusCode.OK, block)
+ }
+
+ var summary: String = ""
+ var description: String = ""
+ var deprecated: Boolean = false
+ var operationId: String = ""
+ val tags: MutableList<String> = mutableListOf()
+ val parameters: MutableList<OpenApiParameter> = mutableListOf()
+ fun tag(vararg tag: String) {
+ tags.addAll(tag)
+ }
+
+ fun tag(vararg tag: IntoTag) {
+ tag.mapTo(tags) { it.intoTag() }
+ }
+
+ inline fun <reified T : Any> queryParameter(name: String, description: String = "") {
+ parameter(ParameterLocation.QUERY, name, description, jsonSchema<T>())
+ }
+
+ fun parameter(
+ location: ParameterLocation, name: String,
+ description: String = "", schema: JsonSchema? = null
+ ) {
+ parameters.add(OpenApiParameter(
+ location, name, description,
+ schema
+ ))
+ }
+
+ fun intoJson(): OpenApiOperation {
+ return OpenApiOperation(
+ tags = tags.map { Tag(it) },
+ summary = summary,
+ description = description,
+ operationId = operationId,
+ deprecated = deprecated,
+ parameters = parameters,
+ responses = responses.mapValues {
+ it.value.intoJson()
+ }
+ )
+
+ }
+}
+
+class DocumentationContext(val path: DocumentationPath) {
+ val ops: MutableMap<HttpMethod, DocumentationOperationContext> = mutableMapOf()
+ var summary: String = ""
+ var description = ""
+ fun intoJson(): OpenApiRoute {
+ return OpenApiRoute(
+ summary,
+ description,
+ get = ops[HttpMethod.Get]?.intoJson(),
+ put = ops[HttpMethod.Put]?.intoJson(),
+ post = ops[HttpMethod.Post]?.intoJson(),
+ patch = ops[HttpMethod.Patch]?.intoJson(),
+ delete = ops[HttpMethod.Delete]?.intoJson(),
+ )
+ }
+
+ fun createOperationNode(method: HttpMethod): DocumentationOperationContext {
+ return ops.getOrPut(method) { DocumentationOperationContext(this) }
+ }
+}
+
+
+class Documentation(config: Configuration) {
+ companion object Plugin : BaseApplicationPlugin<ApplicationCallPipeline, Configuration, Documentation> {
+ override val key: AttributeKey<Documentation> = AttributeKey("LedgerDocumentation")
+
+ override fun install(pipeline: ApplicationCallPipeline, configure: Configuration.() -> Unit): Documentation {
+ val config = Configuration().also(configure)
+ if (config.servers.isEmpty()) {
+ config.servers.add(Server(
+ "http://${pipeline.environment.config.host}:${pipeline.environment.config.port}",
+ "Server",
+ ))
+ }
+ val plugin = Documentation(config)
+ return plugin
+ }
+ }
+
+ val info = config.info
+ var root: RoutingNode? = null
+ private set
+ val servers: List<Server> = config.servers
+
+ private val documentationNodes = mutableMapOf<DocumentationPath, DocumentationContext>()
+ fun createDocumentationNode(endpoint: DocumentationEndpoint) =
+ documentationNodes.getOrPut(endpoint.path) { DocumentationContext(endpoint.path) }
+ .createOperationNode(endpoint.method)
+
+ private val openApiJson by lazy {
+ OpenApiModel(
+ info = info,
+ servers = servers,
+ paths = documentationNodes.map {
+ OpenApiPath(it.key.path) to it.value.intoJson()
+ }.toMap()
+ )
+ }
+
+ fun finalizeJson(): OpenApiModel {
+ return openApiJson
+ }
+
+ fun setRootNode(routingNode: RoutingNode) {
+ require(documentationNodes.isEmpty()) { "Cannot set API root node after routes have been documented: ${documentationNodes.keys}" }
+ this.root = routingNode
+ }
+
+ class Configuration {
+ var info: Info = Info(
+ title = "Example API Docs",
+ description = "Missing description",
+ version = "0.0.0"
+ )
+ val servers: MutableList<Server> = mutableListOf()
+ }
+}
+
+fun Route.docs(block: DocumentationOperationContext.() -> Unit) {
+ val documentation = plugin(Documentation)
+ val documentationPath = DocumentationEndpoint.createDocumentationPath(documentation.root, this)
+ val node = documentation.createDocumentationNode(documentationPath)
+ block(node)
+}
+
+/**
+ * Mark this current routing node as API route. Note that this will not apply retroactively and all api requests must be declared relative to this one.
+ */
+fun Route.setApiRoot() {
+ plugin(Documentation).setRootNode(this as RoutingNode)
+}
+