aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--build-src/src/main/kotlin/helpers.kt7
-rw-r--r--server/core/build.gradle.kts6
-rw-r--r--server/core/src/main/kotlin/moe/nea/ledger/server/core/Application.kt19
-rw-r--r--server/core/src/main/kotlin/moe/nea/ledger/server/core/api/BaseApi.kt5
-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
-rw-r--r--settings.gradle.kts1
8 files changed, 303 insertions, 5 deletions
diff --git a/build-src/src/main/kotlin/helpers.kt b/build-src/src/main/kotlin/helpers.kt
index 5afef4f..48c230e 100644
--- a/build-src/src/main/kotlin/helpers.kt
+++ b/build-src/src/main/kotlin/helpers.kt
@@ -1,4 +1,5 @@
import org.gradle.api.plugins.ExtensionAware
+import org.gradle.kotlin.dsl.DependencyHandlerScope
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.findByType
@@ -8,3 +9,9 @@ inline fun <reified T : Any> ExtensionAware.configureIf(crossinline block: T.()
extensions.configure<T> { block() }
}
}
+
+val ktor_version = "3.0.3"
+
+fun DependencyHandlerScope.declareKtorVersion() {
+ "implementation"(platform("io.ktor:ktor-bom:$ktor_version"))
+}
diff --git a/server/core/build.gradle.kts b/server/core/build.gradle.kts
index 87f613a..deee068 100644
--- a/server/core/build.gradle.kts
+++ b/server/core/build.gradle.kts
@@ -4,17 +4,17 @@ plugins {
application
}
-val ktor_version = "3.0.3"
dependencies {
- implementation(platform("io.ktor:ktor-bom:$ktor_version"))
+ declareKtorVersion()
implementation("io.ktor:ktor-server-netty")
implementation("io.ktor:ktor-server-status-pages")
implementation("io.ktor:ktor-server-content-negotiation")
- implementation("io.ktor:ktor-server-openapi")
implementation("io.ktor:ktor-serialization-kotlinx-json")
implementation("io.ktor:ktor-server-compression")
+ implementation("sh.ondr:kotlin-json-schema:0.1.1")
implementation(project(":database:impl"))
+ implementation(project(":server:swagger"))
runtimeOnly("ch.qos.logback:logback-classic:1.5.16")
runtimeOnly("org.xerial:sqlite-jdbc:3.45.3.0")
diff --git a/server/core/src/main/kotlin/moe/nea/ledger/server/core/Application.kt b/server/core/src/main/kotlin/moe/nea/ledger/server/core/Application.kt
index 0ea6ed3..f00f26d 100644
--- a/server/core/src/main/kotlin/moe/nea/ledger/server/core/Application.kt
+++ b/server/core/src/main/kotlin/moe/nea/ledger/server/core/Application.kt
@@ -8,8 +8,12 @@ import io.ktor.server.plugins.compression.Compression
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
+import kotlinx.serialization.json.Json
import moe.nea.ledger.database.Database
+import moe.nea.ledger.server.core.api.Documentation
+import moe.nea.ledger.server.core.api.Info
import moe.nea.ledger.server.core.api.apiRouting
+import moe.nea.ledger.server.core.api.openApiDocsJson
import java.io.File
fun main(args: Array<String>) {
@@ -19,8 +23,18 @@ fun main(args: Array<String>) {
fun Application.module() {
install(Compression)
+ install(Documentation) {
+ info = Info(
+ "Ledger Analysis Server",
+ "Your local API for loading ledger data",
+ "TODO: buildconfig"
+ )
+ }
install(ContentNegotiation) {
- json()
+ json(Json {
+ this.explicitNulls = false
+ this.encodeDefaults = true
+ })
// cbor()
}
val database = Database(File(System.getProperty("ledger.databasefolder")))
@@ -29,6 +43,9 @@ fun Application.module() {
route("/api") {
this.apiRouting(database)
}
+ route("/api.json") {
+ openApiDocsJson()
+ }
}
}
diff --git a/server/core/src/main/kotlin/moe/nea/ledger/server/core/api/BaseApi.kt b/server/core/src/main/kotlin/moe/nea/ledger/server/core/api/BaseApi.kt
index 264f74b..ad05118 100644
--- a/server/core/src/main/kotlin/moe/nea/ledger/server/core/api/BaseApi.kt
+++ b/server/core/src/main/kotlin/moe/nea/ledger/server/core/api/BaseApi.kt
@@ -3,7 +3,6 @@ package moe.nea.ledger.server.core.api
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
-import io.ktor.server.routing.Routing
import io.ktor.server.routing.get
import moe.nea.ledger.database.DBLogEntry
import moe.nea.ledger.database.Database
@@ -21,5 +20,9 @@ fun Route.apiRouting(database: Database) {
Profile(it[DBLogEntry.playerId], it[DBLogEntry.profileId])
}
call.respond(profiles)
+ }.docs {
+ respondsOk {
+ schema<List<Profile>>()
+ }
}
}
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)
+}
+
diff --git a/settings.gradle.kts b/settings.gradle.kts
index d34446f..c730400 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -31,5 +31,6 @@ include("database:core")
include("database:impl")
include("basetypes")
include("mod")
+include("server:swagger")
include("server:core")
includeBuild("build-src")