diff options
Diffstat (limited to 'server/core')
4 files changed, 249 insertions, 21 deletions
diff --git a/server/core/build.gradle.kts b/server/core/build.gradle.kts index 87f613a..b2a3222 100644 --- a/server/core/build.gradle.kts +++ b/server/core/build.gradle.kts @@ -2,19 +2,22 @@ plugins { kotlin("jvm") kotlin("plugin.serialization") application + id("com.github.gmazzo.buildconfig") } -val ktor_version = "3.0.3" dependencies { - implementation(platform("io.ktor:ktor-bom:$ktor_version")) - 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(project(":database:impl")) + declareKtorVersion() + api("io.ktor:ktor-server-netty") + api("io.ktor:ktor-server-status-pages") + api("io.ktor:ktor-server-content-negotiation") + api("io.ktor:ktor-serialization-kotlinx-json") + api("io.ktor:ktor-server-compression") + api("io.ktor:ktor-server-cors") + api("sh.ondr:kotlin-json-schema:0.1.1") + api(project(":server:analysis")) + api(project(":database:impl")) + api(project(":server:swagger")) runtimeOnly("ch.qos.logback:logback-classic:1.5.16") runtimeOnly("org.xerial:sqlite-jdbc:3.45.3.0") @@ -29,3 +32,6 @@ application { "-Dledger.databasefolder=${project(":mod").file("run/money-ledger").absoluteFile}") mainClass.set("moe.nea.ledger.server.core.ApplicationKt") } +buildConfig { + packageName("moe.nea.ledger.gen") +} 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..23b2a6a 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 @@ -6,29 +6,76 @@ import io.ktor.server.application.install import io.ktor.server.netty.EngineMain import io.ktor.server.plugins.compression.Compression import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.plugins.cors.routing.CORS +import io.ktor.server.response.respondRedirect +import io.ktor.server.routing.Routing +import io.ktor.server.routing.get 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.gen.BuildConfig +import moe.nea.ledger.server.core.api.Documentation +import moe.nea.ledger.server.core.api.Info +import moe.nea.ledger.server.core.api.Server import moe.nea.ledger.server.core.api.apiRouting +import moe.nea.ledger.server.core.api.openApiDocsJson +import moe.nea.ledger.server.core.api.openApiUi +import moe.nea.ledger.server.core.api.setApiRoot import java.io.File fun main(args: Array<String>) { EngineMain.main(args) } +interface AIOProvider { + fun Routing.installExtraRouting() + fun Application.module() +} fun Application.module() { + val aio = runCatching { + Class.forName("moe.nea.ledger.server.aio.AIO") + .newInstance() as AIOProvider + }.getOrNull() + aio?.run { module() } install(Compression) + install(Documentation) { + info = Info( + "Ledger Analysis Server", + "Your local API for loading ledger data", + BuildConfig.VERSION + ) + servers.add( + Server("http://localhost:8080/api", "Your Local Server") + ) + } install(ContentNegotiation) { - json() + json(Json { + this.explicitNulls = false + this.encodeDefaults = true + }) // cbor() } - val database = Database(File(System.getProperty("ledger.databasefolder"))) + install(CORS) { + anyHost() + } + val database = Database(File(System.getProperty("ledger.databasefolder", + "/home/nea/.local/share/PrismLauncher/instances/Skyblock/.minecraft/money-ledger"))) database.loadAndUpgrade() routing { route("/api") { - this.apiRouting(database) + setApiRoot() + get { call.respondRedirect("/openapi/") } + apiRouting(database) + } + route("/api.json") { + openApiDocsJson() + } + route("/openapi") { + openApiUi("/api.json") } + aio?.run { installExtraRouting() } } } 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..3240a65 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 @@ -1,18 +1,49 @@ package moe.nea.ledger.server.core.api +import io.ktor.http.Url +import io.ktor.http.toURI 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 kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.withContext +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 kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import moe.nea.ledger.ItemChange +import moe.nea.ledger.TransactionType +import moe.nea.ledger.analysis.Analysis +import moe.nea.ledger.analysis.AnalysisFilter +import moe.nea.ledger.analysis.AnalysisResult +import moe.nea.ledger.database.DBItemEntry import moe.nea.ledger.database.DBLogEntry import moe.nea.ledger.database.Database +import moe.nea.ledger.database.sql.Clause import moe.nea.ledger.server.core.Profile +import moe.nea.ledger.utils.ULIDWrapper +import java.time.Instant +import java.util.ServiceLoader +import java.util.UUID fun Route.apiRouting(database: Database) { - get("/") { - call.respondText("K") + val allOfferedAnalysisServices: Map<String, Analysis> = run { + val serviceLoader = ServiceLoader.load(Analysis::class.java, environment.classLoader) + val map = mutableMapOf<String, Analysis>() + serviceLoader.forEach { + map[it.id] = it + } + map } + get("/profiles") { val profiles = DBLogEntry.from(database.connection) .select(DBLogEntry.playerId, DBLogEntry.profileId) @@ -21,5 +52,154 @@ fun Route.apiRouting(database: Database) { Profile(it[DBLogEntry.playerId], it[DBLogEntry.profileId]) } call.respond(profiles) + }.docs { + summary = "List all profiles and players known to ledger" + operationId = "listProfiles" + tag(Tags.PROFILE) + respondsOk { + schema<List<Profile>>() + } + } + @OptIn(DelicateCoroutinesApi::class) + val itemNames = GlobalScope.async { + val itemNamesUrl = + Url("https://github.com/nea89o/ledger-auxiliary-data/raw/refs/heads/master/data/item_names.json") + Json.decodeFromStream<Map<String, String>>(itemNamesUrl.toURI().toURL().openStream()) + } + get("/analysis/execute") { + val analysis = allOfferedAnalysisServices[call.queryParameters["analysis"]] ?: TODO() + val start = call.queryParameters["tStart"]?.toLongOrNull()?.let { Instant.ofEpochMilli(it) } ?: TODO() + val end = call.queryParameters["tEnd"]?.toLongOrNull()?.let { Instant.ofEpochMilli(it) } ?: TODO() + val analysisResult = withContext(Dispatchers.IO) { + analysis.perform( + database.connection, + object : AnalysisFilter { + override val startWindow: Instant + get() = start + override val endWindow: Instant + get() = end + override val profiles: List<UUID> + get() = listOf() + } + ) + } + call.respond(analysisResult) + }.docs { + summary = "Execute an analysis on a given timeframe" + operationId = "executeAnalysis" + queryParameter<String>("analysis", description = "An analysis id obtained from getAnalysis") + queryParameter<Long>("tStart", description = "The start of the timeframe to analyze") + queryParameter<Long>("tEnd", + description = "The end of the timeframe to analyze. Make sure to use the end of the day if you want the entire day included.") + tag(Tags.DATA) + respondsOk { + schema<AnalysisResult>() + } + } + get("/analysis/list") { + call.respond(allOfferedAnalysisServices.values.map { + AnalysisListing(it.name, it.id) + }) + }.docs { + summary = "List all installed analysis" + operationId = "getAnalysis" + tag(Tags.DATA) + respondsOk { + schema<List<AnalysisListing>>() + } + } + get("/item") { + val itemIds = call.queryParameters.getAll("itemId")?.toSet() ?: emptySet() + val itemNameMap = itemNames.await() + call.respond(itemIds.associateWith { itemNameMap[it] }) + }.docs { + summary = "Get item names for item ids" + operationId = "getItemNames" + tag(Tags.HYPIXEL) + queryParameter<List<String>>("itemId") + respondsOk { + schema<Map<String, String?>>() + } + } + get("/entries") { + val logs = mutableMapOf<ULIDWrapper, LogEntry>() + val items = mutableMapOf<ULIDWrapper, MutableList<SerializableItemChange>>() + withContext(Dispatchers.IO) { + DBLogEntry.from(database.connection) + .join(DBItemEntry, Clause { column(DBItemEntry.transactionId) eq column(DBLogEntry.transactionId) }) + .select(DBLogEntry.profileId, + DBLogEntry.playerId, + DBLogEntry.transactionId, + DBLogEntry.type, + DBItemEntry.mode, + DBItemEntry.itemId, + DBItemEntry.size) + .forEach { row -> + logs.getOrPut(row[DBLogEntry.transactionId]) { + LogEntry(row[DBLogEntry.type], + row[DBLogEntry.transactionId], + listOf()) + } + items.getOrPut(row[DBLogEntry.transactionId]) { mutableListOf() } + .add(SerializableItemChange( + row[DBItemEntry.itemId].string, + row[DBItemEntry.mode], + row[DBItemEntry.size], + )) + } + } + val compiled = logs.values.map { it.copy(items = items[it.id]!!) } + call.respond(compiled) + }.docs { + summary = "Get all log entries" + operationId = "getLogEntries" + tag(Tags.DATA) + respondsOk { + schema<List<LogEntry>>() + } + } +} + +@Serializable +data class AnalysisListing( + val name: String, + val id: String, +) + +@Serializable +data class LogEntry( + val type: TransactionType, + val id: @Serializable(ULIDSerializer::class) ULIDWrapper, + val items: List<SerializableItemChange>, +) + +@Serializable +data class SerializableItemChange( + val itemId: String, + val direction: ItemChange.ChangeDirection, + val amount: Double, +) + +object ULIDSerializer : KSerializer<ULIDWrapper> { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ULID", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): ULIDWrapper { + return ULIDWrapper(decoder.decodeString()) + } + + override fun serialize(encoder: Encoder, value: ULIDWrapper) { + encoder.encodeString(value.wrapped) } } + +enum class Tags : IntoTag { + PROFILE, + HYPIXEL, + MANAGEMENT, + DATA, + ; + + override fun intoTag(): String { + return name + } +}
\ No newline at end of file diff --git a/server/core/test-requests/test-hello.http b/server/core/test-requests/test-hello.http deleted file mode 100644 index 3ddf352..0000000 --- a/server/core/test-requests/test-hello.http +++ /dev/null @@ -1,5 +0,0 @@ -### GET request to example server -GET localhost:8080/ - -### GET profiles -GET localhost:8080/api/profiles
\ No newline at end of file |