CLI client, SSE

This commit is contained in:
2026-08-08 20:56:32 +02:00
parent 0e57de60b0
commit 8f4c6cc630
22 changed files with 611 additions and 220 deletions
@@ -0,0 +1,29 @@
package com.jaytux.phoebench.server
import com.jaytux.phoebench.common.AdminEvent
import com.jaytux.phoebench.common.HomeEvent
import com.jaytux.phoebench.common.ProjectEvent
import io.ktor.util.reflect.typeInfo
import kotlinx.serialization.serializer
import java.util.concurrent.ConcurrentHashMap
import kotlin.reflect.typeOf
import kotlin.uuid.Uuid
object Buses {
private val _projectBuses = ConcurrentHashMap<Uuid, SSEBus<ProjectEvent>>()
val homeBus = SSEBus<HomeEvent>(typeOf<HomeEvent>(), serializer<HomeEvent>())
val adminBus = SSEBus<AdminEvent>(typeOf<AdminEvent>(), serializer<AdminEvent>())
fun projectBus(id: Uuid) = _projectBuses.computeIfAbsent(id) {
SSEBus<ProjectEvent>(typeOf<ProjectEvent>(), serializer<ProjectEvent>())
}
fun allBuses(): List<SSEBus<*>> {
val res = ArrayList<SSEBus<*>>(_projectBuses.size + 2)
res.addAll(_projectBuses.values)
res.add(homeBus)
res.add(adminBus)
return res
}
}
@@ -1,24 +1,10 @@
package com.jaytux.phoebench.server
import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.HomeResponse
import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.fold
import com.jaytux.phoebench.common.*
import com.jaytux.phoebench.server.db.DB
import com.jaytux.phoebench.server.db.User
import com.jaytux.phoebench.server.handlers.AuthHandler
import com.jaytux.phoebench.server.handlers.ProjectHandler
import com.jaytux.phoebench.server.handlers.RouteError
import com.jaytux.phoebench.server.handlers.deleteAdmin
import com.jaytux.phoebench.server.handlers.deleteAuth
import com.jaytux.phoebench.server.handlers.get
import com.jaytux.phoebench.server.handlers.getAdmin
import com.jaytux.phoebench.server.handlers.getAuth
import com.jaytux.phoebench.server.handlers.patchAdmin
import com.jaytux.phoebench.server.handlers.patchAuth
import com.jaytux.phoebench.server.handlers.post
import com.jaytux.phoebench.server.handlers.postAdmin
import com.jaytux.phoebench.server.handlers.postAuth
import com.jaytux.phoebench.server.handlers.*
import com.jaytux.phoebench.server.handlers.ProjectHandler.accessibleProject
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
@@ -28,23 +14,15 @@ import io.ktor.server.netty.*
import io.ktor.server.plugins.autohead.*
import io.ktor.server.plugins.calllogging.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.plugins.cors.routing.CORS
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.plugins.cors.routing.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.sse.SSE
import io.ktor.server.sse.sse
import kotlinx.serialization.encodeToString
import io.ktor.server.sse.*
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import java.net.URI
import kotlin.uuid.Uuid
import com.jaytux.phoebench.server.handlers.RouteError.Companion.respondJson
import io.ktor.server.sse.heartbeat
import io.ktor.sse.ServerSentEvent
import io.ktor.utils.io.CancellationException
fun main(args: Array<String>) {
DB.db
@@ -156,59 +134,15 @@ fun Application.module() {
deleteAuth(Routes.Label.delete, ProjectHandler::deleteLabel)
postAuth(Routes.Entry.new, ProjectHandler::createEntry)
patchAuth(Routes.Entry.update, ProjectHandler::updateEntry)
deleteAuth(Routes.Entry.delete, ProjectHandler::deleteEntry)
val projectBus = SSEBus.register<HomeResponse.ProjectSummary>("/rt/project")
sse("/rt/project") {
try {
println("Attempt to set up SSE")
val principal = call.principal<JWTPrincipal>()
val userId = principal?.payload?.getClaim(com.jaytux.phoebench.common.Auth.JWT_CLAIM)?.asString()
?: throw RouteError("Missing user claim", HttpStatusCode.Unauthorized)
println(" -- SSE: userId = $userId")
val user = transaction {
User.findById(Uuid.parse(userId)) ?: throw RouteError(
"User not found",
HttpStatusCode.Unauthorized
)
}
println(" -- SSE: user = ${user.username}")
heartbeat {}
val flow = projectBus.register(user.id.value)
try {
flow.collect {
it.fold({
throw Exception() // force end of collecting
}) { event ->
send(ServerSentEvent(data = Json.encodeToString(projectBus.serializer, event)))
}
}
}
catch(e: CancellationException) {
projectBus.disconnect(user.id.value)
throw e
}
sseAuth(Routes.SSE.home, { _, _ -> }) { _, _ -> Buses.homeBus }
sseAdmin(Routes.SSE.admin) { _, _ -> Buses.adminBus }
sseAuth(Routes.SSE.projectSpecific,
{ user, uuid ->
transaction { accessibleProject(user, uuid, false) }
}
catch(e: RouteError) {
call.respondText(
status = e.status,
text = Json.encodeToString(ErrorResponse(e.message ?: "Unknown error")),
contentType = ContentType.Application.Json
)
}
println("--- SSE Session ended ---")
}
sse("/rt/users") {}
sse("/rt/invites") {}
sse("/rt/project/{project-id}") {}
) { _, id -> Buses.projectBus(id) }
}
get("{...}") {
@@ -4,6 +4,7 @@ import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.error
import com.jaytux.phoebench.common.value
import io.ktor.util.reflect.instanceOf
import io.ktor.websocket.Serializer
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.serialization.KSerializer
@@ -13,14 +14,17 @@ import kotlin.reflect.KType
import kotlin.reflect.typeOf
import kotlin.uuid.Uuid
class SSEBus<T> private constructor (private val _containedType: KType, val serializer: KSerializer<T>) {
class SSEBus<T>(private val _containedType: KType, val serializer: KSerializer<T>) {
object Cancellation
private val _unAuthFlow = MutableSharedFlow<T>()
private val _flows = ConcurrentHashMap<Uuid, Pair<Int, MutableSharedFlow<Either<Cancellation, T>>>>()
fun register(user: Uuid): SharedFlow<Either<Cancellation, T>> =
_flows.computeIfAbsent(user) { 1 to MutableSharedFlow(extraBufferCapacity = 64) }.second
fun unRegistered(): SharedFlow<T> = _unAuthFlow
suspend fun send(user: Uuid, data: T) {
_flows[user]?.second?.emit(data.value())
}
@@ -29,6 +33,10 @@ class SSEBus<T> private constructor (private val _containedType: KType, val seri
_flows.forEach { it.value.second.emit(data.value()) }
}
suspend fun sendUnAuth(data: T) {
_unAuthFlow.emit(data)
}
fun disconnect(user: Uuid) {
_flows.compute(user) { _, data ->
if(data == null) null
@@ -46,30 +54,11 @@ class SSEBus<T> private constructor (private val _containedType: KType, val seri
_flows.remove(user)
}
companion object {
private val _busCache = mutableMapOf<String, SSEBus<*>>()
fun <T> register(topic: String, contained: KType, serializer: KSerializer<T>): SSEBus<T> {
val bus = SSEBus<T>(contained, serializer)
_busCache.compute(topic) { k, existing ->
if(existing != null) throw IllegalArgumentException("Bus for $topic exists already")
bus
}
return bus
}
inline fun <reified T> register(topic: String) =
register<T>(topic, typeOf<T>(), serializer<T>())
fun <T> getBus(topic: String, contained: KType): SSEBus<T>? {
val bus = _busCache[topic] ?: return null
if(contained != bus._containedType) throw IllegalArgumentException("Type mismatch for bus for $topic")
@Suppress("UNCHECKED_CAST")
return bus as SSEBus<T>
}
inline fun <reified T> getBus(topic: String) =
getBus<T>(topic, typeOf<T>())
suspend fun forceDisconnectExcept(user: Uuid) {
synchronized(_flows) {
val map = _flows.values.filter { it != _flows[user] }
_flows.keys.retainAll(setOf(user))
map
}.forEach { it.second.emit(Cancellation.error()) }
}
}
@@ -2,6 +2,8 @@ package com.jaytux.phoebench.server.handlers
import com.jaytux.phoebench.common.*
import com.jaytux.phoebench.server.Auth
import com.jaytux.phoebench.server.Buses
import com.jaytux.phoebench.server.SSEBus
import com.jaytux.phoebench.server.db.Invite
import com.jaytux.phoebench.server.db.RefreshToken
import com.jaytux.phoebench.server.db.RefreshTokens
@@ -14,6 +16,7 @@ import com.jaytux.phoebench.server.nowPlusMinutes
import com.jaytux.phoebench.server.systemTZ
import io.ktor.http.HttpStatusCode
import io.ktor.util.logging.KtorSimpleLogger
import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit
import org.jetbrains.exposed.v1.core.Transaction
import org.jetbrains.exposed.v1.core.eq
@@ -71,6 +74,13 @@ object AuthHandler {
_hasOwner = true
}
logger.info("New user: ${user.username} (${user.id.value}; is admin? ${user.isAdmin})")
ServerScope.launch {
Buses.adminBus.sendAll(
AdminEvent.NewUser(
UserListResponse.UserData(user.id.value, user.username, user.isAdmin, user.projectLimit, 0)
)
)
}
val access = Auth.generate(user.id.value)
val refresh = newRefreshToken(user)
@@ -150,6 +160,9 @@ object AuthHandler {
inviteAsAdmin = if(user.isOwner) req.asAdmin else false
expires = nowPlus(48, DateTimeUnit.HOUR)
}
ServerScope.launch {
Buses.adminBus.sendAll(AdminEvent.NewInvite(InviteListResponse.Invite(invite.id.value, invite.expires, invite.inviteAsAdmin)))
}
success(UuidResponse(invite.id.value))
}
@@ -162,6 +175,9 @@ object AuthHandler {
suspend fun deleteInvite(user: User, req: Uuid) = transaction {
val inv = Invite.findById(req) ?: throw RouteError("Invalid invite code", HttpStatusCode.NotFound)
inv.delete()
ServerScope.launch {
Buses.adminBus.sendAll(AdminEvent.InviteDeleted(req))
}
success(EmptyResponse())
}
@@ -184,6 +200,16 @@ object AuthHandler {
if(user.isOwner) target.isAdmin = it
else throw RouteError("Only the server owner can change admin status.", HttpStatusCode.Forbidden)
}
ServerScope.launch {
Buses.adminBus.sendAll(
AdminEvent.UserChanged(
UserListResponse.UserData(user.id.value, user.username, user.isAdmin, user.projectLimit, 0)
)
)
if(changes.isAdmin == false) Buses.adminBus.forceDisconnect(req.first)
}
success(EmptyResponse())
}
@@ -195,6 +221,10 @@ object AuthHandler {
target.delete()
}
else throw RouteError("Only the owner can delete admin accounts.", HttpStatusCode.Forbidden)
ServerScope.launch {
Buses.adminBus.sendAll(AdminEvent.UserDeleted(req))
Buses.allBuses().forEach { it.forceDisconnect(req) }
}
success(EmptyResponse())
}
@@ -4,20 +4,41 @@ import com.jaytux.phoebench.server.handlers.RouteError.Companion.wrapped
import com.jaytux.phoebench.server.handlers.RouteError.Companion.wrappedAdmin
import com.jaytux.phoebench.server.handlers.RouteError.Companion.wrappedAuth
import com.jaytux.phoebench.common.ApiRoute
import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.Elevation
import com.jaytux.phoebench.common.EmptyRequest
import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.SSERoute
import com.jaytux.phoebench.common.foldSuspend
import com.jaytux.phoebench.server.Auth.setup
import com.jaytux.phoebench.server.SSEBus
import com.jaytux.phoebench.server.db.User
import com.jaytux.phoebench.server.handlers.RouteError
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.principal
import io.ktor.server.plugins.BadRequestException
import io.ktor.server.plugins.ContentTransformationException
import io.ktor.server.request.receive
import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
import io.ktor.server.routing.RoutingContext
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.delete
import io.ktor.server.routing.patch
import io.ktor.server.sse.heartbeat
import io.ktor.server.sse.sse
import io.ktor.sse.ServerSentEvent
import io.ktor.util.reflect.typeInfo
import io.ktor.utils.io.CancellationException
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.time.Duration.Companion.seconds
import kotlin.uuid.Uuid
suspend inline fun <reified TReq: Any> ApiRoute<TReq, *>.paramArgs(call: ApplicationCall): TReq =
parseParams(call.parameters) ?: throw RouteError(
@@ -140,4 +161,101 @@ inline fun <reified TReq: Any, reified TRes: Any> Route.patchAuth(api: ApiRoute<
wrapperAuth(api, Route::patch, handler)
inline fun <reified TReq: Any, reified TRes: Any> Route.patchAdmin(api: ApiRoute<TReq, TRes>, noinline handler: suspend (User, TReq) -> Pair<HttpStatusCode, TRes>): Route =
wrapperAdmin(api, Route::patch, handler)
wrapperAdmin(api, Route::patch, handler)
inline fun <reified TParams: Any, reified TEvent: Any, TInter, TFlow> Route.wrapSSE(
api: SSERoute<TParams, TEvent>, noinline extra: suspend (ApplicationCall, TParams) -> TInter,
noinline prepare: suspend (TInter, TParams) -> SSEBus<TEvent>,
noinline extract: suspend (SSEBus<TEvent>, TInter, TParams) -> SharedFlow<TFlow>,
noinline handler: suspend (TFlow, sender: suspend (TEvent) -> Unit) -> Unit,
noinline onCancel: suspend (SSEBus<TEvent>, TInter, TParams, CancellationException) -> Unit
) {
sse(api.pattern) {
try {
heartbeat {
period = 1.seconds
}
val params = api.extractParams(call.parameters) ?: throw RouteError(
"Missing or malformed parameters for SSE ${api.pattern}",
HttpStatusCode.BadRequest
)
val inter = extra(call, params)
val bus = prepare(inter, params)
val stream = extract(bus, inter, params)
try {
stream.collect {
handler(it) { ev -> send(ServerSentEvent(data = Json.encodeToString(bus.serializer, ev))) }
}
}
catch(e: CancellationException) {
onCancel(bus, inter, params, e)
throw e
}
}
catch(e: BadRequestException) {
call.respondText(
status = HttpStatusCode.BadRequest,
text = Json.encodeToString(ErrorResponse(e.message ?: "Unknown error")),
contentType = ContentType.Application.Json
)
}
catch(e: RouteError) {
call.respondText(
status = e.status,
text = Json.encodeToString(ErrorResponse(e.message ?: "Unknown error")),
contentType = ContentType.Application.Json
)
}
}
}
inline fun <reified TParams: Any, reified TEvent: Any> Route.wrapAuthSSE(
api: SSERoute<TParams, TEvent>,
noinline verifyUser: suspend (User, TParams) -> Unit,
noinline prepare: suspend (User, TParams) -> SSEBus<TEvent>
) = wrapSSE(api,
extra = { call, params ->
val principal = call.principal<JWTPrincipal>()
val userId = principal?.payload?.getClaim(com.jaytux.phoebench.common.Auth.JWT_CLAIM)?.asString()
?: throw RouteError("Missing user claim", HttpStatusCode.Unauthorized)
val user = transaction {
User.findById(Uuid.parse(userId)) ?: throw RouteError(
"User not found",
HttpStatusCode.Unauthorized
)
}
verifyUser(user, params)
user
},
prepare = prepare,
extract = { bus, user, _ -> bus.register(user.id.value) },
handler = { event, sender ->
event.foldSuspend({
throw RouteError("This event stream has been discontinued.", HttpStatusCode.Locked)
}) { sender(it) }
},
onCancel = { bus, user, _, _ -> bus.disconnect(user.id.value) }
)
inline fun <reified TParams: Any, reified TEvent: Any> Route.sse(api: SSERoute<TParams, TEvent>, noinline setup: suspend (TParams) -> SSEBus<TEvent>) =
wrapSSE(api,
extra = { _, _ -> },
prepare = { _, params -> setup(params) },
extract = { bus, _, _ -> bus.unRegistered() },
handler = { it, sender -> sender(it) },
onCancel = { _, _, _, _ -> }
)
inline fun <reified TParams: Any, reified TEvent: Any> Route.sseAuth(api: SSERoute<TParams, TEvent>,
noinline verifyUser: suspend (User, TParams) -> Unit, noinline setup: suspend (User, TParams) -> SSEBus<TEvent>
) = wrapAuthSSE(api, verifyUser, setup)
inline fun <reified TParams: Any, reified TEvent: Any> Route.sseAdmin(api: SSERoute<TParams, TEvent>,
noinline setup: suspend (User, TParams) -> SSEBus<TEvent>
) = wrapAuthSSE(api, { user, _ ->
if(!user.isAdmin) {
throw RouteError("Admin access required", HttpStatusCode.Forbidden)
}
}, setup)
@@ -4,6 +4,7 @@ import com.jaytux.phoebench.common.EmptyRequest
import com.jaytux.phoebench.common.EmptyResponse
import com.jaytux.phoebench.common.EntryRequest
import com.jaytux.phoebench.common.EntryResponse
import com.jaytux.phoebench.common.HomeEvent
import com.jaytux.phoebench.common.HomeResponse
import com.jaytux.phoebench.common.LabelRequest
import com.jaytux.phoebench.common.LabelResponse
@@ -11,8 +12,10 @@ import com.jaytux.phoebench.common.NamedID
import com.jaytux.phoebench.common.PartialEntryRequest
import com.jaytux.phoebench.common.PartialLabelRequest
import com.jaytux.phoebench.common.PartialProjectRequest
import com.jaytux.phoebench.common.ProjectEvent
import com.jaytux.phoebench.common.ProjectRequest
import com.jaytux.phoebench.common.ProjectResponse
import com.jaytux.phoebench.server.Buses
import com.jaytux.phoebench.server.SSEBus
import com.jaytux.phoebench.server.db.Entries
import com.jaytux.phoebench.server.db.Entry
@@ -23,6 +26,8 @@ import com.jaytux.phoebench.server.db.Projects
import com.jaytux.phoebench.server.db.User
import com.jaytux.phoebench.server.handlers.RouteError.Companion.success
import io.ktor.http.HttpStatusCode
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.Transaction
import org.jetbrains.exposed.v1.core.eq
@@ -31,10 +36,10 @@ import kotlin.uuid.Uuid
object ProjectHandler {
context(trns: Transaction)
private fun Project.isEditableBy(user: User): Boolean = ownerId.value == user.id.value
fun Project.isEditableBy(user: User): Boolean = ownerId.value == user.id.value
context(trns: Transaction)
private fun Project.isAccessible(user: User, forEditing: Boolean): Project {
fun Project.isAccessible(user: User, forEditing: Boolean): Project {
return when {
isEditableBy(user) -> this
isPublic && !forEditing -> this
@@ -42,7 +47,7 @@ object ProjectHandler {
}
}
private fun Transaction.accessibleProject(user: User, id: Uuid, forEditing: Boolean): Project {
fun Transaction.accessibleProject(user: User, id: Uuid, forEditing: Boolean): Project {
val project = Project.findById(id) ?: throw RouteError("Invalid project ID.", HttpStatusCode.NotFound)
return project.isAccessible(user, forEditing)
}
@@ -77,10 +82,12 @@ object ProjectHandler {
success(proj.toResponse(user))
}.also { (_, proj) ->
val bus = SSEBus.getBus<HomeResponse.ProjectSummary>("/rt/project") ?: return@also
val summary = HomeResponse.ProjectSummary(proj.id, proj.name, proj.isPublic, NamedID(user.username, user.id.value))
if(proj.isPublic) bus.sendAll(summary)
else bus.send(user.id.value, summary)
ServerScope.launch {
val bus = Buses.homeBus
val summary = HomeResponse.ProjectSummary(proj.id, proj.name, proj.isPublic, NamedID(user.username, user.id.value))
if(proj.isPublic) bus.sendAll(HomeEvent.Created(summary))
else bus.send(user.id.value, HomeEvent.Created(summary))
}
}
fun getProject(user: User, req: Uuid) = transaction {
@@ -93,11 +100,30 @@ object ProjectHandler {
val changes = req.second
changes.name?.let { proj.name = it }
changes.isPublic?.let { proj.isPublic = it }
ServerScope.launch {
val bus = Buses.homeBus
val summary = HomeResponse.ProjectSummary(proj.id.value, proj.name, proj.isPublic, NamedID(user.username, user.id.value))
if(proj.isPublic) bus.sendAll(HomeEvent.Changed(summary))
else bus.send(user.id.value, HomeEvent.Changed(summary))
val projectBus = Buses.projectBus(req.first)
projectBus.sendAll(ProjectEvent.Updated(summary))
if(changes.isPublic == false) projectBus.forceDisconnectExcept(proj.owner.id.value)
}
success(EmptyResponse())
}
fun deleteProject(user: User, req: Uuid) = transaction {
accessibleProject(user, req, true).delete()
val proj = accessibleProject(user, req, true)
proj.delete()
ServerScope.launch {
val bus = Buses.homeBus
if(proj.isPublic) bus.sendAll(HomeEvent.Deleted(req))
else bus.send(user.id.value, HomeEvent.Deleted(req))
Buses.projectBus(req).sendAll(ProjectEvent.Deleted)
}
success(EmptyResponse())
}
@@ -109,7 +135,13 @@ object ProjectHandler {
color = req.color
project = proj
}
success(LabelResponse(lbl.id.value, lbl.label, lbl.color))
val res = LabelResponse(lbl.id.value, lbl.label, lbl.color)
ServerScope.launch {
Buses.projectBus(req.projectId).sendAll(ProjectEvent.NewLabel(res))
}
success(res)
}
fun updateLabel(user: User, req: Pair<Uuid, PartialLabelRequest>) = transaction {
@@ -121,6 +153,13 @@ object ProjectHandler {
if(it.length != 7) throw RouteError("Expected Hex-RGB color (7 characters).", HttpStatusCode.BadRequest)
lbl.color = it
}
ServerScope.launch {
Buses.projectBus(lbl.projectId.value).sendAll(ProjectEvent.LabelChanged(
LabelResponse(lbl.id.value, lbl.label, lbl.color)
))
}
success(EmptyResponse())
}
@@ -128,6 +167,11 @@ object ProjectHandler {
val lbl = Label.findById(req) ?: throw RouteError("Invalid label ID.", HttpStatusCode.NotFound)
lbl.project.isAccessible(user, true)
lbl.delete()
ServerScope.launch {
Buses.projectBus(lbl.id.value).sendAll(ProjectEvent.LabelDeleted(req))
}
success(EmptyResponse())
}
@@ -145,32 +189,21 @@ object ProjectHandler {
warmups = req.warmups
unit = req.unit
}
success(EntryResponse(entry.id.value, entry.label.id.value, entry.timestamp, entry.warmups, entry.measurements, entry.unit))
}
fun updateEntry(user: User, req: Pair<Uuid, PartialEntryRequest>) = transaction {
val entry = Entry.findById(req.first) ?: throw RouteError("Invalid entry ID.", HttpStatusCode.NotFound)
entry.project.isAccessible(user, true)
val changes = req.second
changes.label?.let {
val lbl = Label.findById(it)
when {
lbl == null -> throw RouteError("Invalid label ID.", HttpStatusCode.NotFound)
lbl.projectId.value != entry.projectId.value -> throw RouteError("Label is attached to a different project.", HttpStatusCode.Conflict)
else -> entry.label = lbl
}
val response = EntryResponse(entry.id.value, entry.label.id.value, entry.timestamp, entry.warmups, entry.measurements, entry.unit)
ServerScope.launch {
Buses.projectBus(proj.id.value).sendAll(ProjectEvent.NewEntry(response))
}
changes.timestamp?.let { entry.timestamp = it }
changes.warmups?.let { entry.warmups = it }
changes.measurements?.let { entry.measurements = it }
changes.unit?.let { entry.unit = it }
success(EmptyResponse())
success(response)
}
fun deleteEntry(user: User, req: Uuid) = transaction {
val entry = Entry.findById(req) ?: throw RouteError("Invalid entry ID.", HttpStatusCode.NotFound)
entry.project.isAccessible(user, true)
entry.delete()
ServerScope.launch {
Buses.projectBus(entry.project.id.value).sendAll(ProjectEvent.EntryDeleted(req))
}
success(EmptyResponse())
}
}
@@ -0,0 +1,11 @@
package com.jaytux.phoebench.server.handlers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlin.coroutines.CoroutineContext
object ServerScope : CoroutineScope {
private val _actualScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override val coroutineContext: CoroutineContext = _actualScope.coroutineContext
}
@@ -1 +0,0 @@
org.slf4j.simpleLogger.log.io.ktor.server.plugins.cors.CORS=trace