CLI client, SSE
This commit is contained in:
@@ -102,7 +102,7 @@ object CLI {
|
|||||||
data class ID(val id: Uuid) : IProjectIdentification, ILabelIdentification
|
data class ID(val id: Uuid) : IProjectIdentification, ILabelIdentification
|
||||||
|
|
||||||
sealed interface IData<T> {
|
sealed interface IData<T> {
|
||||||
abstract fun toList(): List<T>
|
fun toList(): List<T>
|
||||||
}
|
}
|
||||||
data class DirectData<T>(val data: List<T>) : IData<T> {
|
data class DirectData<T>(val data: List<T>) : IData<T> {
|
||||||
override fun toList(): List<T> = data
|
override fun toList(): List<T> = data
|
||||||
@@ -110,6 +110,7 @@ object CLI {
|
|||||||
data class FileData<T>(val file: InputStream, val parse: (String) -> T?) : IData<T> {
|
data class FileData<T>(val file: InputStream, val parse: (String) -> T?) : IData<T> {
|
||||||
override fun toList(): List<T> {
|
override fun toList(): List<T> {
|
||||||
val raw = file.bufferedReader().use { it.readText() }.split(',')
|
val raw = file.bufferedReader().use { it.readText() }.split(',')
|
||||||
|
|
||||||
val parsed = ArrayList<T>(raw.size)
|
val parsed = ArrayList<T>(raw.size)
|
||||||
val errors = mutableListOf<String>()
|
val errors = mutableListOf<String>()
|
||||||
raw.forEach {
|
raw.forEach {
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ object ProjectHandlers {
|
|||||||
}
|
}
|
||||||
val warmupData = warmup.ensure("warmup").toList()
|
val warmupData = warmup.ensure("warmup").toList()
|
||||||
val measureData = measurement.ensure("measurements").toList()
|
val measureData = measurement.ensure("measurements").toList()
|
||||||
|
println("Loaded ${warmupData.size} warmup elements, ${measureData.size} measurements")
|
||||||
val timeUnit = unit.maybePrompt("time unit for data") {
|
val timeUnit = unit.maybePrompt("time unit for data") {
|
||||||
when(it) {
|
when(it) {
|
||||||
in setOf("ns", "nano", "nanosec") -> TimeUnit.NANOS
|
in setOf("ns", "nano", "nanosec") -> TimeUnit.NANOS
|
||||||
|
|||||||
@@ -28,14 +28,11 @@ class AuthProvider private constructor() {
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
_refresh.value = _refreshAccessor.load()
|
_refresh.value = _refreshAccessor.load()
|
||||||
println("Refresh token: ${_refresh.value}")
|
|
||||||
_server.value = _serverAccessor.load()
|
_server.value = _serverAccessor.load()
|
||||||
println("Server: ${_server.value}")
|
|
||||||
if(_server.value == null) onLogout()
|
if(_server.value == null) onLogout()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setServer(server: String, protocol: String) {
|
fun setServer(server: String, protocol: String) {
|
||||||
println("Setting server to $server, protocol version $protocol")
|
|
||||||
_server.value = server
|
_server.value = server
|
||||||
_protocolVersion.value = protocol
|
_protocolVersion.value = protocol
|
||||||
_serverAccessor.save(server)
|
_serverAccessor.save(server)
|
||||||
@@ -45,7 +42,6 @@ class AuthProvider private constructor() {
|
|||||||
fun onLogin(tokens: TokenResponse) {
|
fun onLogin(tokens: TokenResponse) {
|
||||||
_lock.withLock {
|
_lock.withLock {
|
||||||
if (server.value == null) throw IllegalStateException("Server is null, cannot log in.")
|
if (server.value == null) throw IllegalStateException("Server is null, cannot log in.")
|
||||||
println("Saving new refresh token (${tokens.refresh})")
|
|
||||||
_refresh.value = tokens.refresh
|
_refresh.value = tokens.refresh
|
||||||
_access.value = tokens.access
|
_access.value = tokens.access
|
||||||
_refreshAccessor.save(tokens.refresh)
|
_refreshAccessor.save(tokens.refresh)
|
||||||
@@ -55,7 +51,6 @@ class AuthProvider private constructor() {
|
|||||||
|
|
||||||
fun onRefresh(tokens: TokenResponse) {
|
fun onRefresh(tokens: TokenResponse) {
|
||||||
_lock.withLock {
|
_lock.withLock {
|
||||||
println("Saving new refresh token (${tokens.refresh})")
|
|
||||||
_refresh.value = tokens.refresh
|
_refresh.value = tokens.refresh
|
||||||
_access.value = tokens.access
|
_access.value = tokens.access
|
||||||
_refreshAccessor.save(tokens.refresh)
|
_refreshAccessor.save(tokens.refresh)
|
||||||
@@ -64,7 +59,6 @@ class AuthProvider private constructor() {
|
|||||||
|
|
||||||
fun onLogout() {
|
fun onLogout() {
|
||||||
_lock.withLock {
|
_lock.withLock {
|
||||||
println("Erasing refresh token ${_refresh.value}")
|
|
||||||
_refresh.value = null
|
_refresh.value = null
|
||||||
_access.value = null
|
_access.value = null
|
||||||
_refreshAccessor.erase()
|
_refreshAccessor.erase()
|
||||||
@@ -73,7 +67,6 @@ class AuthProvider private constructor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun asBearer(): BearerTokens? = _lock.withLock {
|
fun asBearer(): BearerTokens? = _lock.withLock {
|
||||||
println("Bearer tokens requested (access: ${access.value}, refresh: ${_refresh.value})")
|
|
||||||
BearerTokens(
|
BearerTokens(
|
||||||
accessToken = _access.value ?: return null,
|
accessToken = _access.value ?: return null,
|
||||||
refreshToken = _refresh.value?.toString() ?: return null
|
refreshToken = _refresh.value?.toString() ?: return null
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.jaytux.phoebench.clients
|
|||||||
|
|
||||||
import com.jaytux.phoebench.common.*
|
import com.jaytux.phoebench.common.*
|
||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
|
import io.ktor.client.plugins.*
|
||||||
import io.ktor.client.plugins.auth.*
|
import io.ktor.client.plugins.auth.*
|
||||||
import io.ktor.client.plugins.auth.providers.*
|
import io.ktor.client.plugins.auth.providers.*
|
||||||
import io.ktor.client.plugins.contentnegotiation.*
|
import io.ktor.client.plugins.contentnegotiation.*
|
||||||
@@ -10,8 +11,6 @@ import io.ktor.serialization.kotlinx.json.*
|
|||||||
import io.ktor.utils.io.*
|
import io.ktor.utils.io.*
|
||||||
import kotlinx.atomicfu.locks.ReentrantLock
|
import kotlinx.atomicfu.locks.ReentrantLock
|
||||||
import kotlinx.atomicfu.locks.withLock
|
import kotlinx.atomicfu.locks.withLock
|
||||||
import kotlinx.serialization.KSerializer
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
class Client private constructor(private val _auth: AuthProvider) {
|
class Client private constructor(private val _auth: AuthProvider) {
|
||||||
@@ -19,7 +18,6 @@ class Client private constructor(private val _auth: AuthProvider) {
|
|||||||
install(ContentNegotiation) { json() }
|
install(ContentNegotiation) { json() }
|
||||||
}
|
}
|
||||||
private val _client = platformClient {
|
private val _client = platformClient {
|
||||||
var tryingRefresh = false
|
|
||||||
install(ContentNegotiation) { json() }
|
install(ContentNegotiation) { json() }
|
||||||
|
|
||||||
install(SSE) {
|
install(SSE) {
|
||||||
@@ -28,38 +26,33 @@ class Client private constructor(private val _auth: AuthProvider) {
|
|||||||
bufferPolicy = SSEBufferPolicy.LastEvents(5)
|
bufferPolicy = SSEBufferPolicy.LastEvents(5)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
install(HttpTimeout) {
|
||||||
|
requestTimeoutMillis = 10000
|
||||||
|
socketTimeoutMillis = 10000
|
||||||
|
}
|
||||||
|
|
||||||
install(Auth) {
|
install(Auth) {
|
||||||
bearer {
|
bearer {
|
||||||
cacheTokens = false
|
cacheTokens = false
|
||||||
|
|
||||||
loadTokens {
|
loadTokens {
|
||||||
val res = _auth.asBearer()
|
val res = _auth.asBearer()
|
||||||
println("Client requested bearer tokens and got $res")
|
|
||||||
res
|
res
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshTokens {
|
refreshTokens {
|
||||||
if (tryingRefresh) {
|
val ref = _auth.refresh.value ?: return@refreshTokens null
|
||||||
|
|
||||||
|
val res = callRoute(_authClient, Routes.Auth.refresh, RefreshRequest(ref), true).foldSuspend({
|
||||||
|
if (it.msg.startsWith(COROUTINE_CANCELLED)) println("Coro calling refresh was cancelled")
|
||||||
_auth.onLogout()
|
_auth.onLogout()
|
||||||
null
|
null
|
||||||
} else {
|
}) {
|
||||||
tryingRefresh = true
|
_auth.onRefresh(it)
|
||||||
val ref = _auth.refresh.value ?: return@refreshTokens null
|
val res = _auth.asBearer()
|
||||||
println("Trying to re-authenticate using $ref")
|
|
||||||
|
|
||||||
val res = callRoute(_authClient, Routes.Auth.refresh, RefreshRequest(ref), true).foldSuspend({
|
|
||||||
if (it.msg.startsWith(COROUTINE_CANCELLED)) println("Coro calling refresh was cancelled")
|
|
||||||
_auth.onLogout()
|
|
||||||
null
|
|
||||||
}) {
|
|
||||||
_auth.onRefresh(it)
|
|
||||||
val res = _auth.asBearer()
|
|
||||||
println("Client requested bearer tokens (from refresh) and got $res")
|
|
||||||
res
|
|
||||||
}
|
|
||||||
tryingRefresh = false
|
|
||||||
res
|
res
|
||||||
}
|
}
|
||||||
|
res
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,14 +87,19 @@ class Client private constructor(private val _auth: AuthProvider) {
|
|||||||
): Either<ErrorResponse, TRes> =
|
): Either<ErrorResponse, TRes> =
|
||||||
callRoute(_client, route, body)
|
callRoute(_client, route, body)
|
||||||
|
|
||||||
suspend fun <T> connectSSE(route: String, serializer: KSerializer<T>, onEvent: suspend (T) -> Unit) {
|
suspend fun <TParams : Any, TEvent : Any> callSSE(
|
||||||
println("Client tries to set up SSE to $route")
|
route: SSERoute<TParams, TEvent>,
|
||||||
val server = _auth.server.value ?: throw IllegalStateException("No server URL set.")
|
params: TParams,
|
||||||
_client.sse(urlString = "$server$route", showCommentEvents = true, showRetryEvents = true) {
|
handler: suspend (Either<ErrorResponse, TEvent>) -> Unit
|
||||||
incoming.collect {
|
): Either<ErrorResponse, Unit> {
|
||||||
println("RECEIVE: $it")
|
try {
|
||||||
if(it.data != null) onEvent(Json.decodeFromString(serializer, it.data!!))
|
println("SSE connection to ${route.path} using server ${_auth.server.value}")
|
||||||
}
|
val client =
|
||||||
|
IClient.Default(_client, _auth.server.value ?: throw IllegalStateException("No server URL set."))
|
||||||
|
return route.call(client, params, handler)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
handler(ErrorResponse("Event stream connected to ${route.path} was cancelled.").error())
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ fun <T> MutableState<T>.immutable(): State<T> = this
|
|||||||
fun <T> T.ignore() {}
|
fun <T> T.ignore() {}
|
||||||
|
|
||||||
inline fun <reified R> ViewModel.withScope(crossinline block: suspend () -> R) = viewModelScope.launch {
|
inline fun <reified R> ViewModel.withScope(crossinline block: suspend () -> R) = viewModelScope.launch {
|
||||||
withContext(Dispatchers.Unconfined) { block() }
|
withContext(Dispatchers.Default) { block() }
|
||||||
}
|
}
|
||||||
|
|
||||||
val formatter = LocalDateTime.Format {
|
val formatter = LocalDateTime.Format {
|
||||||
@@ -83,3 +83,11 @@ fun Float.fmt(): String {
|
|||||||
val decInt = (decimals * 1000).roundToInt().toFloat() / 1000f
|
val decInt = (decimals * 1000).roundToInt().toFloat() / 1000f
|
||||||
return (integer + decInt).toString()
|
return (integer + decInt).toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inline fun <T, X : Comparable<X>> List<T>.insort(elem: T, crossinline sortBy: (T) -> X): List<T> {
|
||||||
|
val insertionPoint = binarySearchBy(sortBy(elem), selector = sortBy)
|
||||||
|
if(insertionPoint >= 0) return this
|
||||||
|
val index = -insertionPoint - 1
|
||||||
|
|
||||||
|
return toMutableList().apply { add(index, elem) }
|
||||||
|
}
|
||||||
@@ -9,11 +9,17 @@ import com.jaytux.phoebench.clients.SnackProvider
|
|||||||
import com.jaytux.phoebench.clients.SnackProvider.Companion.snackOnError
|
import com.jaytux.phoebench.clients.SnackProvider.Companion.snackOnError
|
||||||
import com.jaytux.phoebench.clients.SnackProvider.Companion.snackOr
|
import com.jaytux.phoebench.clients.SnackProvider.Companion.snackOr
|
||||||
import com.jaytux.phoebench.clients.immutable
|
import com.jaytux.phoebench.clients.immutable
|
||||||
|
import com.jaytux.phoebench.clients.insort
|
||||||
import com.jaytux.phoebench.clients.toClipEntry
|
import com.jaytux.phoebench.clients.toClipEntry
|
||||||
import com.jaytux.phoebench.clients.withScope
|
import com.jaytux.phoebench.clients.withScope
|
||||||
|
import com.jaytux.phoebench.common.AdminEvent
|
||||||
|
import com.jaytux.phoebench.common.Either
|
||||||
|
import com.jaytux.phoebench.common.HomeEvent
|
||||||
import com.jaytux.phoebench.common.HomeResponse
|
import com.jaytux.phoebench.common.HomeResponse
|
||||||
import com.jaytux.phoebench.common.InviteListResponse
|
import com.jaytux.phoebench.common.InviteListResponse
|
||||||
|
import com.jaytux.phoebench.common.Routes
|
||||||
import com.jaytux.phoebench.common.UserListResponse
|
import com.jaytux.phoebench.common.UserListResponse
|
||||||
|
import com.jaytux.phoebench.common.value
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.serialization.serializer
|
import kotlinx.serialization.serializer
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
@@ -22,7 +28,8 @@ class HomeVM(
|
|||||||
private val _client: Client = Client.get(),
|
private val _client: Client = Client.get(),
|
||||||
private val _auth: AuthProvider = AuthProvider.get(),
|
private val _auth: AuthProvider = AuthProvider.get(),
|
||||||
private val _snack: SnackProvider = SnackProvider.get(),
|
private val _snack: SnackProvider = SnackProvider.get(),
|
||||||
private val _repo: IHomeRepo = IHomeRepo.default(_client)
|
private val _repo: IHomeRepo = IHomeRepo.default(_client),
|
||||||
|
private val _sseRepo: ISSERepo = ISSERepo.default(_client)
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _username = mutableStateOf<String?>(null)
|
private val _username = mutableStateOf<String?>(null)
|
||||||
private val _isAdmin = mutableStateOf(false)
|
private val _isAdmin = mutableStateOf(false)
|
||||||
@@ -33,8 +40,6 @@ class HomeVM(
|
|||||||
private val _users = mutableStateOf(listOf<UserListResponse.UserData>())
|
private val _users = mutableStateOf(listOf<UserListResponse.UserData>())
|
||||||
private val _invites = mutableStateOf(listOf<InviteListResponse.Invite>())
|
private val _invites = mutableStateOf(listOf<InviteListResponse.Invite>())
|
||||||
|
|
||||||
private var _tokenWasNull = _auth.refresh.value == null
|
|
||||||
|
|
||||||
val username = _username.immutable()
|
val username = _username.immutable()
|
||||||
val isAdmin = _isAdmin.immutable()
|
val isAdmin = _isAdmin.immutable()
|
||||||
val projectLimit = _projectLimit.immutable()
|
val projectLimit = _projectLimit.immutable()
|
||||||
@@ -45,8 +50,15 @@ class HomeVM(
|
|||||||
val users = _users.immutable()
|
val users = _users.immutable()
|
||||||
|
|
||||||
private var _listenJob: Job? = null
|
private var _listenJob: Job? = null
|
||||||
|
private var _adminJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
_listenJob = withScope {
|
||||||
|
_sseRepo.connectHome {
|
||||||
|
it.snackOr(this::homeEventHandler)
|
||||||
|
}.snackOnError()
|
||||||
|
}
|
||||||
|
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,24 +77,7 @@ class HomeVM(
|
|||||||
_publicProjects.value = listOf()
|
_publicProjects.value = listOf()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshIfNeeded() {
|
|
||||||
println("Check for refresh: $_tokenWasNull && ${_auth.refresh.value != null}")
|
|
||||||
if(_tokenWasNull && _auth.refresh.value != null) refresh()
|
|
||||||
_tokenWasNull = _auth.refresh.value == null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
_listenJob = withScope {
|
|
||||||
_client.connectSSE("/rt/project", serializer<HomeResponse.ProjectSummary>()) {
|
|
||||||
if(it.owner.name == _username.value) {
|
|
||||||
if(_ownProjects.value.none { p -> p.id == it.id }) _ownProjects.value += it
|
|
||||||
}
|
|
||||||
if(it.isPublic) {
|
|
||||||
if(_publicProjects.value.none { p -> p.id == it.id }) _publicProjects.value += it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
withScope {
|
withScope {
|
||||||
resetAdmin()
|
resetAdmin()
|
||||||
_repo.getHome().snackOr {
|
_repo.getHome().snackOr {
|
||||||
@@ -90,24 +85,30 @@ class HomeVM(
|
|||||||
_username.value = it.username
|
_username.value = it.username
|
||||||
_isAdmin.value = it.isAdmin
|
_isAdmin.value = it.isAdmin
|
||||||
_projectLimit.value = it.projectLimit
|
_projectLimit.value = it.projectLimit
|
||||||
_ownProjects.value = it.ownProjects
|
_ownProjects.value = it.ownProjects.sortedBy { p -> p.name }
|
||||||
_publicProjects.value = it.publicProjects
|
_publicProjects.value = it.publicProjects.sortedBy { p -> p.name }
|
||||||
refreshAdmin()
|
refreshAdmin()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshAdmin() {
|
fun refreshAdmin() {
|
||||||
|
if(_isAdmin.value) {
|
||||||
|
_adminJob = withScope {
|
||||||
|
_sseRepo.connectAdmin {
|
||||||
|
it.snackOr(this::adminEventHandler)
|
||||||
|
}.snackOnError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
withScope {
|
withScope {
|
||||||
if(_isAdmin.value) {
|
if(_isAdmin.value) {
|
||||||
_repo.inviteList().snackOr { r ->
|
_repo.inviteList().snackOr { r ->
|
||||||
_invites.value = r.uuids.map {
|
_invites.value = r.uuids
|
||||||
it.copy(expires = it.expires)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_repo.userList().snackOr { r ->
|
_repo.userList().snackOr { r ->
|
||||||
_users.value = r.users
|
_users.value = r.users.sortedBy { it.name }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -117,6 +118,42 @@ class HomeVM(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun homeEventHandler(event: HomeEvent) {
|
||||||
|
when (event) {
|
||||||
|
is HomeEvent.Changed -> {
|
||||||
|
arrayOf(_ownProjects, _publicProjects).forEach { state ->
|
||||||
|
state.value = state.value.filter { p -> p.id != event.summary.id }
|
||||||
|
.insort(event.summary, HomeResponse.ProjectSummary::name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is HomeEvent.Created -> {
|
||||||
|
if (event.summary.owner.name == username.value) {
|
||||||
|
_ownProjects.value = _ownProjects.value.insort(event.summary, HomeResponse.ProjectSummary::name)
|
||||||
|
}
|
||||||
|
if (event.summary.isPublic) {
|
||||||
|
_publicProjects.value = _publicProjects.value.insort(event.summary, HomeResponse.ProjectSummary::name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is HomeEvent.Deleted -> {
|
||||||
|
arrayOf(_ownProjects, _publicProjects).forEach { state ->
|
||||||
|
state.value = state.value.filter { p -> p.id != event.id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun adminEventHandler(event: AdminEvent) {
|
||||||
|
when(event) {
|
||||||
|
is AdminEvent.InviteDeleted -> _invites.value = _invites.value.filter { it.code != event.id }
|
||||||
|
is AdminEvent.NewInvite -> _invites.value += event.invite
|
||||||
|
is AdminEvent.NewUser -> _users.value = _users.value.insort(event.user, UserListResponse.UserData::name)
|
||||||
|
is AdminEvent.UserChanged -> _users.value = _users.value.filter { it.id != event.user.id }.insort(event.user, UserListResponse.UserData::name)
|
||||||
|
is AdminEvent.UserDeleted -> _users.value = _users.value.filter { it.id != event.id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun mkInvite(clipboard: Clipboard, asAdmin: Boolean) {
|
fun mkInvite(clipboard: Clipboard, asAdmin: Boolean) {
|
||||||
withScope {
|
withScope {
|
||||||
_repo.newInvite(asAdmin).snackOr {
|
_repo.newInvite(asAdmin).snackOr {
|
||||||
@@ -171,7 +208,7 @@ class HomeVM(
|
|||||||
fun mkProject(name: String, isPublic: Boolean) {
|
fun mkProject(name: String, isPublic: Boolean) {
|
||||||
withScope {
|
withScope {
|
||||||
_repo.newProject(name, isPublic).snackOr {
|
_repo.newProject(name, isPublic).snackOr {
|
||||||
refresh()
|
if(_listenJob == null) refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-10
@@ -31,8 +31,6 @@ interface IProjectRepo {
|
|||||||
|
|
||||||
suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List<Float>, measurements: List<Float>,
|
suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List<Float>, measurements: List<Float>,
|
||||||
unit: TimeUnit): Either<ErrorResponse, EntryResponse>
|
unit: TimeUnit): Either<ErrorResponse, EntryResponse>
|
||||||
suspend fun updateEntry(id: Uuid, label: Uuid? = null, timestamp: Instant? = null, warmups: List<Float>? = null,
|
|
||||||
measurements: List<Float>? = null, unit: TimeUnit? = null): Either<ErrorResponse, Unit>
|
|
||||||
suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit>
|
suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit>
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -60,14 +58,6 @@ interface IProjectRepo {
|
|||||||
): Either<ErrorResponse, EntryResponse> =
|
): Either<ErrorResponse, EntryResponse> =
|
||||||
_client.callRoute(Routes.Entry.new, EntryRequest(label, timestamp, _projectId, warmups, measurements, unit))
|
_client.callRoute(Routes.Entry.new, EntryRequest(label, timestamp, _projectId, warmups, measurements, unit))
|
||||||
|
|
||||||
override suspend fun updateEntry(id: Uuid, label: Uuid?, timestamp: Instant?,
|
|
||||||
warmups: List<Float>?, measurements: List<Float>?,
|
|
||||||
unit: TimeUnit?
|
|
||||||
): Either<ErrorResponse, Unit> =
|
|
||||||
_client.callRoute(Routes.Entry.update,
|
|
||||||
id to PartialEntryRequest(label, timestamp, null, warmups, measurements, unit)
|
|
||||||
).ignoreValue()
|
|
||||||
|
|
||||||
override suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit> =
|
override suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit> =
|
||||||
_client.callRoute(Routes.Entry.delete, id).ignoreValue()
|
_client.callRoute(Routes.Entry.delete, id).ignoreValue()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.jaytux.phoebench.clients.data
|
||||||
|
|
||||||
|
import com.jaytux.phoebench.clients.Client
|
||||||
|
import com.jaytux.phoebench.common.AdminEvent
|
||||||
|
import com.jaytux.phoebench.common.Either
|
||||||
|
import com.jaytux.phoebench.common.ErrorResponse
|
||||||
|
import com.jaytux.phoebench.common.HomeEvent
|
||||||
|
import com.jaytux.phoebench.common.ProjectEvent
|
||||||
|
import com.jaytux.phoebench.common.Routes
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
|
interface ISSERepo {
|
||||||
|
suspend fun connectHome(onEvent: suspend (Either<ErrorResponse, HomeEvent>) -> Unit): Either<ErrorResponse, Unit>
|
||||||
|
suspend fun connectAdmin(onEvent: suspend (Either<ErrorResponse, AdminEvent>) -> Unit): Either<ErrorResponse, Unit>
|
||||||
|
suspend fun connectProject(id: Uuid, onEvent: suspend (Either<ErrorResponse, ProjectEvent>) -> Unit): Either<ErrorResponse, Unit>
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
class Default(private val _client: Client) : ISSERepo {
|
||||||
|
override suspend fun connectHome(onEvent: suspend (Either<ErrorResponse, HomeEvent>) -> Unit): Either<ErrorResponse, Unit> =
|
||||||
|
_client.callSSE(Routes.SSE.home, Unit, onEvent)
|
||||||
|
|
||||||
|
override suspend fun connectAdmin(onEvent: suspend (Either<ErrorResponse, AdminEvent>) -> Unit): Either<ErrorResponse, Unit> =
|
||||||
|
_client.callSSE(Routes.SSE.admin, Unit, onEvent)
|
||||||
|
|
||||||
|
override suspend fun connectProject(id: Uuid, onEvent: suspend (Either<ErrorResponse, ProjectEvent>) -> Unit): Either<ErrorResponse, Unit> =
|
||||||
|
_client.callSSE(Routes.SSE.projectSpecific, id, onEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun default(client: Client) = Default(client)
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
-9
@@ -6,14 +6,19 @@ import androidx.lifecycle.ViewModel
|
|||||||
import com.jaytux.phoebench.clients.AuthProvider
|
import com.jaytux.phoebench.clients.AuthProvider
|
||||||
import com.jaytux.phoebench.clients.Client
|
import com.jaytux.phoebench.clients.Client
|
||||||
import com.jaytux.phoebench.clients.SnackProvider
|
import com.jaytux.phoebench.clients.SnackProvider
|
||||||
|
import com.jaytux.phoebench.clients.SnackProvider.Companion.snackOnError
|
||||||
import com.jaytux.phoebench.clients.SnackProvider.Companion.snackOr
|
import com.jaytux.phoebench.clients.SnackProvider.Companion.snackOr
|
||||||
import com.jaytux.phoebench.clients.hexString
|
import com.jaytux.phoebench.clients.hexString
|
||||||
import com.jaytux.phoebench.clients.immutable
|
import com.jaytux.phoebench.clients.immutable
|
||||||
|
import com.jaytux.phoebench.clients.insort
|
||||||
import com.jaytux.phoebench.clients.systemTz
|
import com.jaytux.phoebench.clients.systemTz
|
||||||
import com.jaytux.phoebench.clients.withScope
|
import com.jaytux.phoebench.clients.withScope
|
||||||
import com.jaytux.phoebench.common.EntryResponse
|
import com.jaytux.phoebench.common.EntryResponse
|
||||||
import com.jaytux.phoebench.common.LabelResponse
|
import com.jaytux.phoebench.common.LabelResponse
|
||||||
|
import com.jaytux.phoebench.common.ProjectEvent
|
||||||
import com.jaytux.phoebench.common.TimeUnit
|
import com.jaytux.phoebench.common.TimeUnit
|
||||||
|
import com.jaytux.phoebench.common.foldSuspend
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
@@ -24,6 +29,7 @@ class ProjectVM(
|
|||||||
private val _snack: SnackProvider = SnackProvider.get(),
|
private val _snack: SnackProvider = SnackProvider.get(),
|
||||||
private val _client: Client = Client.get(),
|
private val _client: Client = Client.get(),
|
||||||
private val _repo: IProjectRepo = IProjectRepo.default(_client, _id),
|
private val _repo: IProjectRepo = IProjectRepo.default(_client, _id),
|
||||||
|
private val _sseRepo: ISSERepo = ISSERepo.default(_client),
|
||||||
private val _forceBack: () -> Unit
|
private val _forceBack: () -> Unit
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
data class Label(val id: Uuid, val name: String, val colorStr: String, val uiColor: Color = parseColor(colorStr)) {
|
data class Label(val id: Uuid, val name: String, val colorStr: String, val uiColor: Color = parseColor(colorStr)) {
|
||||||
@@ -74,7 +80,16 @@ class ProjectVM(
|
|||||||
val labels = _labels.immutable()
|
val labels = _labels.immutable()
|
||||||
val entries = _entries.immutable()
|
val entries = _entries.immutable()
|
||||||
|
|
||||||
|
private var _job: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
_job = withScope {
|
||||||
|
_sseRepo.connectProject(_id) {
|
||||||
|
it.snackOr(this::handleProjectEvent)
|
||||||
|
}.snackOnError()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +106,22 @@ class ProjectVM(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleProjectEvent(event: ProjectEvent) {
|
||||||
|
when(event) {
|
||||||
|
ProjectEvent.Deleted -> back()
|
||||||
|
is ProjectEvent.EntryDeleted -> _entries.value = _entries.value.filter { it.id != event.id }
|
||||||
|
is ProjectEvent.LabelChanged -> _labels.value += (event.label.id to Label.fromResponse(event.label))
|
||||||
|
is ProjectEvent.LabelDeleted -> _labels.value = _labels.value.filter { it.value.id != event.id }
|
||||||
|
is ProjectEvent.NewEntry -> _entries.value = _entries.value.insort(Entry.fromResponse(event.entry, _labels.value), Entry::timeStamp)
|
||||||
|
is ProjectEvent.NewLabel -> _labels.value += (event.label.id to Label.fromResponse(event.label))
|
||||||
|
is ProjectEvent.Updated -> {
|
||||||
|
_name.value = event.changes.name
|
||||||
|
_owner.value = event.changes.owner.name
|
||||||
|
_public.value = event.changes.isPublic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun update(name: String?, isPublic: Boolean?) {
|
fun update(name: String?, isPublic: Boolean?) {
|
||||||
withScope {
|
withScope {
|
||||||
_repo.update(name, isPublic).snackOr { refresh() }
|
_repo.update(name, isPublic).snackOr { refresh() }
|
||||||
@@ -99,10 +130,15 @@ class ProjectVM(
|
|||||||
|
|
||||||
fun delete() {
|
fun delete() {
|
||||||
withScope {
|
withScope {
|
||||||
_repo.delete().snackOr { _forceBack() }
|
_repo.delete().snackOr { back() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun back() {
|
||||||
|
_job?.cancel()
|
||||||
|
_forceBack()
|
||||||
|
}
|
||||||
|
|
||||||
fun mkLabel(name: String, color: Color) {
|
fun mkLabel(name: String, color: Color) {
|
||||||
withScope {
|
withScope {
|
||||||
_repo.newLabel(name, color).snackOr {
|
_repo.newLabel(name, color).snackOr {
|
||||||
@@ -136,14 +172,6 @@ class ProjectVM(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateEntry(id: Uuid, label: Label?, warmups: List<Float>?, measurements: List<Float>?, unit: TimeUnit?) {
|
|
||||||
withScope {
|
|
||||||
_repo.updateEntry(id, label?.id, Clock.System.now(), warmups, measurements, unit).snackOr {
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteEntry(id: Uuid) {
|
fun deleteEntry(id: Uuid) {
|
||||||
withScope {
|
withScope {
|
||||||
_repo.deleteEntry(id).snackOr {
|
_repo.deleteEntry(id).snackOr {
|
||||||
|
|||||||
@@ -275,7 +275,6 @@ fun AuthenticatedRoot() {
|
|||||||
) { insets ->
|
) { insets ->
|
||||||
Surface(Modifier.padding(insets), color = MaterialTheme.colorScheme.surface) {
|
Surface(Modifier.padding(insets), color = MaterialTheme.colorScheme.surface) {
|
||||||
currentProject?.let {
|
currentProject?.let {
|
||||||
BackHandler { leaveProject() }
|
|
||||||
ProjectView(it, ::leaveProject)
|
ProjectView(it, ::leaveProject)
|
||||||
} ?: run {
|
} ?: run {
|
||||||
HomeView { currentProject = it }
|
HomeView { currentProject = it }
|
||||||
|
|||||||
+5
-1
@@ -9,7 +9,9 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.backhandler.BackHandler
|
||||||
import androidx.compose.ui.draw.scale
|
import androidx.compose.ui.draw.scale
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
@@ -54,9 +56,11 @@ import io.github.koalaplot.core.xygraph.rememberGridStyle
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ProjectView(id: Uuid, forceBack: () -> Unit) {
|
fun ProjectView(id: Uuid, forceBack: () -> Unit) {
|
||||||
val vm = viewModel(key = id.toString()) { ProjectVM(id, _forceBack = forceBack) }
|
val vm = viewModel(key = id.toString()) { ProjectVM(id, _forceBack = forceBack) }
|
||||||
|
BackHandler { vm.back() }
|
||||||
|
|
||||||
val name by vm.name
|
val name by vm.name
|
||||||
val owner by vm.owner
|
val owner by vm.owner
|
||||||
@@ -489,7 +493,7 @@ fun ProjectPlotArea(vm: ProjectVM){
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
LazyColumn(Modifier.padding(start = 5.dp)) {
|
LazyColumn(Modifier.padding(start = 5.dp)) {
|
||||||
items(labels.toList()) { (_, lbl) ->
|
items(labels.toList().sortedBy { it.second.name }) { (_, lbl) ->
|
||||||
Box(Modifier.fillMaxWidth().clickable { labelFilter.toggle(lbl) }) {
|
Box(Modifier.fillMaxWidth().clickable { labelFilter.toggle(lbl) }) {
|
||||||
Box {
|
Box {
|
||||||
QuickLabel(lbl)
|
QuickLabel(lbl)
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.jaytux.phoebench.common
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed class HomeEvent {
|
||||||
|
@Serializable
|
||||||
|
data class Created(val summary: HomeResponse.ProjectSummary) : HomeEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Changed(val summary: HomeResponse.ProjectSummary) : HomeEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Deleted(val id: Uuid) : HomeEvent()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed class AdminEvent {
|
||||||
|
@Serializable
|
||||||
|
data class NewUser(val user: UserListResponse.UserData) : AdminEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UserChanged(val user: UserListResponse.UserData) : AdminEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UserDeleted(val id: Uuid) : AdminEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NewInvite(val invite: InviteListResponse.Invite) : AdminEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class InviteDeleted(val id: Uuid) : AdminEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed class ProjectEvent {
|
||||||
|
@Serializable
|
||||||
|
object Deleted : ProjectEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Updated(val changes: HomeResponse.ProjectSummary) : ProjectEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NewLabel(val label: LabelResponse) : ProjectEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LabelChanged(val label: LabelResponse) : ProjectEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LabelDeleted(val id: Uuid) : ProjectEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NewEntry(val entry: EntryResponse) : ProjectEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class EntryDeleted(val id: Uuid) : ProjectEvent()
|
||||||
|
}
|
||||||
@@ -38,7 +38,12 @@ object Routes {
|
|||||||
|
|
||||||
object Entry {
|
object Entry {
|
||||||
val new = ApiRoute.post<EntryRequest, EntryResponse>("/entry", Elevation.AUTH)
|
val new = ApiRoute.post<EntryRequest, EntryResponse>("/entry", Elevation.AUTH)
|
||||||
val update = ApiRoute.patchUuidNoRes<PartialEntryRequest>("/entry", Elevation.AUTH)
|
|
||||||
val delete = ApiRoute.deleteUuidNoRes("/entry", Elevation.AUTH)
|
val delete = ApiRoute.deleteUuidNoRes("/entry", Elevation.AUTH)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
object SSE {
|
||||||
|
val home = SSERoute.noArgs<HomeEvent>("/rt/home", Elevation.AUTH)
|
||||||
|
val admin = SSERoute.noArgs<AdminEvent>("/rt/admin", Elevation.ADMIN)
|
||||||
|
val projectSpecific = SSERoute.uuid<ProjectEvent>("/rt/project", Elevation.AUTH)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.jaytux.phoebench.common
|
||||||
|
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.plugins.ResponseException
|
||||||
|
import io.ktor.client.plugins.sse.SSEClientException
|
||||||
|
import io.ktor.client.plugins.sse.sse
|
||||||
|
import io.ktor.client.plugins.sse.sseSession
|
||||||
|
import io.ktor.http.Parameters
|
||||||
|
import io.ktor.http.buildUrl
|
||||||
|
import io.ktor.util.reflect.TypeInfo
|
||||||
|
import io.ktor.util.reflect.typeInfo
|
||||||
|
import io.ktor.utils.io.CancellationException
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.serializer
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
|
sealed class SSERoute<TParams, TEvent: Any>(val path: String, val elevation: Elevation, private val _resType: TypeInfo) {
|
||||||
|
open val pattern = path
|
||||||
|
private val deserializer = Json.serializersModule.serializer(_resType.kotlinType!!)
|
||||||
|
|
||||||
|
protected open fun buildUrl(params: TParams): String = path
|
||||||
|
|
||||||
|
abstract fun extractParams(reqParams: Parameters): TParams?
|
||||||
|
|
||||||
|
suspend fun call(client: IClient, params: TParams, handler: suspend (Either<ErrorResponse, TEvent>) -> Unit): Either<ErrorResponse, Unit> {
|
||||||
|
val fullUrl = "${client.serverUrl}${buildUrl(params)}"
|
||||||
|
try {
|
||||||
|
client.client.sse(urlString = fullUrl, showCommentEvents = true, showRetryEvents = true) {
|
||||||
|
incoming.collect {
|
||||||
|
try {
|
||||||
|
val data = it.data
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
if (data != null) {
|
||||||
|
println("SSE [$path] with data $data")
|
||||||
|
handler((Json.decodeFromString(deserializer, data) as TEvent).value())
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
withContext(NonCancellable) {
|
||||||
|
handler(ErrorResponse("The stream to $fullUrl was disconnected.").error())
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
handler(ErrorResponse(e.message ?: "Unknown error while streaming $fullUrl").error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Unit.value()
|
||||||
|
}
|
||||||
|
catch(e: ResponseException) {
|
||||||
|
val error = e.response.body<ErrorResponse>()
|
||||||
|
return error.error()
|
||||||
|
}
|
||||||
|
catch(e: SSEClientException) {
|
||||||
|
return ErrorResponse(e.message ?: "Could not set up event stream for $fullUrl.").error()
|
||||||
|
}
|
||||||
|
catch(e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
catch(e: Exception) {
|
||||||
|
return ErrorResponse(e.message ?: "SSE connection failed.").error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SSERoute0<TEvent: Any>(path: String, elevation: Elevation, resType: TypeInfo) : SSERoute<Unit, TEvent>(path, elevation, resType) {
|
||||||
|
override fun extractParams(reqParams: Parameters) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SSERoute1<T1, TEvent: Any>(path: String, elevation: Elevation, resType: TypeInfo, val urlEncode: (T1) -> String, val urlDecode: (String?) -> T1?)
|
||||||
|
: SSERoute<T1, TEvent>(path, elevation, resType)
|
||||||
|
{
|
||||||
|
override val pattern: String = "$path/{param}"
|
||||||
|
|
||||||
|
override fun buildUrl(params: T1): String = "$path/${urlEncode(params)}"
|
||||||
|
|
||||||
|
override fun extractParams(reqParams: Parameters): T1? = urlDecode(reqParams["param"])
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
inline fun <reified TEvent: Any> noArgs(path: String, elevation: Elevation) =
|
||||||
|
SSERoute0<TEvent>(path, elevation, typeInfo<TEvent>())
|
||||||
|
|
||||||
|
inline fun <T, reified TEvent: Any> single(path: String, elevation: Elevation,
|
||||||
|
noinline urlEncode: (T) -> String = { it.toString() }, noinline urlDecode: (String?) -> T?
|
||||||
|
) = SSERoute1<T, TEvent>(path, elevation, typeInfo<TEvent>(), urlEncode, urlDecode)
|
||||||
|
|
||||||
|
inline fun <reified TEvent: Any> uuid(path: String, elevation: Elevation) = single<Uuid, TEvent>(path, elevation) {
|
||||||
|
it?.let { p -> Uuid.parseOrNull(p) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
package com.jaytux.phoebench.server
|
||||||
|
|
||||||
import com.jaytux.phoebench.common.ErrorResponse
|
import com.jaytux.phoebench.common.*
|
||||||
import com.jaytux.phoebench.common.HomeResponse
|
|
||||||
import com.jaytux.phoebench.common.Routes
|
|
||||||
import com.jaytux.phoebench.common.fold
|
|
||||||
import com.jaytux.phoebench.server.db.DB
|
import com.jaytux.phoebench.server.db.DB
|
||||||
import com.jaytux.phoebench.server.db.User
|
import com.jaytux.phoebench.server.db.User
|
||||||
import com.jaytux.phoebench.server.handlers.AuthHandler
|
import com.jaytux.phoebench.server.handlers.*
|
||||||
import com.jaytux.phoebench.server.handlers.ProjectHandler
|
import com.jaytux.phoebench.server.handlers.ProjectHandler.accessibleProject
|
||||||
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 io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.ktor.serialization.kotlinx.json.*
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
import io.ktor.server.application.*
|
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.autohead.*
|
||||||
import io.ktor.server.plugins.calllogging.*
|
import io.ktor.server.plugins.calllogging.*
|
||||||
import io.ktor.server.plugins.contentnegotiation.*
|
import io.ktor.server.plugins.contentnegotiation.*
|
||||||
import io.ktor.server.plugins.cors.routing.CORS
|
import io.ktor.server.plugins.cors.routing.*
|
||||||
import io.ktor.server.plugins.statuspages.StatusPages
|
import io.ktor.server.plugins.statuspages.*
|
||||||
import io.ktor.server.request.*
|
import io.ktor.server.request.*
|
||||||
import io.ktor.server.response.*
|
import io.ktor.server.response.*
|
||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
import io.ktor.server.sse.SSE
|
import io.ktor.server.sse.*
|
||||||
import io.ktor.server.sse.sse
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.serializer
|
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import java.net.URI
|
|
||||||
import kotlin.uuid.Uuid
|
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>) {
|
fun main(args: Array<String>) {
|
||||||
DB.db
|
DB.db
|
||||||
@@ -156,59 +134,15 @@ fun Application.module() {
|
|||||||
deleteAuth(Routes.Label.delete, ProjectHandler::deleteLabel)
|
deleteAuth(Routes.Label.delete, ProjectHandler::deleteLabel)
|
||||||
|
|
||||||
postAuth(Routes.Entry.new, ProjectHandler::createEntry)
|
postAuth(Routes.Entry.new, ProjectHandler::createEntry)
|
||||||
patchAuth(Routes.Entry.update, ProjectHandler::updateEntry)
|
|
||||||
deleteAuth(Routes.Entry.delete, ProjectHandler::deleteEntry)
|
deleteAuth(Routes.Entry.delete, ProjectHandler::deleteEntry)
|
||||||
|
|
||||||
val projectBus = SSEBus.register<HomeResponse.ProjectSummary>("/rt/project")
|
sseAuth(Routes.SSE.home, { _, _ -> }) { _, _ -> Buses.homeBus }
|
||||||
sse("/rt/project") {
|
sseAdmin(Routes.SSE.admin) { _, _ -> Buses.adminBus }
|
||||||
try {
|
sseAuth(Routes.SSE.projectSpecific,
|
||||||
println("Attempt to set up SSE")
|
{ user, uuid ->
|
||||||
val principal = call.principal<JWTPrincipal>()
|
transaction { accessibleProject(user, uuid, false) }
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch(e: RouteError) {
|
) { _, id -> Buses.projectBus(id) }
|
||||||
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}") {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get("{...}") {
|
get("{...}") {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.jaytux.phoebench.common.Either
|
|||||||
import com.jaytux.phoebench.common.error
|
import com.jaytux.phoebench.common.error
|
||||||
import com.jaytux.phoebench.common.value
|
import com.jaytux.phoebench.common.value
|
||||||
import io.ktor.util.reflect.instanceOf
|
import io.ktor.util.reflect.instanceOf
|
||||||
|
import io.ktor.websocket.Serializer
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.serialization.KSerializer
|
import kotlinx.serialization.KSerializer
|
||||||
@@ -13,14 +14,17 @@ import kotlin.reflect.KType
|
|||||||
import kotlin.reflect.typeOf
|
import kotlin.reflect.typeOf
|
||||||
import kotlin.uuid.Uuid
|
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
|
object Cancellation
|
||||||
|
|
||||||
|
private val _unAuthFlow = MutableSharedFlow<T>()
|
||||||
private val _flows = ConcurrentHashMap<Uuid, Pair<Int, MutableSharedFlow<Either<Cancellation, T>>>>()
|
private val _flows = ConcurrentHashMap<Uuid, Pair<Int, MutableSharedFlow<Either<Cancellation, T>>>>()
|
||||||
|
|
||||||
fun register(user: Uuid): SharedFlow<Either<Cancellation, T>> =
|
fun register(user: Uuid): SharedFlow<Either<Cancellation, T>> =
|
||||||
_flows.computeIfAbsent(user) { 1 to MutableSharedFlow(extraBufferCapacity = 64) }.second
|
_flows.computeIfAbsent(user) { 1 to MutableSharedFlow(extraBufferCapacity = 64) }.second
|
||||||
|
|
||||||
|
fun unRegistered(): SharedFlow<T> = _unAuthFlow
|
||||||
|
|
||||||
suspend fun send(user: Uuid, data: T) {
|
suspend fun send(user: Uuid, data: T) {
|
||||||
_flows[user]?.second?.emit(data.value())
|
_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()) }
|
_flows.forEach { it.value.second.emit(data.value()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun sendUnAuth(data: T) {
|
||||||
|
_unAuthFlow.emit(data)
|
||||||
|
}
|
||||||
|
|
||||||
fun disconnect(user: Uuid) {
|
fun disconnect(user: Uuid) {
|
||||||
_flows.compute(user) { _, data ->
|
_flows.compute(user) { _, data ->
|
||||||
if(data == null) null
|
if(data == null) null
|
||||||
@@ -46,30 +54,11 @@ class SSEBus<T> private constructor (private val _containedType: KType, val seri
|
|||||||
_flows.remove(user)
|
_flows.remove(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
suspend fun forceDisconnectExcept(user: Uuid) {
|
||||||
private val _busCache = mutableMapOf<String, SSEBus<*>>()
|
synchronized(_flows) {
|
||||||
|
val map = _flows.values.filter { it != _flows[user] }
|
||||||
fun <T> register(topic: String, contained: KType, serializer: KSerializer<T>): SSEBus<T> {
|
_flows.keys.retainAll(setOf(user))
|
||||||
val bus = SSEBus<T>(contained, serializer)
|
map
|
||||||
_busCache.compute(topic) { k, existing ->
|
}.forEach { it.second.emit(Cancellation.error()) }
|
||||||
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>())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,8 @@ package com.jaytux.phoebench.server.handlers
|
|||||||
|
|
||||||
import com.jaytux.phoebench.common.*
|
import com.jaytux.phoebench.common.*
|
||||||
import com.jaytux.phoebench.server.Auth
|
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.Invite
|
||||||
import com.jaytux.phoebench.server.db.RefreshToken
|
import com.jaytux.phoebench.server.db.RefreshToken
|
||||||
import com.jaytux.phoebench.server.db.RefreshTokens
|
import com.jaytux.phoebench.server.db.RefreshTokens
|
||||||
@@ -14,6 +16,7 @@ import com.jaytux.phoebench.server.nowPlusMinutes
|
|||||||
import com.jaytux.phoebench.server.systemTZ
|
import com.jaytux.phoebench.server.systemTZ
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
import io.ktor.util.logging.KtorSimpleLogger
|
import io.ktor.util.logging.KtorSimpleLogger
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import org.jetbrains.exposed.v1.core.Transaction
|
import org.jetbrains.exposed.v1.core.Transaction
|
||||||
import org.jetbrains.exposed.v1.core.eq
|
import org.jetbrains.exposed.v1.core.eq
|
||||||
@@ -71,6 +74,13 @@ object AuthHandler {
|
|||||||
_hasOwner = true
|
_hasOwner = true
|
||||||
}
|
}
|
||||||
logger.info("New user: ${user.username} (${user.id.value}; is admin? ${user.isAdmin})")
|
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 access = Auth.generate(user.id.value)
|
||||||
val refresh = newRefreshToken(user)
|
val refresh = newRefreshToken(user)
|
||||||
@@ -150,6 +160,9 @@ object AuthHandler {
|
|||||||
inviteAsAdmin = if(user.isOwner) req.asAdmin else false
|
inviteAsAdmin = if(user.isOwner) req.asAdmin else false
|
||||||
expires = nowPlus(48, DateTimeUnit.HOUR)
|
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))
|
success(UuidResponse(invite.id.value))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +175,9 @@ object AuthHandler {
|
|||||||
suspend fun deleteInvite(user: User, req: Uuid) = transaction {
|
suspend fun deleteInvite(user: User, req: Uuid) = transaction {
|
||||||
val inv = Invite.findById(req) ?: throw RouteError("Invalid invite code", HttpStatusCode.NotFound)
|
val inv = Invite.findById(req) ?: throw RouteError("Invalid invite code", HttpStatusCode.NotFound)
|
||||||
inv.delete()
|
inv.delete()
|
||||||
|
ServerScope.launch {
|
||||||
|
Buses.adminBus.sendAll(AdminEvent.InviteDeleted(req))
|
||||||
|
}
|
||||||
success(EmptyResponse())
|
success(EmptyResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +200,16 @@ object AuthHandler {
|
|||||||
if(user.isOwner) target.isAdmin = it
|
if(user.isOwner) target.isAdmin = it
|
||||||
else throw RouteError("Only the server owner can change admin status.", HttpStatusCode.Forbidden)
|
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())
|
success(EmptyResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +221,10 @@ object AuthHandler {
|
|||||||
target.delete()
|
target.delete()
|
||||||
}
|
}
|
||||||
else throw RouteError("Only the owner can delete admin accounts.", HttpStatusCode.Forbidden)
|
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())
|
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.wrappedAdmin
|
||||||
import com.jaytux.phoebench.server.handlers.RouteError.Companion.wrappedAuth
|
import com.jaytux.phoebench.server.handlers.RouteError.Companion.wrappedAuth
|
||||||
import com.jaytux.phoebench.common.ApiRoute
|
import com.jaytux.phoebench.common.ApiRoute
|
||||||
|
import com.jaytux.phoebench.common.Either
|
||||||
import com.jaytux.phoebench.common.Elevation
|
import com.jaytux.phoebench.common.Elevation
|
||||||
import com.jaytux.phoebench.common.EmptyRequest
|
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.db.User
|
||||||
|
import com.jaytux.phoebench.server.handlers.RouteError
|
||||||
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
import io.ktor.server.application.ApplicationCall
|
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.plugins.ContentTransformationException
|
||||||
import io.ktor.server.request.receive
|
import io.ktor.server.request.receive
|
||||||
|
import io.ktor.server.response.respondText
|
||||||
import io.ktor.server.routing.Route
|
import io.ktor.server.routing.Route
|
||||||
import io.ktor.server.routing.RoutingContext
|
import io.ktor.server.routing.RoutingContext
|
||||||
import io.ktor.server.routing.get
|
import io.ktor.server.routing.get
|
||||||
import io.ktor.server.routing.post
|
import io.ktor.server.routing.post
|
||||||
import io.ktor.server.routing.delete
|
import io.ktor.server.routing.delete
|
||||||
import io.ktor.server.routing.patch
|
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.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 =
|
suspend inline fun <reified TReq: Any> ApiRoute<TReq, *>.paramArgs(call: ApplicationCall): TReq =
|
||||||
parseParams(call.parameters) ?: throw RouteError(
|
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)
|
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 =
|
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.EmptyResponse
|
||||||
import com.jaytux.phoebench.common.EntryRequest
|
import com.jaytux.phoebench.common.EntryRequest
|
||||||
import com.jaytux.phoebench.common.EntryResponse
|
import com.jaytux.phoebench.common.EntryResponse
|
||||||
|
import com.jaytux.phoebench.common.HomeEvent
|
||||||
import com.jaytux.phoebench.common.HomeResponse
|
import com.jaytux.phoebench.common.HomeResponse
|
||||||
import com.jaytux.phoebench.common.LabelRequest
|
import com.jaytux.phoebench.common.LabelRequest
|
||||||
import com.jaytux.phoebench.common.LabelResponse
|
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.PartialEntryRequest
|
||||||
import com.jaytux.phoebench.common.PartialLabelRequest
|
import com.jaytux.phoebench.common.PartialLabelRequest
|
||||||
import com.jaytux.phoebench.common.PartialProjectRequest
|
import com.jaytux.phoebench.common.PartialProjectRequest
|
||||||
|
import com.jaytux.phoebench.common.ProjectEvent
|
||||||
import com.jaytux.phoebench.common.ProjectRequest
|
import com.jaytux.phoebench.common.ProjectRequest
|
||||||
import com.jaytux.phoebench.common.ProjectResponse
|
import com.jaytux.phoebench.common.ProjectResponse
|
||||||
|
import com.jaytux.phoebench.server.Buses
|
||||||
import com.jaytux.phoebench.server.SSEBus
|
import com.jaytux.phoebench.server.SSEBus
|
||||||
import com.jaytux.phoebench.server.db.Entries
|
import com.jaytux.phoebench.server.db.Entries
|
||||||
import com.jaytux.phoebench.server.db.Entry
|
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.db.User
|
||||||
import com.jaytux.phoebench.server.handlers.RouteError.Companion.success
|
import com.jaytux.phoebench.server.handlers.RouteError.Companion.success
|
||||||
import io.ktor.http.HttpStatusCode
|
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.SortOrder
|
||||||
import org.jetbrains.exposed.v1.core.Transaction
|
import org.jetbrains.exposed.v1.core.Transaction
|
||||||
import org.jetbrains.exposed.v1.core.eq
|
import org.jetbrains.exposed.v1.core.eq
|
||||||
@@ -31,10 +36,10 @@ import kotlin.uuid.Uuid
|
|||||||
|
|
||||||
object ProjectHandler {
|
object ProjectHandler {
|
||||||
context(trns: Transaction)
|
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)
|
context(trns: Transaction)
|
||||||
private fun Project.isAccessible(user: User, forEditing: Boolean): Project {
|
fun Project.isAccessible(user: User, forEditing: Boolean): Project {
|
||||||
return when {
|
return when {
|
||||||
isEditableBy(user) -> this
|
isEditableBy(user) -> this
|
||||||
isPublic && !forEditing -> 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)
|
val project = Project.findById(id) ?: throw RouteError("Invalid project ID.", HttpStatusCode.NotFound)
|
||||||
return project.isAccessible(user, forEditing)
|
return project.isAccessible(user, forEditing)
|
||||||
}
|
}
|
||||||
@@ -77,10 +82,12 @@ object ProjectHandler {
|
|||||||
|
|
||||||
success(proj.toResponse(user))
|
success(proj.toResponse(user))
|
||||||
}.also { (_, proj) ->
|
}.also { (_, proj) ->
|
||||||
val bus = SSEBus.getBus<HomeResponse.ProjectSummary>("/rt/project") ?: return@also
|
ServerScope.launch {
|
||||||
val summary = HomeResponse.ProjectSummary(proj.id, proj.name, proj.isPublic, NamedID(user.username, user.id.value))
|
val bus = Buses.homeBus
|
||||||
if(proj.isPublic) bus.sendAll(summary)
|
val summary = HomeResponse.ProjectSummary(proj.id, proj.name, proj.isPublic, NamedID(user.username, user.id.value))
|
||||||
else bus.send(user.id.value, summary)
|
if(proj.isPublic) bus.sendAll(HomeEvent.Created(summary))
|
||||||
|
else bus.send(user.id.value, HomeEvent.Created(summary))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getProject(user: User, req: Uuid) = transaction {
|
fun getProject(user: User, req: Uuid) = transaction {
|
||||||
@@ -93,11 +100,30 @@ object ProjectHandler {
|
|||||||
val changes = req.second
|
val changes = req.second
|
||||||
changes.name?.let { proj.name = it }
|
changes.name?.let { proj.name = it }
|
||||||
changes.isPublic?.let { proj.isPublic = 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())
|
success(EmptyResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteProject(user: User, req: Uuid) = transaction {
|
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())
|
success(EmptyResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +135,13 @@ object ProjectHandler {
|
|||||||
color = req.color
|
color = req.color
|
||||||
project = proj
|
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 {
|
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)
|
if(it.length != 7) throw RouteError("Expected Hex-RGB color (7 characters).", HttpStatusCode.BadRequest)
|
||||||
lbl.color = it
|
lbl.color = it
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ServerScope.launch {
|
||||||
|
Buses.projectBus(lbl.projectId.value).sendAll(ProjectEvent.LabelChanged(
|
||||||
|
LabelResponse(lbl.id.value, lbl.label, lbl.color)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
success(EmptyResponse())
|
success(EmptyResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +167,11 @@ object ProjectHandler {
|
|||||||
val lbl = Label.findById(req) ?: throw RouteError("Invalid label ID.", HttpStatusCode.NotFound)
|
val lbl = Label.findById(req) ?: throw RouteError("Invalid label ID.", HttpStatusCode.NotFound)
|
||||||
lbl.project.isAccessible(user, true)
|
lbl.project.isAccessible(user, true)
|
||||||
lbl.delete()
|
lbl.delete()
|
||||||
|
|
||||||
|
ServerScope.launch {
|
||||||
|
Buses.projectBus(lbl.id.value).sendAll(ProjectEvent.LabelDeleted(req))
|
||||||
|
}
|
||||||
|
|
||||||
success(EmptyResponse())
|
success(EmptyResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,32 +189,21 @@ object ProjectHandler {
|
|||||||
warmups = req.warmups
|
warmups = req.warmups
|
||||||
unit = req.unit
|
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 response = EntryResponse(entry.id.value, entry.label.id.value, entry.timestamp, entry.warmups, entry.measurements, entry.unit)
|
||||||
val entry = Entry.findById(req.first) ?: throw RouteError("Invalid entry ID.", HttpStatusCode.NotFound)
|
ServerScope.launch {
|
||||||
entry.project.isAccessible(user, true)
|
Buses.projectBus(proj.id.value).sendAll(ProjectEvent.NewEntry(response))
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
changes.timestamp?.let { entry.timestamp = it }
|
success(response)
|
||||||
changes.warmups?.let { entry.warmups = it }
|
|
||||||
changes.measurements?.let { entry.measurements = it }
|
|
||||||
changes.unit?.let { entry.unit = it }
|
|
||||||
success(EmptyResponse())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteEntry(user: User, req: Uuid) = transaction {
|
fun deleteEntry(user: User, req: Uuid) = transaction {
|
||||||
val entry = Entry.findById(req) ?: throw RouteError("Invalid entry ID.", HttpStatusCode.NotFound)
|
val entry = Entry.findById(req) ?: throw RouteError("Invalid entry ID.", HttpStatusCode.NotFound)
|
||||||
entry.project.isAccessible(user, true)
|
entry.project.isAccessible(user, true)
|
||||||
entry.delete()
|
entry.delete()
|
||||||
|
ServerScope.launch {
|
||||||
|
Buses.projectBus(entry.project.id.value).sendAll(ProjectEvent.EntryDeleted(req))
|
||||||
|
}
|
||||||
success(EmptyResponse())
|
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
|
|
||||||
Reference in New Issue
Block a user