diff options
-rw-r--r-- | build-src/src/main/kotlin/helpers.kt | 7 | ||||
-rw-r--r-- | server/core/build.gradle.kts | 6 | ||||
-rw-r--r-- | server/core/src/main/kotlin/moe/nea/ledger/server/core/Application.kt | 19 | ||||
-rw-r--r-- | server/core/src/main/kotlin/moe/nea/ledger/server/core/api/BaseApi.kt | 5 | ||||
-rw-r--r-- | server/swagger/build.gradle.kts | 16 | ||||
-rw-r--r-- | server/swagger/src/main/kotlin/moe/nea/ledger/server/core/api/OpenApiModel.kt | 98 | ||||
-rw-r--r-- | server/swagger/src/main/kotlin/moe/nea/ledger/server/core/api/docs.kt | 156 | ||||
-rw-r--r-- | settings.gradle.kts | 1 |
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") |