diff --git a/.gitignore b/.gitignore index 1a5ff25..c583502 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ /*.iml /kotlin-js-store/ /.kotlin/ +/docs/ diff --git a/build.gradle.kts b/build.gradle.kts index 1bfb3fe..c1ceec3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -80,6 +80,7 @@ kotlin { dependencies { implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.html) + implementation(libs.ktor.openapi.schema) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0e13838..db436cc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,14 +22,20 @@ ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty", version.ref = "ktor" } ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } +ktor-server-cors = { group = "io.ktor", name = "ktor-server-cors", version.ref = "ktor" } ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" } ktor-server-caching-headers = { group = "io.ktor", name = "ktor-server-caching-headers", version.ref = "ktor" } ktor-server-compression = { group = "io.ktor", name = "ktor-server-compression", version.ref = "ktor" } +ktor-server-openapi = { group = "io.ktor", name = "ktor-server-openapi", version.ref = "ktor" } +ktor-server-routing-openapi = { group = "io.ktor", name = "ktor-server-routing-openapi", version.ref = "ktor" } +ktor-server-swagger = { group = "io.ktor", name = "ktor-server-swagger", version.ref = "ktor" } ktor-network = { group = "io.ktor", name = "ktor-network", version.ref = "ktor" } +ktor-openapi-schema = { group = "io.ktor", name = "ktor-openapi-schema", version.ref = "ktor" } +ktor-redoc = { group = "io.github.smiley4", name = "ktor-redoc", version = "5.4.0" } logback-classic = { group = "ch.qos.logback", name = "logback-classic", version = "1.5.28" } zKtlint = { group = "com.pinterest.ktlint", name = "ktlint-cli", version.ref = "ktlint"} [bundles] -ktor-server = [ "ktor-server-core", "ktor-server-netty", "ktor-server-websockets", "ktor-server-caching-headers", "ktor-server-compression" ] +ktor-server = [ "ktor-server-core", "ktor-server-netty", "ktor-server-cors", "ktor-server-websockets", "ktor-server-caching-headers", "ktor-server-compression", "ktor-server-openapi", "ktor-server-routing-openapi", "ktor-server-swagger", "ktor-redoc" ] ktor-client = [ "ktor-client-core", "ktor-client-okhttp" ] diff --git a/readme.md b/readme.md index 0bdb726..65388c6 100644 --- a/readme.md +++ b/readme.md @@ -16,9 +16,11 @@ The result can easily be saved back again, allowing for a smooth editing experie For more information, see [the wiki](https://github.com/KyoriPowered/adventure-webui/wiki/Editor-API). +For your convenience, we host the API documentation in [redoc](https://webui.advntr.dev/api/redoc), [swagger](https://webui.advntr.dev/api/swagger) or [raw openapi json](https://webui.advntr.dev/api/docs.json) formats. + ### Deployment -To run the server, type `./gradlew run -PisDevelopment`. +To run the server, type `./gradlew jvmRun -PisDevelopment`. This will create a server running at `http://localhost:8080`. For production usage, simply remove the development flag from the run task. diff --git a/src/commonMain/kotlin/net/kyori/adventure/webui/BuildInfo.kt b/src/commonMain/kotlin/net/kyori/adventure/webui/BuildInfo.kt index 38e2ec0..f683619 100644 --- a/src/commonMain/kotlin/net/kyori/adventure/webui/BuildInfo.kt +++ b/src/commonMain/kotlin/net/kyori/adventure/webui/BuildInfo.kt @@ -1,11 +1,22 @@ package net.kyori.adventure.webui +import io.ktor.openapi.JsonSchema import kotlinx.serialization.Serializable +@JsonSchema.Title("BuildInfo") +@JsonSchema.Description("Information about the build of the server") @Serializable public data class BuildInfo( + @JsonSchema.Description("The time the server started") + @JsonSchema.Example("\"2021-08-22T19:20:00Z\"") public val startedAt: String, + @JsonSchema.Description("The version of Adventure used by the server") + @JsonSchema.Example("4.10.0-SNAPSHOT") public val version: String, + @JsonSchema.Description("The commit hash of code in use by the server") + @JsonSchema.Example("9f43339123b0ad37cfe210b6562e39b9a3ccf7c7") public val commit: String, + @JsonSchema.Description("The URL of the Bytebin instance used by the server") + @JsonSchema.Example("\"https://bytebin.lucko.me\"") public val bytebinInstance: String ) diff --git a/src/commonMain/kotlin/net/kyori/adventure/webui/websocket/Packet.kt b/src/commonMain/kotlin/net/kyori/adventure/webui/websocket/Packet.kt index 6ffabc6..c89ef45 100644 --- a/src/commonMain/kotlin/net/kyori/adventure/webui/websocket/Packet.kt +++ b/src/commonMain/kotlin/net/kyori/adventure/webui/websocket/Packet.kt @@ -1,5 +1,6 @@ package net.kyori.adventure.webui.websocket +import io.ktor.openapi.JsonSchema import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject @@ -16,21 +17,40 @@ public data class Call( @Serializable @SerialName("placeholders") +@JsonSchema.Description("Placeholders to use when rendering the message.") public data class Placeholders( + @JsonSchema.Description("String placeholders to replace in the message.") + @JsonSchema.Example("{\"test\": \"TEST\"}") public val stringPlaceholders: Map? = null, + @JsonSchema.Example("{}") public val componentPlaceholders: Map? = null ) : Packet @Serializable +@JsonSchema.Title("Combined") +@JsonSchema.Description("Multi purpose request object") public data class Combined( + @JsonSchema.Description("MiniMessage String") + @JsonSchema.Example("\"Hello, world!\"") public val miniMessage: String? = null, + @JsonSchema.Description("Placeholders") public val placeholders: Placeholders? = null, + @JsonSchema.Description("The background to render the message on.") + @JsonSchema.Example("stone") public val background: String? = null, + @JsonSchema.Description("The mode to render the message in.") + @JsonSchema.Example("chat_open") public val mode: String? = null ) @Serializable +@JsonSchema.Title("InGamePreview") +@JsonSchema.Description("Request to preview a message in-game.") public data class InGamePreview( + @JsonSchema.Description("The message to render the message in-game.") + @JsonSchema.Example("\"Hello, world!\"") public val miniMessage: String? = null, + @JsonSchema.Description("A random key to use as the hostname") + @JsonSchema.Example("abc123") public val key: String? = null ) diff --git a/src/commonMain/kotlin/net/kyori/adventure/webui/websocket/Response.kt b/src/commonMain/kotlin/net/kyori/adventure/webui/websocket/Response.kt index 3ffad75..2f69df3 100644 --- a/src/commonMain/kotlin/net/kyori/adventure/webui/websocket/Response.kt +++ b/src/commonMain/kotlin/net/kyori/adventure/webui/websocket/Response.kt @@ -1,17 +1,25 @@ package net.kyori.adventure.webui.websocket +import io.ktor.openapi.JsonSchema import kotlinx.serialization.Serializable /** The server -> client response. */ -@Serializable public data class Response(public val parseResult: ParseResult? = null) +@Serializable +@JsonSchema.Title("Response") +@JsonSchema.Description("A parse response.") +public data class Response(public val parseResult: ParseResult? = null) /** The result of a parse. */ @Serializable +@JsonSchema.Title("ParseResult") public data class ParseResult( /** If the parse was a success. */ + @JsonSchema.Description("If the parse was a success.") public val success: Boolean, /** The result of the conversion, only if it was a [success]. */ + @JsonSchema.Description("The result of the conversion, only if it was a success.") public val dom: String? = null, /** The error message, if it wasn't a [success]. */ + @JsonSchema.Description("The error message, if it wasn't a success.") public val errorMessage: String? = null ) diff --git a/src/commonMain/resources/web/index.html b/src/commonMain/resources/web/index.html index 29af8cf..748a97a 100644 --- a/src/commonMain/resources/web/index.html +++ b/src/commonMain/resources/web/index.html @@ -326,6 +326,22 @@

How do I preview my MiniMessage text in-game?

+
+

Is MiniMessage Viewer free to use?

+
+
+ Yes, MiniMessage Viewer is completely free to use. You can access it online at https://webui.advntr.dev/ without any cost. +
+
+
+
+

Is there an api available for MiniMessage Viewer?

+
+
+ Yes! You can check out the api documentation using redoc, swagger or directly access the OpenAPI spec in JSON format here. +
+
+
diff --git a/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/Application.kt b/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/Application.kt index b3d1d97..3146099 100644 --- a/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/Application.kt +++ b/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/Application.kt @@ -1,8 +1,12 @@ package net.kyori.adventure.webui.jvm +import io.github.smiley4.ktorredoc.redoc import io.ktor.http.CacheControl import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders import io.ktor.http.content.CachingOptions +import io.ktor.openapi.OpenApiDoc +import io.ktor.openapi.OpenApiInfo import io.ktor.server.application.Application import io.ktor.server.application.install import io.ktor.server.application.log @@ -10,12 +14,23 @@ import io.ktor.server.plugins.cachingheaders.CachingHeaders import io.ktor.server.plugins.compression.Compression import io.ktor.server.plugins.compression.deflate import io.ktor.server.plugins.compression.gzip +import io.ktor.server.plugins.cors.routing.CORS +import io.ktor.server.plugins.openapi.openAPI +import io.ktor.server.plugins.swagger.swaggerUI +import io.ktor.server.response.respond +import io.ktor.server.response.respondText +import io.ktor.server.routing.get +import io.ktor.server.routing.openapi.OpenApiDocSource +import io.ktor.server.routing.openapi.OperationDescribeAttributeKey +import io.ktor.server.routing.openapi.plus +import io.ktor.server.routing.route import io.ktor.server.routing.routing +import io.ktor.server.routing.routingRoot import io.ktor.server.websocket.WebSockets import io.ktor.server.websocket.pingPeriod import io.ktor.server.websocket.timeout import io.ktor.websocket.WebSocketDeflateExtension -import kotlin.time.Duration +import kotlinx.serialization.json.Json import kotlin.time.Duration.Companion.seconds public fun Application.main() { @@ -43,11 +58,44 @@ public fun Application.main() { extensions { install(WebSocketDeflateExtension) } } + install(CORS) { + anyHost() + allowHeader(HttpHeaders.ContentType) + } + routing { // enable trace routing if in dev mode if (developmentMode) { trace { route -> this@main.log.debug(route.buildText()) } } + + // api docs + val oaInfo = OpenApiInfo( + "Adventure web API", + "Adventure ${getConfigString("miniMessageVersion")}", + description = "OpenAPI documentation for the Adventure web API.", + contact = OpenApiInfo.Contact("PaperMC Discord", "https://discord.gg/papermc", ""), + license = OpenApiInfo.License("The MIT License", "https://github.com/PaperMC/adventure-webui/blob/main/license.txt", "MIT"), + ) + val oaSource = OpenApiDocSource.Routing { + routingRoot.descendants().filterNot { it.attributes.getOrNull(OperationDescribeAttributeKey).isNullOrEmpty() } + } + get("/api/docs.json") { + call.respondText( + Json.encodeToString(OpenApiDoc(info = oaInfo) + oaSource.routes(this@main)), + ContentType.Application.Json, + ) + } + route("/api/redoc") { + redoc("/api/docs.json") { + pageTitle = "Adventure web API docs" + } + } + swaggerUI("/api/swagger") { + info = oaInfo + source = oaSource + faviconLocation = "/favicon-32x32.png" + } } } diff --git a/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/MiniMessage.kt b/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/MiniMessage.kt index f351d77..97ae9d6 100644 --- a/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/MiniMessage.kt +++ b/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/MiniMessage.kt @@ -1,23 +1,25 @@ +@file:OptIn(ExperimentalKtorApi::class) + package net.kyori.adventure.webui.jvm.minimessage +import io.ktor.http.ContentType.Text import io.ktor.http.HttpStatusCode +import io.ktor.openapi.ExampleObject +import io.ktor.openapi.GenericElement +import io.ktor.openapi.jsonSchema import io.ktor.server.application.Application -import io.ktor.server.http.content.defaultResource -import io.ktor.server.http.content.resource -import io.ktor.server.http.content.resources -import io.ktor.server.http.content.static -import io.ktor.server.http.content.staticFiles import io.ktor.server.http.content.staticResources import io.ktor.server.request.receiveText import io.ktor.server.response.respondText import io.ktor.server.routing.get +import io.ktor.server.routing.openapi.describe import io.ktor.server.routing.post import io.ktor.server.routing.route import io.ktor.server.routing.routing import io.ktor.server.websocket.webSocket +import io.ktor.utils.io.ExperimentalKtorApi import io.ktor.websocket.Frame import io.ktor.websocket.readText -import kotlinx.serialization.encodeToString import net.kyori.adventure.text.minimessage.MiniMessage import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver @@ -41,6 +43,7 @@ import net.kyori.adventure.webui.jvm.minimessage.hook.FONT_RENDER_HOOK import net.kyori.adventure.webui.jvm.minimessage.hook.HOVER_EVENT_RENDER_HOOK import net.kyori.adventure.webui.jvm.minimessage.hook.HookManager import net.kyori.adventure.webui.jvm.minimessage.hook.INSERTION_RENDER_HOOK +import net.kyori.adventure.webui.jvm.minimessage.hook.SHADOW_COLOR_RENDER_HOOK import net.kyori.adventure.webui.jvm.minimessage.hook.TEXT_COLOR_RENDER_HOOK import net.kyori.adventure.webui.jvm.minimessage.hook.TEXT_DECORATION_RENDER_HOOK import net.kyori.adventure.webui.jvm.minimessage.hook.TEXT_RENDER_HOOK @@ -55,8 +58,6 @@ import net.kyori.adventure.webui.websocket.ParseResult import net.kyori.adventure.webui.websocket.Placeholders import net.kyori.adventure.webui.websocket.Response import java.time.Instant -import net.kyori.adventure.webui.jvm.minimessage.hook.SHADOW_COLOR_RENDER_HOOK -import java.io.File private val startedAt = Instant.now() @@ -108,6 +109,45 @@ public fun Application.miniMessage() { // set up other routing route(URL_API) { + + fun miniMessageToHtml( + isolateNewlines: Boolean, + miniMessage: String, + tagResolver: TagResolver = TagResolver.empty() + ): Response { + val response = + try { + val result = StringBuilder() + + if (isolateNewlines) { + miniMessage + .split("\n") + .map { line -> HookManager.render(line) } + .map { line -> + MiniMessage.miniMessage() + .deserialize(line, tagResolver) + } + .map { component -> HookManager.render(component) } + .forEach { component -> + result.appendComponent(component) + result.append("\n") + } + } else { + val component = MiniMessage.miniMessage().deserialize(HookManager.render(miniMessage), tagResolver) + result.appendComponent(HookManager.render(component)) + } + + Response(ParseResult(true, result.toString())) + } catch (e: Exception) { + Response( + ParseResult( + false, errorMessage = e.message ?: "Unknown error!" + ) + ) + } + return response + } + webSocket(URL_MINI_TO_HTML) { var tagResolver = TagResolver.empty() var miniMessage: String? = null @@ -125,45 +165,39 @@ public fun Application.miniMessage() { } if (miniMessage == null) continue - val response = - try { - val result = StringBuilder() - - if (isolateNewlines) { - miniMessage - .split("\n") - .map { line -> HookManager.render(line) } - .map { line -> - MiniMessage.miniMessage() - .deserialize(line, tagResolver) - } - .map { component -> HookManager.render(component) } - .forEach { component -> - result.appendComponent(component) - result.append("\n") - } - } else { - val component = MiniMessage.miniMessage().deserialize(HookManager.render(miniMessage), tagResolver) - result.appendComponent(HookManager.render(component)) - } - - Response(ParseResult(true, result.toString())) - } catch (e: Exception) { - Response( - ParseResult( - false, errorMessage = e.message ?: "Unknown error!" - ) - ) - } + val response = miniMessageToHtml(isolateNewlines, miniMessage, tagResolver) outgoing.send(Frame.Text(Serializers.json.encodeToString(response))) } } } + post(URL_MINI_TO_HTML) { + val structure = Serializers.json.tryDecodeFromString(call.receiveText()) + val input = structure?.miniMessage ?: return@post call.response.status(HttpStatusCode.BadRequest) + val response = miniMessageToHtml(false, input) + call.respondText(Serializers.json.encodeToString(response)) + }.describe { + operationId = "miniMessageToHtml" + summary = "Convert MiniMessage to HTML" + description = "Converts a MiniMessage string to an HTML representation." + requestBody { + schema = jsonSchema() + } + responses { + HttpStatusCode.OK { + description = "The HTML representation of the MiniMessage text, along with success status and error message if applicable" + schema = jsonSchema() + } + HttpStatusCode.BadRequest { + description = "Invalid request - miniMessage field is missing" + } + } + } + post(URL_MINI_TO_JSON) { val structure = Serializers.json.tryDecodeFromString(call.receiveText()) - val input = structure?.miniMessage ?: return@post + val input = structure?.miniMessage ?: return@post call.response.status(HttpStatusCode.BadRequest) call.respondText( GsonComponentSerializer.gson() .serialize( @@ -171,14 +205,56 @@ public fun Application.miniMessage() { .deserialize(input, structure.placeholders.tagResolver) ) ) + }.describe { + operationId = "miniMessageToJson" + summary = "Convert MiniMessage to JSON" + description = "Converts a MiniMessage string to a JSON representation of the Adventure Component." + requestBody { + schema = jsonSchema() + } + responses { + HttpStatusCode.OK { + description = "The JSON representation of the MiniMessage text" + } + HttpStatusCode.BadRequest { + description = "Invalid request - miniMessage field is missing" + } + } } post(URL_MINI_TO_TREE) { val structure = Serializers.json.tryDecodeFromString(call.receiveText()) - val input = structure?.miniMessage ?: return@post + val input = structure?.miniMessage ?: return@post call.response.status(HttpStatusCode.BadRequest) val resolver = structure.placeholders.tagResolver val root = MiniMessage.miniMessage().deserializeToTree(input, resolver) call.respondText(root.toString()) + }.describe { + operationId = "miniMessageToTree" + summary = "Convert MiniMessage to tree" + description = "Converts a MiniMessage string to a tree representation showing the structure of the parsed tags." + requestBody { + schema = jsonSchema() + } + responses { + HttpStatusCode.OK { + description = "A string representation of the MiniMessage parse tree" + Text.Plain { + example("Example", ExampleObject(value = GenericElement(""" + Node { + TagNode('gold') { + TextNode('Hello') + TagNode('red') { + TextNode('World') + } + } + } + """.trimIndent()))) + } + } + HttpStatusCode.BadRequest { + description = "Invalid request - miniMessage field is missing" + } + } } post(URL_MINI_SHORTEN) { @@ -189,6 +265,21 @@ public fun Application.miniMessage() { } else { call.response.status(HttpStatusCode.InternalServerError) } + }.describe { + operationId = "shortenMiniMessage" + summary = "Shorten MiniMessage" + description = "Stores a MiniMessage string and its placeholders in Bytebin and returns a short code for retrieval." + requestBody { + schema = jsonSchema() + } + responses { + HttpStatusCode.OK { + description = "The short code for retrieving the stored content" + } + HttpStatusCode.InternalServerError { + description = "Failed to store the content in Bytebin" + } + } } get(URL_MINI_SHORTEN) { @@ -200,6 +291,25 @@ public fun Application.miniMessage() { } else { call.response.status(HttpStatusCode.NotFound) } + }.describe { + operationId = "retrieveShortenedMiniMessage" + summary = "Retrieve shortened MiniMessage" + description = "Retrieves a previously stored MiniMessage string and its placeholders using a short code." + parameters { + query("code") { + description = "The short code returned from the POST endpoint" + required = true + } + } + responses { + HttpStatusCode.OK { + description = "The retrieved MiniMessage content and placeholders" + schema = jsonSchema() + } + HttpStatusCode.NotFound { + description = "The short code was not found or has expired" + } + } } post(URL_IN_GAME_PREVIEW) { @@ -210,6 +320,21 @@ public fun Application.miniMessage() { } else { call.response.status(HttpStatusCode.BadRequest) } + }.describe { + operationId = "createInGamePreview" + summary = "Create in-game preview" + description = "Creates a temporary Minecraft server preview for the provided MiniMessage text. Returns a hostname that can be used to connect to the preview server." + requestBody { + schema = jsonSchema() + } + responses { + HttpStatusCode.OK { + description = "The hostname of the preview server" + } + HttpStatusCode.BadRequest { + description = "Invalid request - miniMessage or key is missing" + } + } } get(URL_BUILD_INFO) { @@ -220,6 +345,16 @@ public fun Application.miniMessage() { bytebinInstance = BytebinStorage.BYTEBIN_INSTANCE, ) call.respondText(Serializers.json.encodeToString(info)) + }.describe { + operationId = "getBuildInfo" + summary = "Get build information" + description = "Returns information about the build of this application." + responses { + HttpStatusCode.OK { + description = "The build information" + schema = jsonSchema() + } + } } route(URL_EDITOR) { installEditor() }