Add basic graphql implementation with manga and chapters loading with data loaders

This commit is contained in:
Valter Martinek
2022-11-10 22:42:12 +01:00
committed by Syer10
parent f2a650ba02
commit 21719f4408
15 changed files with 535 additions and 0 deletions

View File

@@ -64,6 +64,10 @@ dependencies {
// implementation(fileTree("lib/"))
implementation(kotlin("script-runtime"))
implementation("com.expediagroup", "graphql-kotlin-server", "6.3.0")
implementation("com.expediagroup", "graphql-kotlin-schema-generator", "6.3.0")
implementation("com.graphql-java", "graphql-java-extended-scalars", "19.0")
testImplementation(libs.mockk)
}

View File

@@ -0,0 +1,17 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql
import io.javalin.apibuilder.ApiBuilder.post
import suwayomi.tachidesk.graphql.controller.GraphQLController
object GraphQL {
fun defineEndpoints() {
post("graphql", GraphQLController.execute)
}
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.controller
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import io.javalin.http.HttpCode
import suwayomi.tachidesk.graphql.impl.getGraphQLServer
import suwayomi.tachidesk.server.JavalinSetup.future
import suwayomi.tachidesk.server.util.handler
import suwayomi.tachidesk.server.util.withOperation
object GraphQLController {
private val mapper = jacksonObjectMapper()
private val tachideskGraphQLServer = getGraphQLServer(mapper)
/** execute graphql query */
val execute = handler(
documentWith = {
withOperation {
summary("GraphQL endpoint")
description("Endpoint for GraphQL endpoints")
}
},
behaviorOf = { ctx ->
ctx.future(
future {
tachideskGraphQLServer.execute(ctx)
}
)
},
withResults = {
json<Any>(HttpCode.OK)
}
)
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.dataLoaders
import com.expediagroup.graphql.dataloader.KotlinDataLoader
import org.dataloader.DataLoader
import org.dataloader.DataLoaderFactory
import org.jetbrains.exposed.sql.StdOutSqlLogger
import org.jetbrains.exposed.sql.addLogger
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.types.ChapterType
import suwayomi.tachidesk.manga.model.table.ChapterTable
import java.util.concurrent.CompletableFuture
class ChapterDataLoader : KotlinDataLoader<Int, ChapterType> {
override val dataLoaderName = "ChapterDataLoader"
override fun getDataLoader(): DataLoader<Int, ChapterType> = DataLoaderFactory.newDataLoader<Int, ChapterType> { ids ->
CompletableFuture.supplyAsync {
transaction {
addLogger(StdOutSqlLogger)
ChapterTable.select { ChapterTable.id inList ids }
.map { ChapterType(it) }
}
}
}
}
class ChaptersForMangaDataLoader : KotlinDataLoader<Int, List<ChapterType>> {
override val dataLoaderName = "ChaptersForMangaDataLoader"
override fun getDataLoader(): DataLoader<Int, List<ChapterType>> = DataLoaderFactory.newDataLoader<Int, List<ChapterType>> { ids ->
CompletableFuture.supplyAsync {
transaction {
addLogger(StdOutSqlLogger)
val chaptersByMangaId = ChapterTable.select { ChapterTable.manga inList ids }
.map { ChapterType(it) }
.groupBy { it.mangaId }
ids.map { chaptersByMangaId[it] ?: emptyList() }
}
}
}
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.dataLoaders
import com.expediagroup.graphql.dataloader.KotlinDataLoader
import org.dataloader.DataLoader
import org.dataloader.DataLoaderFactory
import org.jetbrains.exposed.sql.StdOutSqlLogger
import org.jetbrains.exposed.sql.addLogger
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.types.MangaType
import suwayomi.tachidesk.manga.model.table.MangaTable
import java.util.concurrent.CompletableFuture
class MangaDataLoader : KotlinDataLoader<Int, MangaType> {
override val dataLoaderName = "MangaDataLoader"
override fun getDataLoader(): DataLoader<Int, MangaType> = DataLoaderFactory.newDataLoader<Int, MangaType> { ids ->
CompletableFuture.supplyAsync {
transaction {
addLogger(StdOutSqlLogger)
MangaTable.select { MangaTable.id inList ids }
.map { MangaType(it) }
}
}
}
}

View File

@@ -0,0 +1,16 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.dataLoaders
import com.expediagroup.graphql.dataloader.KotlinDataLoaderRegistryFactory
val tachideskDataLoaderRegistryFactory = KotlinDataLoaderRegistryFactory(
MangaDataLoader(),
ChapterDataLoader(),
ChaptersForMangaDataLoader()
)

View File

@@ -0,0 +1,30 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.impl
import com.expediagroup.graphql.server.execution.GraphQLRequestParser
import com.expediagroup.graphql.server.types.GraphQLServerRequest
import com.fasterxml.jackson.databind.ObjectMapper
import io.javalin.http.Context
import java.io.IOException
/**
* Custom logic for how Javalin parses the incoming [Context] into the [GraphQLServerRequest]
*/
class JavalinGraphQLRequestParser(
private val mapper: ObjectMapper
) : GraphQLRequestParser<Context> {
@Suppress("BlockingMethodInNonBlockingContext")
override suspend fun parseRequest(context: Context): GraphQLServerRequest = try {
val rawRequest = context.body()
mapper.readValue(rawRequest, GraphQLServerRequest::class.java)
} catch (e: IOException) {
throw IOException("Unable to parse GraphQL payload.")
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.impl
import com.expediagroup.graphql.generator.execution.GraphQLContext
import com.expediagroup.graphql.server.execution.GraphQLContextFactory
import io.javalin.http.Context
/**
* Custom logic for how Tachidesk should create its context given the [Context]
*/
class TachideskGraphQLContextFactory : GraphQLContextFactory<GraphQLContext, Context> {
override suspend fun generateContextMap(request: Context): Map<*, Any> =
mutableMapOf<Any, Any>(
// "user" to User(
// email = "fake@site.com",
// firstName = "Someone",
// lastName = "You Don't know",
// universityId = 4
// )
).also { map ->
// request.headers["my-custom-header"]?.let { customHeader ->
// map["customHeader"] = customHeader
// }
}
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.impl
import com.expediagroup.graphql.generator.SchemaGeneratorConfig
import com.expediagroup.graphql.generator.TopLevelObject
import com.expediagroup.graphql.generator.hooks.SchemaGeneratorHooks
import com.expediagroup.graphql.generator.scalars.IDValueUnboxer
import com.expediagroup.graphql.generator.toSchema
import graphql.GraphQL
import graphql.scalars.ExtendedScalars
import graphql.schema.GraphQLType
import suwayomi.tachidesk.graphql.queries.ChapterQuery
import suwayomi.tachidesk.graphql.queries.MangaQuery
import kotlin.reflect.KClass
import kotlin.reflect.KType
class CustomSchemaGeneratorHooks : SchemaGeneratorHooks {
override fun willGenerateGraphQLType(type: KType): GraphQLType? = when (type.classifier as? KClass<*>) {
Long::class -> ExtendedScalars.GraphQLLong
else -> null
}
}
val schema = toSchema(
config = SchemaGeneratorConfig(
supportedPackages = listOf("suwayomi.tachidesk.graphql"),
introspectionEnabled = true,
hooks = CustomSchemaGeneratorHooks()
),
queries = listOf(
TopLevelObject(MangaQuery()),
TopLevelObject(ChapterQuery())
),
mutations = listOf()
)
fun getGraphQLObject(): GraphQL = GraphQL.newGraphQL(schema)
.valueUnboxer(IDValueUnboxer())
.build()

View File

@@ -0,0 +1,29 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.impl
import com.expediagroup.graphql.server.execution.GraphQLRequestHandler
import com.expediagroup.graphql.server.execution.GraphQLServer
import com.fasterxml.jackson.databind.ObjectMapper
import io.javalin.http.Context
import suwayomi.tachidesk.graphql.dataLoaders.tachideskDataLoaderRegistryFactory
class TachideskGraphQLServer(
requestParser: JavalinGraphQLRequestParser,
contextFactory: TachideskGraphQLContextFactory,
requestHandler: GraphQLRequestHandler
) : GraphQLServer<Context>(requestParser, contextFactory, requestHandler)
fun getGraphQLServer(mapper: ObjectMapper): TachideskGraphQLServer {
val requestParser = JavalinGraphQLRequestParser(mapper)
val contextFactory = TachideskGraphQLContextFactory()
val graphQL = getGraphQLObject()
val requestHandler = GraphQLRequestHandler(graphQL, tachideskDataLoaderRegistryFactory)
return TachideskGraphQLServer(requestParser, contextFactory, requestHandler)
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.queries
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.sql.andWhere
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.types.ChapterType
import suwayomi.tachidesk.manga.model.table.ChapterTable
import java.util.concurrent.CompletableFuture
class ChapterQuery {
fun chapter(dataFetchingEnvironment: DataFetchingEnvironment, id: Int): CompletableFuture<ChapterType> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, ChapterType>("ChapterDataLoader", id)
}
data class ChapterQueryInput(
val ids: List<Int>? = null,
val mangaIds: List<Int>? = null,
val page: Int? = null,
val count: Int? = null
)
fun chapters(input: ChapterQueryInput? = null): List<ChapterType> {
val results = transaction {
var res = ChapterTable.selectAll()
if (input != null) {
if (input.mangaIds != null) {
res = res.andWhere { ChapterTable.manga inList input.mangaIds }
}
if (input.ids != null) {
res = res.andWhere { ChapterTable.id inList input.ids }
}
if (input.count != null) {
val offset = if (input.page == null) 0 else (input.page * input.count).toLong()
res = res.limit(input.count, offset)
}
}
res.toList()
}
return results.map { ChapterType(it) }
}
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.queries
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.sql.andWhere
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.types.MangaType
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import java.util.concurrent.CompletableFuture
class MangaQuery {
fun manga(dataFetchingEnvironment: DataFetchingEnvironment, id: Int): CompletableFuture<MangaType> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, MangaType>("MangaDataLoader", id)
}
data class MangaQueryInput(
val ids: List<Int>? = null,
val categoryIds: List<Int>? = null,
val page: Int? = null,
val count: Int? = null
)
fun mangas(input: MangaQueryInput? = null): List<MangaType> {
val results = transaction {
var res = MangaTable.selectAll()
if (input != null) {
if (input.categoryIds != null) {
res = MangaTable.innerJoin(CategoryMangaTable)
.select { CategoryMangaTable.category inList input.categoryIds }
}
if (input.ids != null) {
res.andWhere { MangaTable.id inList input.ids }
}
if (input.count != null) {
val offset = if (input.page == null) 0 else (input.page * input.count).toLong()
res.limit(input.count, offset)
}
}
res.toList()
}
return results.map { MangaType(it) }
}
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.types
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.sql.ResultRow
import suwayomi.tachidesk.manga.model.table.ChapterTable
import java.util.concurrent.CompletableFuture
class ChapterType(
val id: Int,
val url: String,
val name: String,
val uploadDate: Long,
val chapterNumber: Float,
val scanlator: String?,
val mangaId: Int,
val isRead: Boolean,
val isBookmarked: Boolean,
val lastPageRead: Int,
val lastReadAt: Long,
val sourceOrder: Int,
val fetchedAt: Long,
val isDownloaded: Boolean,
val pageCount: Int
// val chapterCount: Int?,
// val meta: Map<String, String> = emptyMap()
) {
constructor(row: ResultRow) : this(
row[ChapterTable.id].value,
row[ChapterTable.url],
row[ChapterTable.name],
row[ChapterTable.date_upload],
row[ChapterTable.chapter_number],
row[ChapterTable.scanlator],
row[ChapterTable.manga].value,
row[ChapterTable.isRead],
row[ChapterTable.isBookmarked],
row[ChapterTable.lastPageRead],
row[ChapterTable.lastReadAt],
row[ChapterTable.sourceOrder],
row[ChapterTable.fetchedAt],
row[ChapterTable.isDownloaded],
row[ChapterTable.pageCount]
// transaction { ChapterTable.select { manga eq chapterEntry[manga].value }.count().toInt() },
// Chapter.getChapterMetaMap(chapterEntry[id])
)
fun manga(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<MangaType> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, MangaType>("MangaDataLoader", mangaId)
}
// fun chapters(): List<String> {
// return listOf("Foo", "Bar", "Baz")
// }
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.types
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.sql.ResultRow
import suwayomi.tachidesk.manga.model.dataclass.toGenreList
import suwayomi.tachidesk.manga.model.table.MangaStatus
import suwayomi.tachidesk.manga.model.table.MangaTable
import java.time.Instant
import java.util.concurrent.CompletableFuture
class MangaType(
val id: Int,
val sourceId: String,
val url: String,
val title: String,
val thumbnailUrl: String?,
val initialized: Boolean,
val artist: String?,
val author: String?,
val description: String?,
val genre: List<String>,
val status: String,
val inLibrary: Boolean,
val inLibraryAt: Long,
val realUrl: String?,
var lastFetchedAt: Long?,
var chaptersLastFetchedAt: Long?
) {
constructor(row: ResultRow) : this(
row[MangaTable.id].value,
row[MangaTable.sourceReference].toString(),
row[MangaTable.url],
row[MangaTable.title],
row[MangaTable.thumbnail_url],
row[MangaTable.initialized],
row[MangaTable.artist],
row[MangaTable.author],
row[MangaTable.description],
row[MangaTable.genre].toGenreList(),
MangaStatus.valueOf(row[MangaTable.status]).name,
row[MangaTable.inLibrary],
row[MangaTable.inLibraryAt],
row[MangaTable.realUrl],
row[MangaTable.lastFetchedAt],
row[MangaTable.chaptersLastFetchedAt]
)
fun chapters(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<List<ChapterType>> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, List<ChapterType>>("ChaptersForMangaDataLoader", id)
}
fun age(): Long? {
if (lastFetchedAt == null) return null
return Instant.now().epochSecond.minus(lastFetchedAt!!)
}
fun chaptersAge(): Long? {
if (chaptersLastFetchedAt == null) return null
return Instant.now().epochSecond.minus(chaptersLastFetchedAt!!)
}
}

View File

@@ -24,6 +24,7 @@ import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import suwayomi.tachidesk.global.GlobalAPI
import suwayomi.tachidesk.graphql.GraphQL
import suwayomi.tachidesk.manga.MangaAPI
import suwayomi.tachidesk.server.util.Browser
import suwayomi.tachidesk.server.util.setupWebInterface
@@ -109,6 +110,7 @@ object JavalinSetup {
GlobalAPI.defineEndpoints()
MangaAPI.defineEndpoints()
}
GraphQL.defineEndpoints()
}
}