Skip to content

Commit

Permalink
后端API框架(Bomb v2 in ExplodeX) (#3)
Browse files Browse the repository at this point in the history
* 初步完成后端API框架

1. 完成 Bomb 脚手架(模块化设计,内容序列化、反序列化,登录验证)
2. 添加 saveConfig() 方法
3. 添加查询用户自己(/user/me)
4. 添加欢迎接口(/):会返回一些随机的内容,内容来源于预言地城(命运2)的对话内容
5. 更新 .gitignore

* 添加用户相关API

1. 添加用户查询(/user/{id})
2. 添加用户注册(/user/register)
3. 添加用户查询成绩(/user/{id}/best 和 /user/{id}/last)
4. 添加用户修改名称(/user/{id}/username)
5. 添加用户修改密码(/user/{id}/password)

* 将 WelcomeBO 设为 internal

* 修复 BombPrincipal 假用户缺少字段编译错误

* 修复修改用户名称的逻辑错误

* 修改 RecordBO 和 UserBO 的可见性

* 扩充 BombApi

1. 添加曲目查询(/set)
2. 添加谱面查询(/chart)
3. 添加数据上传接口(/upload)

* 修改 BombApi 版本为 2
  • Loading branch information
Taskeren authored Oct 27, 2022
1 parent 8052389 commit 95ed90d
Show file tree
Hide file tree
Showing 26 changed files with 745 additions and 152 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ build
logs
out

*.config.toml
*.cfg
32 changes: 32 additions & 0 deletions booster-bomb/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
plugins {
kotlin("jvm")
}

group = "explode"
version = "1.0-SNAPSHOT"

repositories {
mavenCentral()
}

dependencies {
implementation(project(":booster"))
implementation(project(":labyrinth"))

// ktor shits
implementation("io.ktor:ktor-server-cors:2.1.2")
implementation("io.ktor:ktor-server-auth:2.1.2")
implementation("io.ktor:ktor-server-status-pages:2.1.2")
implementation("io.ktor:ktor-server-content-negotiation:2.1.2")
implementation("io.ktor:ktor-serialization-gson:2.1.2")

// gson (the most lenient json serializer, fuck off kotlinx.serialization!)
implementation("com.google.code.gson:gson:2.9.0")

testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.0")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.9.0")
}

tasks.getByName<Test>("test") {
useJUnitPlatform()
}
172 changes: 172 additions & 0 deletions booster-bomb/src/main/kotlin/explode2/booster/bomb/BombPlugin.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package explode2.booster.bomb

import com.google.gson.Gson
import explode2.booster.*
import explode2.booster.bomb.submods.*
import explode2.booster.bomb.submods.basic.WelcomeBO
import explode2.booster.bomb.submods.chart.chartModule
import explode2.booster.bomb.submods.chart.setModule
import explode2.booster.bomb.submods.extra.newSongModule
import explode2.booster.bomb.submods.user.userModule
import explode2.booster.event.KtorModuleEvent
import explode2.booster.event.RouteConfigure
import explode2.gateau.GameUser
import explode2.labyrinth.LabyrinthPlugin.Companion.labyrinth
import io.ktor.http.*
import io.ktor.serialization.gson.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.util.pipeline.*
import org.greenrobot.eventbus.Subscribe
import org.slf4j.LoggerFactory
import org.slf4j.MarkerFactory
import java.lang.reflect.Type
import java.util.UUID

const val BombApiVersion = 2 // 0 和 1 在 Explode-Kotlin 里,反正都是异坨屎
const val BombApiVersionPatch = 0

internal val logger = LoggerFactory.getLogger("Bomb")
internal val superstarMarker = MarkerFactory.getMarker("Superstar")
internal val configureMarker = MarkerFactory.getMarker("Configure")

class BombPlugin : BoosterPlugin {

override val id: String = "bomb-api"
override val version: String = "1.0.0"

private val pathRaw = config.getString("route-path", "general", "bomb/v{version}", "后端根地址")
private val useSuperstar = config.getBoolean("superstar-admin", "general", true, "启用 Superstar 账号")

// 后门账号,用于创建初始账号
private var superstar = UUID.randomUUID().toString()

init {
subscribeEvents()
saveConfig()

if(useSuperstar) {
logger.info(superstarMarker, "Superstar Enabled")
logger.info(superstarMarker, "Superstar: $superstar")
}
}

@Subscribe
fun ktor(event: KtorModuleEvent) {
val path = pathRaw
.replace("{version}", "$BombApiVersion") // {version} -> API大版本号
.replace("{version_patch}", "$BombApiVersionPatch") // {version_patch} -> API补丁版本号

logger.info(configureMarker, "Configuring Ktor Bomb Module: $path")

event.configure {
install(ContentNegotiation) {
gson {
setPrettyPrinting()
}
}

install(Authentication) {
basic {
realm = "Access to User Certification requested paths"
validate { c ->
if(useSuperstar && c.name == superstar) {
SuperstarPrincipal
} else {
val username = c.name
val password = c.password
val user = labyrinth.gameUserFactory.getGameUserByName(username)
logger.debug("Login requested with username \"$username\" and password \"$password\", found ${user != null}")
if(user != null && (user.validatePassword(password) || password == superstar)) { // 密码正确或者使用 Superstar
object : BombPrincipal {
override val user: GameUser = user
}
} else {
null
}
}
}
}
}

install(StatusPages) {
exception<Throwable> { call, cause ->
call.respondError(cause.toError("Uncaught Exception!"))
}
}

routing {
route(path, bombModule)
}
}
}
}

// 返回值序列化
private val gson = Gson()

// 随机欢迎语
private val welcomeMessages = listOf(
"Light or Dark — nothing changes. Everything dies.",
"“What is the Dark?” they ask. “What is its nature?” Why don’t they ask the same of the Light?",
"You asked a question. Welcome to the answer.",
"You ask a question of pure potential. Death is intrinsic to the answer.",
"Light and Dark. Heaven or hell. What’s the difference?"
)

internal inline val PipelineContext<*, ApplicationCall>.bombCall: BombApplicationCall get() = BombApplicationCall(context)
internal class BombApplicationCall(private val delegate: ApplicationCall) : ApplicationCall by delegate {
override fun toString(): String =
"BombApplicationCall(requestUri=${request.uri}, requestHeaders=${request.headers}, requestCookies=${request.cookies})"
}

private val bombModule: RouteConfigure = {

logger.debug(configureMarker, "[Module]: <default>")

// <GET>[/] 用来测试的接口
get {
bombCall.respondData(WelcomeBO(welcomeMessages.random()).toData())
}

// 用户接口模块
logger.debug(configureMarker, "[Module]: User")
route("user", userModule)
// 曲目接口模块
route("set", setModule)
// 谱面接口模块
route("chart", chartModule)
// 上传接口模块
route("upload", newSongModule)

}

internal suspend fun ApplicationCall.respondJson(content: Any?, typeOfSrc: Type? = null, contentType: ContentType? = null, status: HttpStatusCode? = null) {
runCatching {
if(typeOfSrc != null) { // 如果提供了 Type 就用
gson.toJson(content, typeOfSrc)
} else { // 否则就默认
gson.toJson(content)
}
}.onSuccess {
// 返回数据
respondText(it, contentType, status)
}.onFailure {
logger.warn("Exception occurred when parsing content into json: $content", it)
// 把异常返回
respondError(it.toError())
}
}

internal suspend fun <T> ApplicationCall.respondData(content: Data<T>) {
respondJson(content, status = HttpStatusCode.OK)
}

internal suspend fun ApplicationCall.respondError(content: Error) {
respondJson(content, status = HttpStatusCode.InternalServerError)
}
17 changes: 17 additions & 0 deletions booster-bomb/src/main/kotlin/explode2/booster/bomb/Localization.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package explode2.booster.bomb

object Localization {

const val NotAuthenticated = "NOT_AUTHENTICATED"

const val NotAuthenticatedOrUserNotFound = "NOT_AUTHENTICATED_OR_USER_NOT_FOUND"

const val UserNotFound = "USER_NOT_FOUND"

const val UserAlreadyExists = "USER_ALREADY_EXISTS"

const val UserLoginFailed = "USER_LOGIN_FAILED"

const val ResourceNotFound = "RESOURCE_NOT_FOUND"

}
7 changes: 7 additions & 0 deletions booster-bomb/src/main/kotlin/explode2/booster/bomb/Util.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package explode2.booster.bomb

infix fun Int.clamp(closure: IntRange): Int = when {
this < closure.first -> closure.first
this > closure.last -> closure.last
else -> this
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package explode2.booster.bomb.submods

data class Data<T>(
val success: Boolean,
val message: String?,
val data: T
)

fun <T> T.toData(message: String? = null, success: Boolean = true) =
Data(success, message, this)

data class Error(
val success: Boolean,
val message: String?,
val stackTrace: String?
)

data class ExceptionContext(val message: String, val exception: Throwable)

/**
* 根据 [Throwable] 生成 [Error] 实例
*/
fun Throwable.toError(message: String? = null, success: Boolean = false) =
Error(success, message ?: this.message, stackTraceToString())

fun toError(message: String, success: Boolean = false, needStackTrace: Boolean = false) =
Error(success, message, if(needStackTrace) Exception().stackTraceToString() else null)

fun toError(operationName: String, success: Boolean = false, contexts: List<ExceptionContext>) =
Error(success, buildString {
// single => Exception occurred when executing {OperationName}:
// multiple => Exception occurred multiple times when executing {OperationName}:
append("Exception occurred ")
if(contexts.size != 1) append("multiple times ")
append("when executing ")
append(operationName)
append(":")
appendLine()
// for each exception => [{SimpleClassName}] {Message of Context} - {Message of Exception}
contexts.forEach {
append("[")
append(it.exception.javaClass.simpleName)
append("] ")
append(it.message)
append(" - ")
append(it.exception.message)
appendLine()
}
}, null)
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package explode2.booster.bomb.submods

import explode2.booster.bomb.logger
import explode2.booster.bomb.superstarMarker
import explode2.gateau.*
import io.ktor.server.auth.*
import java.time.OffsetDateTime

interface BombPrincipal : Principal {

val user: GameUser?
}

object SuperstarPrincipal : BombPrincipal {
// 假玩家数据
override val user: GameUser = object : GameUser {
override val id: String = "00000000-0000-0000-0000-000000000000"
override var username: String = "SUPERSTAR"
override var coin: Int = Int.MAX_VALUE
override var diamond: Int = Int.MAX_VALUE
override var ppTime: OffsetDateTime = OffsetDateTime.MIN
override var isReviewer: Boolean = true
override val SongSet.isOwned: Boolean get() = true
override val ownedSets: List<SongSet> = listOf()
override fun calculateR(): Int = Int.MIN_VALUE
override fun calculateHighestGoldenMedal(): Int = 0
override fun changePassword(password: String) {
logger.warn(superstarMarker, "Unexpected \"changePassword($password)\" has been invoked!")
}
override fun validatePassword(password: String): Boolean {
logger.warn(superstarMarker, "Unexpected \"validatePassword($password)\" has been invoked!")
return false
}
override fun giveSet(setId: String) {
logger.warn(superstarMarker, "Unexpected \"giveSet($setId)\" has been invoked!")
}
override fun calculateLastRecords(limit: Int): List<GameRecord> = emptyList()
override fun calculateBestRecords(limit: Int, sortedBy: ScoreOrRanking): List<GameRecord> = emptyList()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package explode2.booster.bomb.submods.basic

internal data class WelcomeBO(val welcome: String)
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package explode2.booster.bomb.submods.chart

import explode2.booster.bomb.*
import explode2.booster.bomb.submods.toData
import explode2.booster.bomb.submods.toError
import explode2.booster.event.RouteConfigure
import explode2.labyrinth.LabyrinthPlugin.Companion.labyrinth
import io.ktor.server.routing.*
import io.ktor.server.util.*

// [/chart]
internal val chartModule: RouteConfigure = {

// [/chart/{id}]
get("{id}") {
val cid = bombCall.parameters.getOrFail("id")
val chart = labyrinth.songChartFactory.getSongChartById(cid)
?: return@get bombCall.respondError(toError(Localization.ResourceNotFound))
bombCall.respondData(chart.toBO().toData())
}

}

// [/set]
internal val setModule: RouteConfigure = {

// [/set/{id}]
get("{id}") {
val sid = bombCall.parameters.getOrFail("id")
val set = labyrinth.songSetFactory.getSongSetById(sid)
?: return@get bombCall.respondError(toError(Localization.ResourceNotFound))
bombCall.respondData(set.toBO().toData())
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package explode2.booster.bomb.submods.chart

import explode2.gateau.SongChart

internal data class ChartBO(
val id: String,
val difficulty_class: Int,
val difficulty_value: Int,
val d: Double?
)

internal fun SongChart.toBO() =
ChartBO(id, difficultyClass, difficultyValue, d)
Loading

0 comments on commit 95ed90d

Please sign in to comment.