aboutsummaryrefslogtreecommitdiff
path: root/server/core
diff options
context:
space:
mode:
Diffstat (limited to 'server/core')
-rw-r--r--server/core/build.gradle.kts37
-rw-r--r--server/core/src/main/kotlin/moe/nea/ledger/server/core/Application.kt81
-rw-r--r--server/core/src/main/kotlin/moe/nea/ledger/server/core/api/BaseApi.kt205
-rw-r--r--server/core/src/main/kotlin/moe/nea/ledger/server/core/model.kt30
-rw-r--r--server/core/src/main/resources/application.conf10
5 files changed, 363 insertions, 0 deletions
diff --git a/server/core/build.gradle.kts b/server/core/build.gradle.kts
new file mode 100644
index 0000000..b2a3222
--- /dev/null
+++ b/server/core/build.gradle.kts
@@ -0,0 +1,37 @@
+plugins {
+ kotlin("jvm")
+ kotlin("plugin.serialization")
+ application
+ id("com.github.gmazzo.buildconfig")
+}
+
+
+dependencies {
+ 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")
+}
+
+java {
+ toolchain.languageVersion.set(JavaLanguageVersion.of(21))
+}
+application {
+ val isDevelopment: Boolean = project.ext.has("development")
+ applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment",
+ "-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
new file mode 100644
index 0000000..23b2a6a
--- /dev/null
+++ b/server/core/src/main/kotlin/moe/nea/ledger/server/core/Application.kt
@@ -0,0 +1,81 @@
+package moe.nea.ledger.server.core
+
+import io.ktor.serialization.kotlinx.json.json
+import io.ktor.server.application.Application
+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 {
+ this.explicitNulls = false
+ this.encodeDefaults = true
+ })
+// cbor()
+ }
+ 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") {
+ 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
new file mode 100644
index 0000000..3240a65
--- /dev/null
+++ b/server/core/src/main/kotlin/moe/nea/ledger/server/core/api/BaseApi.kt
@@ -0,0 +1,205 @@
+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.routing.Route
+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) {
+ 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)
+ .distinct()
+ .map {
+ 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/src/main/kotlin/moe/nea/ledger/server/core/model.kt b/server/core/src/main/kotlin/moe/nea/ledger/server/core/model.kt
new file mode 100644
index 0000000..a27a729
--- /dev/null
+++ b/server/core/src/main/kotlin/moe/nea/ledger/server/core/model.kt
@@ -0,0 +1,30 @@
+@file:UseSerializers(UUIDSerializer::class)
+
+package moe.nea.ledger.server.core
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.UseSerializers
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import java.util.UUID
+
+object UUIDSerializer : KSerializer<UUID> {
+ override val descriptor: SerialDescriptor
+ get() = PrimitiveSerialDescriptor("LedgerUUID", PrimitiveKind.STRING)
+
+ override fun deserialize(decoder: kotlinx.serialization.encoding.Decoder): UUID {
+ return UUID.fromString(decoder.decodeString())
+ }
+
+ override fun serialize(encoder: kotlinx.serialization.encoding.Encoder, value: UUID) {
+ encoder.encodeString(value.toString())
+ }
+}
+
+@Serializable
+data class Profile(
+ val playerId: UUID,
+ val profileId: UUID,
+) \ No newline at end of file
diff --git a/server/core/src/main/resources/application.conf b/server/core/src/main/resources/application.conf
new file mode 100644
index 0000000..386ffd3
--- /dev/null
+++ b/server/core/src/main/resources/application.conf
@@ -0,0 +1,10 @@
+ktor {
+ application {
+ modules = [
+ moe.nea.ledger.server.core.ApplicationKt.module
+ ]
+ }
+ deployment {
+ port = 8080
+ }
+} \ No newline at end of file