aboutsummaryrefslogtreecommitdiff
path: root/server/swagger
diff options
context:
space:
mode:
Diffstat (limited to 'server/swagger')
-rw-r--r--server/swagger/build.gradle.kts16
-rw-r--r--server/swagger/src/main/kotlin/moe/nea/ledger/server/core/api/OpenApiModel.kt98
-rw-r--r--server/swagger/src/main/kotlin/moe/nea/ledger/server/core/api/docs.kt156
3 files changed, 270 insertions, 0 deletions
diff --git a/server/swagger/build.gradle.kts b/server/swagger/build.gradle.kts
new file mode 100644
index 0000000..9ea7cb2
--- /dev/null
+++ b/server/swagger/build.gradle.kts
@@ -0,0 +1,16 @@
+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")
+}
+
+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..d447df0
--- /dev/null
+++ b/server/swagger/src/main/kotlin/moe/nea/ledger/server/core/api/OpenApiModel.kt
@@ -0,0 +1,98 @@
+package moe.nea.ledger.server.core.api
+
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import kotlinx.serialization.KSerializer
+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 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<Parameter>,
+ val responses: Map<@Serializable(HttpStatusCodeIntAsString::class) HttpStatusCode, OpenApiResponse>
+)
+
+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..5680027
--- /dev/null
+++ b/server/swagger/src/main/kotlin/moe/nea/ledger/server/core/api/docs.kt
@@ -0,0 +1,156 @@
+package moe.nea.ledger.server.core.api
+
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.server.application.ApplicationCallPipeline
+import io.ktor.server.application.BaseApplicationPlugin
+import io.ktor.server.response.respond
+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.util.AttributeKey
+import sh.ondr.jsonschema.JsonSchema
+import sh.ondr.jsonschema.jsonSchema
+
+
+fun Route.openApiDocsJson() {
+ get {
+ val docs = plugin(Documentation)
+ val model = docs.finalizeJson()
+ call.respond(model)
+ }
+}
+
+class DocumentationPath(val path: String) {
+
+ companion object {
+ fun createDocumentationPath(baseRoute: Route?, route: Route): DocumentationPath {
+ return DocumentationPath(createRoutePath(baseRoute, route as RoutingNode))
+ }
+
+ 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 "/"
+ }
+ var parentPath = createRoutePath(baseRoute, parent)
+ if (!parentPath.endsWith("/"))
+ parentPath += "/"
+ return when (val selector = route.selector) {
+ is TrailingSlashRouteSelector -> parentPath
+ is RootRouteSelector -> parentPath
+ is PathSegmentConstantRouteSelector -> parentPath + selector.value
+ is HttpMethodRouteSelector -> parentPath // TODO: generate a separate path here
+ else -> error("Could not comprehend $selector (${selector.javaClass})")
+ }
+ }
+ }
+}
+
+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)
+ )
+ )
+ }
+}
+
+class DocumentationContext(val path: DocumentationPath) {
+ 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 = ""
+ fun intoJson(): OpenApiRoute {
+ return OpenApiRoute(
+ summary,
+ description,
+ get = OpenApiOperation(
+ tags = listOf(), // TODO: tags
+ summary = "",
+ description = "",
+ operationId = "",
+ deprecated = false,
+ responses = responses.mapValues {
+ it.value.intoJson()
+ }
+ ),
+ post = null, patch = null, delete = null, // TODO: generate separate contexts for those
+ )
+ }
+}
+
+
+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)
+ val plugin = Documentation(config)
+ return plugin
+ }
+ }
+
+ val info = config.info
+ var root: RoutingNode? = null
+ private val documentationNodes = mutableMapOf<DocumentationPath, DocumentationContext>()
+ fun createDocumentationNode(path: DocumentationPath) =
+ documentationNodes.getOrPut(path) { DocumentationContext(path) }
+
+ private val openApiJson by lazy {
+ OpenApiModel(
+ info = info,
+ // TODO: generate server list better
+ servers = listOf(Server("http://localhost:8080", "Local Server")),
+ paths = documentationNodes.map {
+ OpenApiPath(it.key.path) to it.value.intoJson()
+ }.toMap()
+ )
+ }
+
+ fun finalizeJson(): OpenApiModel {
+ return openApiJson
+ }
+
+ class Configuration {
+ var info: Info = Info(
+ title = "Example API Docs",
+ description = "Missing description",
+ version = "0.0.0"
+ )
+ }
+}
+
+fun Route.docs(block: DocumentationContext.() -> Unit) {
+ val documentation = plugin(Documentation)
+ val documentationPath = DocumentationPath.createDocumentationPath(documentation.root, this)
+ val node = documentation.createDocumentationNode(documentationPath)
+ block(node)
+}
+