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,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 {
val new = ApiRoute.post<EntryRequest, EntryResponse>("/entry", Elevation.AUTH)
val update = ApiRoute.patchUuidNoRes<PartialEntryRequest>("/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) }
}
}
}