diff --git a/server/build.gradle.kts b/server/build.gradle.kts index cf987c3c3..3d0185a8d 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -16,7 +16,8 @@ dependencies { implementation("com.squareup.okio:okio:2.10.0") // Javalin api - implementation("io.javalin:javalin:4.0.0") + implementation("io.javalin:javalin:4.1.1") + implementation("io.javalin:javalin-openapi:4.1.1") // jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency` val jacksonVersion = "2.12.4" implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion") diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/MangaAPI.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/MangaAPI.kt index 9cc822b4c..0f2dc9f5d 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/MangaAPI.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/MangaAPI.kt @@ -52,7 +52,7 @@ object MangaAPI { } path("manga") { - get("{mangaId}", MangaController::retrieve) + get("{mangaId}", MangaController.retrieve) get("{mangaId}/thumbnail", MangaController::thumbnail) get("{mangaId}/category", MangaController::categoryList) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt index 007b34412..46b34c428 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt @@ -13,20 +13,35 @@ import suwayomi.tachidesk.manga.impl.Chapter import suwayomi.tachidesk.manga.impl.Library import suwayomi.tachidesk.manga.impl.Manga import suwayomi.tachidesk.manga.impl.Page +import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.util.handler +import suwayomi.tachidesk.server.util.pathParam +import suwayomi.tachidesk.server.util.queryParam +import suwayomi.tachidesk.server.util.withOperation object MangaController { /** get manga info */ - fun retrieve(ctx: Context) { - val mangaId = ctx.pathParam("mangaId").toInt() - val onlineFetch = ctx.queryParam("onlineFetch")?.toBoolean() ?: false - - ctx.future( - future { - Manga.getManga(mangaId, onlineFetch) + val retrieve = handler( + pathParam("mangaId"), + queryParam("onlineFetch", false), + documentWith = { + withOperation { + summary("Get a manga") + description("Get a manga from the database using a specific id") } - ) - } + }, + behaviorOf = { ctx, mangaId, onlineFetch -> + ctx.future( + future { + Manga.getManga(mangaId, onlineFetch) + } + ) + }, + withResults = { + json("OK") + } + ) /** manga thumbnail */ fun thumbnail(ctx: Context) { diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt index a6c088ed1..0e70ff8ad 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt @@ -11,6 +11,10 @@ import io.javalin.Javalin import io.javalin.apibuilder.ApiBuilder.path import io.javalin.core.security.RouteRole import io.javalin.http.staticfiles.Location +import io.javalin.plugin.openapi.OpenApiOptions +import io.javalin.plugin.openapi.OpenApiPlugin +import io.javalin.plugin.openapi.ui.SwaggerOptions +import io.swagger.v3.oas.models.info.Info import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -46,6 +50,7 @@ object JavalinSetup { logger.info { "Serving webUI static files" } config.addStaticFiles(applicationDirs.webUIRoot, Location.EXTERNAL) config.addSinglePageRoot("/", applicationDirs.webUIRoot + "/index.html", Location.EXTERNAL) + config.registerPlugin(OpenApiPlugin(getOpenApiOptions())) } config.enableCorsForAllOrigins() @@ -98,6 +103,21 @@ object JavalinSetup { } } + private fun getOpenApiOptions(): OpenApiOptions { + val applicationInfo = Info().apply { + version("1.0") + description("Tachidesk Api") + } + return OpenApiOptions(applicationInfo).apply { + path("/api/openapi.json") + swagger( + SwaggerOptions("/api/swagger-ui").apply { + title("Tachidesk Swagger Documentation") + } + ) + } + } + object Auth { enum class Role : RouteRole { ANYONE, USER_READ, USER_WRITE } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/util/DocumentationDsl.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/util/DocumentationDsl.kt new file mode 100644 index 000000000..4fa594221 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/util/DocumentationDsl.kt @@ -0,0 +1,403 @@ +package suwayomi.tachidesk.server.util + +import io.javalin.http.Context +import io.javalin.plugin.openapi.dsl.DocumentedHandler +import io.javalin.plugin.openapi.dsl.OpenApiDocumentation +import io.javalin.plugin.openapi.dsl.documented +import io.swagger.v3.oas.models.Operation + +fun getSimpleParamItem(ctx: Context, param: Param): String? { + return when (param) { + is Param.FormParam -> ctx.formParam(param.key) + is Param.PathParam -> ctx.pathParam(param.key) + is Param.QueryParam -> ctx.queryParam(param.key) + } +} + +@Suppress("UNCHECKED_CAST") +fun getParam(ctx: Context, param: Param): T { + val typedItem: Any? = when (param.clazz) { + String::class.java -> getSimpleParamItem(ctx, param) + Int::class.java -> getSimpleParamItem(ctx, param)?.toIntOrNull() + Long::class.java -> getSimpleParamItem(ctx, param)?.toLongOrNull() + Boolean::class.java -> getSimpleParamItem(ctx, param)?.toBoolean() + Float::class.java -> getSimpleParamItem(ctx, param)?.toFloatOrNull() + Double::class.java -> getSimpleParamItem(ctx, param)?.toDoubleOrNull() + else -> { + when (param) { + is Param.FormParam -> ctx.formParamAsClass(param.key, param.clazz) + is Param.PathParam -> ctx.pathParamAsClass(param.key, param.clazz) + is Param.QueryParam -> ctx.queryParamAsClass(param.key, param.clazz) + }.let { + if (param.nullable) { + it.allowNullable().get() ?: param.defaultValue + } else { + if (param.defaultValue != null) { + it.getOrDefault(param.defaultValue!!) + } else { + it.get() + } + } + } + } + } + + return if (param.nullable) { + typedItem as T + } else { + typedItem!! as T + } +} + +inline fun getDocumentation( + documentWith: OpenApiDocumentation.() -> Unit, + noinline withResults: ResultsBuilder.() -> Unit, + vararg params: Param<*> +): OpenApiDocumentation { + return OpenApiDocumentation().apply(documentWith).apply { + applyResults(withResults) + params.forEach { + when (it) { + is Param.FormParam -> formParam(it.key, it.clazz, !it.nullable && it.defaultValue == null) + is Param.PathParam -> pathParam(it.key, it.clazz) + is Param.QueryParam -> queryParam(it.key, it.clazz,) + } + } + } +} + +fun OpenApiDocumentation.applyResults(withResults: ResultsBuilder.() -> Unit) { + ResultsBuilder().apply(withResults).results.forEach { + it.applyTo(this) + } +} + +fun OpenApiDocumentation.withOperation(block: Operation.() -> Unit) { + operation(block) +} + +inline fun formParam(key: String, defaultValue: T? = null): Param.FormParam { + return Param.FormParam(key, T::class.java, defaultValue, null is T) +} +inline fun queryParam(key: String, defaultValue: T? = null): Param.QueryParam { + return Param.QueryParam(key, T::class.java, defaultValue, null is T) +} +inline fun pathParam(key: String): Param.PathParam { + return Param.PathParam(key, T::class.java, null, false) +} + +sealed class Param { + abstract val key: String + abstract val clazz: Class + abstract val defaultValue: T? + abstract val nullable: Boolean + data class FormParam( + override val key: String, + override val clazz: Class, + override val defaultValue: T?, + override val nullable: Boolean + ) : Param() + data class QueryParam( + override val key: String, + override val clazz: Class, + override val defaultValue: T?, + override val nullable: Boolean + ) : Param() + data class PathParam( + override val key: String, + override val clazz: Class, + override val defaultValue: T?, + override val nullable: Boolean + ) : Param() +} + +class ResultsBuilder { + val results = mutableListOf>() + + inline fun json(status: String) { + results += ResultType.MimeType(status, "application/json", T::class.java) + } + inline fun plainText(status: String) { + results += ResultType.MimeType(status, "text/plain", String::class.java) + } +} + +sealed class ResultType { + abstract fun applyTo(documentation: OpenApiDocumentation) + data class MimeType(val status: String, val mime: String, private val clazz: Class) : ResultType() { + override fun applyTo(documentation: OpenApiDocumentation) { + documentation.result(status, clazz) + } + } +} + +inline fun handler( + documentWith: OpenApiDocumentation.() -> Unit = {}, + noinline behaviorOf: (ctx: Context) -> Unit, + noinline withResults: ResultsBuilder.() -> Unit +): DocumentedHandler { + return documented( + documentation = getDocumentation(documentWith, withResults), + handle = behaviorOf + ) +} + +inline fun handler( + param1: Param, + documentWith: OpenApiDocumentation.() -> Unit, + noinline behaviorOf: (ctx: Context, P1) -> Unit, + noinline withResults: ResultsBuilder.() -> Unit +): DocumentedHandler { + return documented( + documentation = getDocumentation(documentWith, withResults, param1), + handle = { + behaviorOf( + it, + getParam(it, param1) + ) + } + ) +} + +inline fun handler( + param1: Param, + param2: Param, + documentWith: OpenApiDocumentation.() -> Unit = {}, + crossinline behaviorOf: (ctx: Context, P1, P2) -> Unit, + noinline withResults: ResultsBuilder.() -> Unit +): DocumentedHandler { + return documented( + documentation = getDocumentation(documentWith, withResults, param1, param2), + handle = { + behaviorOf( + it, + getParam(it, param1), + getParam(it, param2) + ) + } + ) +} + +inline fun handler( + param1: Param, + param2: Param, + param3: Param, + documentWith: OpenApiDocumentation.() -> Unit = {}, + crossinline behaviorOf: (ctx: Context, P1, P2, P3) -> Unit, + noinline withResults: ResultsBuilder.() -> Unit +): DocumentedHandler { + return documented( + documentation = getDocumentation(documentWith, withResults, param1, param2, param3), + handle = { + behaviorOf( + it, + getParam(it, param1), + getParam(it, param2), + getParam(it, param3), + ) + } + ) +} + +inline fun handler( + param1: Param, + param2: Param, + param3: Param, + param4: Param, + documentWith: OpenApiDocumentation.() -> Unit = {}, + crossinline behaviorOf: (ctx: Context, P1, P2, P3, P4) -> Unit, + noinline withResults: ResultsBuilder.() -> Unit +): DocumentedHandler { + return documented( + documentation = getDocumentation(documentWith, withResults, param1, param2, param3, param4), + handle = { + behaviorOf( + it, + getParam(it, param1), + getParam(it, param2), + getParam(it, param3), + getParam(it, param4), + ) + } + ) +} + +inline fun handler( + param1: Param, + param2: Param, + param3: Param, + param4: Param, + param5: Param, + documentWith: OpenApiDocumentation.() -> Unit = {}, + crossinline behaviorOf: (ctx: Context, P1, P2, P3, P4, P5) -> Unit, + noinline withResults: ResultsBuilder.() -> Unit +): DocumentedHandler { + return documented( + documentation = getDocumentation(documentWith, withResults, param1, param2, param3, param4, param5), + handle = { + behaviorOf( + it, + getParam(it, param1), + getParam(it, param2), + getParam(it, param3), + getParam(it, param4), + getParam(it, param5), + ) + } + ) +} + +inline fun handler( + param1: Param, + param2: Param, + param3: Param, + param4: Param, + param5: Param, + param6: Param, + documentWith: OpenApiDocumentation.() -> Unit = {}, + crossinline behaviorOf: (ctx: Context, P1, P2, P3, P4, P5, P6) -> Unit, + noinline withResults: ResultsBuilder.() -> Unit +): DocumentedHandler { + return documented( + documentation = getDocumentation(documentWith, withResults, param1, param2, param3, param4, param5, param6), + handle = { + behaviorOf( + it, + getParam(it, param1), + getParam(it, param2), + getParam(it, param3), + getParam(it, param4), + getParam(it, param5), + getParam(it, param6), + ) + } + ) +} + +inline fun handler( + param1: Param, + param2: Param, + param3: Param, + param4: Param, + param5: Param, + param6: Param, + param7: Param, + documentWith: OpenApiDocumentation.() -> Unit = {}, + crossinline behaviorOf: (ctx: Context, P1, P2, P3, P4, P5, P6, P7) -> Unit, + noinline withResults: ResultsBuilder.() -> Unit +): DocumentedHandler { + return documented( + documentation = getDocumentation(documentWith, withResults, param1, param2, param3, param4, param5, param6, param7), + handle = { + behaviorOf( + it, + getParam(it, param1), + getParam(it, param2), + getParam(it, param3), + getParam(it, param4), + getParam(it, param5), + getParam(it, param6), + getParam(it, param7), + ) + } + ) +} + +inline fun handler( + param1: Param, + param2: Param, + param3: Param, + param4: Param, + param5: Param, + param6: Param, + param7: Param, + param8: Param, + documentWith: OpenApiDocumentation.() -> Unit = {}, + crossinline behaviorOf: (ctx: Context, P1, P2, P3, P4, P5, P6, P7, P8) -> Unit, + noinline withResults: ResultsBuilder.() -> Unit +): DocumentedHandler { + return documented( + documentation = getDocumentation(documentWith, withResults, param1, param2, param3, param4, param5, param6, param7, param8), + handle = { + behaviorOf( + it, + getParam(it, param1), + getParam(it, param2), + getParam(it, param3), + getParam(it, param4), + getParam(it, param5), + getParam(it, param6), + getParam(it, param7), + getParam(it, param8), + ) + } + ) +} + +inline fun handler( + param1: Param, + param2: Param, + param3: Param, + param4: Param, + param5: Param, + param6: Param, + param7: Param, + param8: Param, + param9: Param, + documentWith: OpenApiDocumentation.() -> Unit = {}, + crossinline behaviorOf: (ctx: Context, P1, P2, P3, P4, P5, P6, P7, P8, P9) -> Unit, + noinline withResults: ResultsBuilder.() -> Unit +): DocumentedHandler { + return documented( + documentation = getDocumentation(documentWith, withResults, param1, param2, param3, param4, param5, param6, param7, param8, param9), + handle = { + behaviorOf( + it, + getParam(it, param1), + getParam(it, param2), + getParam(it, param3), + getParam(it, param4), + getParam(it, param5), + getParam(it, param6), + getParam(it, param7), + getParam(it, param8), + getParam(it, param9), + ) + } + ) +} + +inline fun handler( + param1: Param, + param2: Param, + param3: Param, + param4: Param, + param5: Param, + param6: Param, + param7: Param, + param8: Param, + param9: Param, + param10: Param, + documentWith: OpenApiDocumentation.() -> Unit = {}, + crossinline behaviorOf: (ctx: Context, P1, P2, P3, P4, P5, P6, P7, P8, P9, P10) -> Unit, + noinline withResults: ResultsBuilder.() -> Unit +): DocumentedHandler { + return documented( + documentation = getDocumentation(documentWith, withResults, param1, param2, param3, param4, param5, param6, param7, param8, param9, param10), + handle = { + behaviorOf( + it, + getParam(it, param1), + getParam(it, param2), + getParam(it, param3), + getParam(it, param4), + getParam(it, param5), + getParam(it, param6), + getParam(it, param7), + getParam(it, param8), + getParam(it, param9), + getParam(it, param10), + ) + } + ) +}