From ce6e008426c30cba493832d0866950c59f7c31c1 Mon Sep 17 00:00:00 2001 From: Linnea Gräf Date: Fri, 17 Jan 2025 12:50:23 +0100 Subject: feat(server): Add openapi docs --- .../moe/nea/ledger/server/core/api/OpenApiModel.kt | 98 +++++++++++++ .../kotlin/moe/nea/ledger/server/core/api/docs.kt | 156 +++++++++++++++++++++ 2 files changed, 254 insertions(+) create mode 100644 server/swagger/src/main/kotlin/moe/nea/ledger/server/core/api/OpenApiModel.kt create mode 100644 server/swagger/src/main/kotlin/moe/nea/ledger/server/core/api/docs.kt (limited to 'server/swagger/src/main/kotlin/moe/nea') 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, + val paths: Map, +) + +@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, + val summary: String, + val description: String, + val operationId: String, + val deprecated: Boolean, +// val parameters: List, + val responses: Map<@Serializable(HttpStatusCodeIntAsString::class) HttpStatusCode, OpenApiResponse> +) + +object HttpStatusCodeIntAsString : KSerializer { + 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 { + 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 schema() { + schema = jsonSchema() + } + + fun intoJson(): OpenApiResponse { + return OpenApiResponse( + "", + mapOf( + ContentType.Application.Json to OpenApiResponseContentType(schema) + ) + ) + } +} + +class DocumentationContext(val path: DocumentationPath) { + val responses = mutableMapOf() + 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 { + override val key: AttributeKey = 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() + 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) +} + -- cgit