Initial version (Server, Compose clients work)

This commit is contained in:
2026-08-04 12:12:25 +02:00
commit 2a1487037b
59 changed files with 4701 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
@file:OptIn(ExperimentalWasmDsl::class)
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.serialization)
}
val partialsDirectory = layout.buildDirectory.dir("generated/sources/partials")
val versionDirectory = layout.buildDirectory.dir("generated/sources/version")
val requestsDirectory = layout.projectDirectory.dir("src/commonMain/kotlin/com/jaytux/phoebench/common/")
val generatePartials = tasks.register<Exec>("generatePartials") {
group = "generation"
description = "Generate Partial classes (requests with all-nullable fields)"
val scriptFile = project.file("partialize.main.kts")
val targets = fileTree(requestsDirectory) {
include("**/*.kt")
}
val lst = targets.map { it.absolutePath }
inputs.file(scriptFile)
inputs.files(targets)
outputs.dir(partialsDirectory)
executable = "kotlin"
doFirst {
args(scriptFile.absolutePath, partialsDirectory.get().asFile.absolutePath, *lst.toTypedArray())
}
}
val generateVersion = tasks.register<Task>("protocolVersion") {
doFirst {
val outFile = versionDirectory.get().file("com/jaytux/phoebench/common/Version.kt").asFile
outFile.parentFile.mkdirs()
outFile.writeText("""
package com.jaytux.phoebench.common
object ProtocolVersion {
const val VERSION="${rootProject.version}"
}
""".trimIndent())
}
}
kotlin {
jvm("desktop")
wasmJs {
browser()
}
sourceSets {
val commonMain by getting {
kotlin {
srcDir(generatePartials)
srcDir(versionDirectory)
}
dependencies {
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.auth)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization)
implementation(libs.kotlinx.serialization.json)
}
}
}
targets.all {
compilations.all {
compileTaskProvider.configure {
dependsOn(generatePartials)
dependsOn(generateVersion)
}
}
}
compilerOptions {
freeCompilerArgs.add("-Xcontext-parameters")
optIn.add("kotlin.uuid.ExperimentalUuidApi")
}
}
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env kotlin
@file:DependsOn("org.jetbrains.kotlin:kotlin-compiler-embeddable:2.3.21")
import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreApplicationEnvironment
import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreApplicationEnvironmentMode
import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreProjectEnvironment
import org.jetbrains.kotlin.com.intellij.openapi.util.Disposer
import org.jetbrains.kotlin.parsing.KotlinParserDefinition
import org.jetbrains.kotlin.psi.KtClass
import org.jetbrains.kotlin.psi.KtPsiFactory
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.*
import kotlin.system.exitProcess
fun process(file: Path, outputDir: Path, factory: KtPsiFactory) {
if(!file.exists()) {
System.err.println("Skipping $file: file does not exist")
return
}
if(!file.isReadable()) {
System.err.println("Skipping $file: file is not readable")
return
}
val ktFile = factory.createFile(file.readText())
val pkg = ktFile.packageFqName.toString()
val imports = ktFile.importDirectives.mapNotNull { it.importedFqName }.filter { !it.toString().contains("ToPartialize") }
val classes = ktFile.declarations.mapNotNull { cls ->
if(cls !is KtClass || !cls.isData()) return@mapNotNull null
val annotations = cls.annotationEntries.map { it.text }
if("@ToPartialize" !in annotations) return@mapNotNull null
val ogName = cls.name ?: return@mapNotNull null
val newName = "Partial$ogName"
val nestedTypes = cls.declarations.filterIsInstance<KtClass>().mapNotNull { it.name }.toSet()
val props = cls.primaryConstructorParameters.map {
val name = it.name
val type = it.typeReference?.text
if(name == null || type == null) return@mapNotNull null
name to type
}.joinToString(", ") { (n, t) ->
val nnT = if(t.endsWith('?')) t.substring(startIndex = 0, endIndex = t.length - 1) else t
val useT = if(nnT in nestedTypes) "$ogName.$nnT" else nnT
"val $n: $useT? = null"
}
"@Serializable\ndata class $newName($props)"
}
val cnt = "package $pkg\n\n${imports.joinToString("\n") { "import $it" }}\n\n${classes.joinToString("\n\n")}"
// println(cnt)
val writeDir = outputDir.resolve(pkg.replace('.', '/'))
val fileName = file.fileName
// println("Trying to write to $writeDir/$fileName")
if(!writeDir.exists()) writeDir.createDirectories()
Files.writeString(Path("$writeDir/$fileName"), cnt)
}
// usage kotlin partialize.main.kts output-dir [input-file]+
if(args.isEmpty()) {
System.err.println("Usage: partialize.main.kts <output-dir> <input-file>+")
exitProcess(-1)
}
val outputDir = Path(args[0])
if(outputDir.notExists()) {
println("Creating output directory $outputDir...")
Files.createDirectory(outputDir)
}
else if(!outputDir.isDirectory()) {
System.err.println("Output directory $outputDir is not a directory.")
exitProcess(-1)
}
else if(!outputDir.isWritable()) {
System.err.println("Cannot write to output directory $outputDir")
exitProcess(-1)
}
val inputFiles = args.slice(1 until args.size)
val disp = Disposer.newDisposable()
val appEnv = KotlinCoreApplicationEnvironment.create(disp, KotlinCoreApplicationEnvironmentMode.Production)
appEnv.registerParserDefinition(KotlinParserDefinition())
val project = KotlinCoreProjectEnvironment(disp, appEnv)
val factory = KtPsiFactory(project.project)
inputFiles.forEach {
process(Path(it), outputDir, factory)
}
@@ -0,0 +1,239 @@
package com.jaytux.phoebench.common
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.patch
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.request.url
import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType
import io.ktor.http.Parameters
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.util.reflect.TypeInfo
import io.ktor.util.reflect.typeInfo
import kotlin.reflect.KClass
import kotlin.uuid.Uuid
enum class Elevation {
UN_AUTH, AUTH, ADMIN
}
sealed class ApiRoute<TReq, TRes: Any>(val verb: String, val path: String, val elevation: Elevation, private val _resType: TypeInfo) {
enum class ReqBodySource {
BODY, PARAMS, QUERY, NON_BODY, NON_QUERY
}
open val pattern = path
open val bodySource = ReqBodySource.BODY
suspend fun extract(body: HttpResponse, meta: Any? = null): Either<ErrorResponse, TRes> = try {
println("Extracting response for $verb $pattern [${body.status.value}] as ${_resType.type.simpleName} (${_resType.kotlinType}${meta?.let { "; meta = $it" } ?: ""})")
if(body.status.isSuccess()) body.body<TRes>(_resType).value()
else body.body<ErrorResponse>().error()
} catch(e: Exception) {
ErrorResponse("Failed to parse response: ${e.message}").error()
}
abstract suspend fun makeCall(client: IClient, body: TReq): HttpResponse
suspend fun call(client: IClient, body: TReq, meta: Any? = null): Either<ErrorResponse, TRes> = extract(makeCall(client, body), meta)
open suspend fun parseParams(params: Parameters): TReq? = throw UnsupportedOperationException()
open suspend fun parseQuery(params: Parameters): TReq = throw UnsupportedOperationException()
open suspend fun parseNonBody(query: Parameters, params: Parameters): TReq? = throw UnsupportedOperationException()
open suspend fun parseNonQuery(params: Parameters, receiver: suspend (bodyType: KClass<*>, baseReq: Any) -> TReq?): TReq? = throw UnsupportedOperationException()
class GetRoute<TRes: Any>(path: String, elevation: Elevation, resType: TypeInfo)
: ApiRoute<EmptyRequest, TRes>("GET", path, elevation, resType) {
override suspend fun makeCall(client: IClient, body: EmptyRequest): HttpResponse = client.client.get {
url("${client.serverUrl}$path")
}
suspend fun makeCall(client: IClient): HttpResponse = makeCall(client, EmptyRequest())
}
class GetRoute1<TReq: Any, TRes: Any>(
path: String, elevation: Elevation, resType: TypeInfo, val urlEncode: (TReq) -> String,
val urlDecode: (String?) -> TReq?
) : ApiRoute<TReq, TRes>("GET", path, elevation, resType) {
override val pattern: String = "$path/{param}"
override val bodySource: ReqBodySource = ReqBodySource.PARAMS
override suspend fun makeCall(client: IClient, body: TReq): HttpResponse = client.client.get {
url("${client.serverUrl}$path/${urlEncode(body)}")
}
override suspend fun parseParams(params: Parameters): TReq? = urlDecode(params["param"])
}
class PostRoute<TReq: Any, TRes: Any>(path: String, elevation: Elevation, private val _reqType: TypeInfo, resType: TypeInfo)
: ApiRoute<TReq, TRes>("POST", path, elevation, resType) {
override suspend fun makeCall(client: IClient, body: TReq): HttpResponse = client.client.post {
url("${client.serverUrl}$path")
contentType(ContentType.Application.Json)
setBody(body, _reqType)
}
}
class PostRoute1<TReq: Any, TBody: Any, TRes: Any>(
private val pathPre: String, private val pathPost: String, elevation: Elevation, private val _bodyType: TypeInfo,
resType: TypeInfo, val urlEncode: (TReq) -> String, val urlDecode: (String?) -> TReq?
) : ApiRoute<Pair<TReq, TBody>, TRes>("POST", "$pathPre/{param}/$pathPost", elevation, resType) {
override val pattern: String = "$pathPre/{param}/$pathPost"
override val bodySource: ReqBodySource = ReqBodySource.NON_QUERY
override suspend fun makeCall(client: IClient, body: Pair<TReq, TBody>): HttpResponse = client.client.post {
url("${client.serverUrl}$pathPre/${urlEncode(body.first)}/$pathPost")
contentType(ContentType.Application.Json)
setBody(body.second, _bodyType)
}
override suspend fun parseNonQuery(params: Parameters, receiver: suspend (bodyType: KClass<*>, urlParam: Any) -> Pair<TReq, TBody>?): Pair<TReq, TBody>? {
val param = urlDecode(params["param"]) ?: return null
return receiver(_bodyType.type, param)
}
}
class DeleteRoute1<TReq: Any, TRes: Any>(
path: String, elevation: Elevation, resType: TypeInfo, val urlEncode: (TReq) -> String,
val urlDecode: (String?) -> TReq?
) : ApiRoute<TReq, TRes>("DELETE", path, elevation, resType) {
override val pattern: String = "$path/{param}"
override val bodySource: ReqBodySource = ReqBodySource.PARAMS
override suspend fun makeCall(client: IClient, body: TReq): HttpResponse = client.client.delete {
url("${client.serverUrl}$path/${urlEncode(body)}")
}
override suspend fun parseParams(params: Parameters): TReq? = urlDecode(params["param"])
}
class PatchRoute1<TReq: Any, TBody: Any, TRes: Any>(
path: String, elevation: Elevation, resType: TypeInfo, private val _bodyType: TypeInfo,
val urlEncode: (TReq) -> String, val urlDecode: (String?) -> TReq?
) : ApiRoute<Pair<TReq, TBody>, TRes>("PATCH", path, elevation, resType) {
override val bodySource: ReqBodySource = ReqBodySource.NON_QUERY
override val pattern: String = "$path/{param}"
override suspend fun makeCall(client: IClient, body: Pair<TReq, TBody>): HttpResponse = client.client.patch {
url("${client.serverUrl}$path/${urlEncode(body.first)}")
contentType(ContentType.Application.Json)
setBody(body.second, _bodyType)
}
override suspend fun parseNonQuery(params: Parameters, receiver: suspend (bodyType: KClass<*>, urlParam: Any) -> Pair<TReq, TBody>?): Pair<TReq, TBody>? {
val param = urlDecode(params["param"]) ?: return null
return receiver(_bodyType.type, param)
}
}
companion object {
fun parseUuid(str: String?) = str?.let {
try {
Uuid.parse(it)
}
catch(_: Exception) {
null
}
}
/**
* Builds a GET route with no request parameters.
*/
inline fun <reified TRes: Any> get(path: String, elevation: Elevation) =
GetRoute<TRes>(path, elevation, typeInfo<TRes>())
/**
* Builds a GET route with one request parameter (encoded as URL parameter in the endpoint, like
* `/endpoint/arg`).
*/
inline fun <reified TReq: Any, reified TRes: Any> get1(path: String, elevation: Elevation,
noinline encode: (TReq) -> String = { it.toString() }, noinline decode: (String?) -> TReq?
) = GetRoute1<TReq, TRes>(path, elevation, typeInfo<TRes>(), encode, decode)
/**
* Builds a GET route with one UUID request parameter (encoded as URL parameter in the endpoint, like
* `/endpoint/arg`).
*/
inline fun <reified TRes: Any> getUuid(path: String, elevation: Elevation) =
get1<Uuid, TRes>(path, elevation, Uuid::toString, this::parseUuid)
/**
* Builds a POST route with request and response types.
*/
inline fun <reified TReq: Any, reified TRes: Any> post(path: String, elevation: Elevation) =
PostRoute<TReq, TRes>(path, elevation, typeInfo<TReq>(), typeInfo<TRes>())
/**
* Builds a POST route with two hardcoded path segments and one request parameter (encoded as URL
* parameter in the endpoint, like `/endpoint-pre/arg/endpoint-post`), and a request body.
*/
inline fun <reified TReq: Any, reified TBody: Any, reified TRes: Any> post1(
pathPre: String, pathPost: String, elevation: Elevation, noinline encode: (TReq) -> String,
noinline decode: (String?) -> TReq?
) = PostRoute1<TReq, TBody, TRes>(pathPre, pathPost, elevation, typeInfo<TBody>(), typeInfo<TRes>(), encode, decode)
/**
* Builds a POST route with two hardcoded path segments and one UUID request parameter (encoded as URL
* parameter in the endpoint, like `/endpoint-pre/uuid/endpoint-post`), and a request body.
*/
inline fun <reified TBody: Any, reified TRes: Any> postUuid(pathPre: String, pathPost: String, elevation: Elevation) =
post1<Uuid, TBody, TRes>(pathPre, pathPost, elevation, Uuid::toString, this::parseUuid)
/**
* Builds a POST route with request type and no response (EmptyResponse).
*/
inline fun <reified TReq: Any> postNoRes(path: String, elevation: Elevation) =
post<TReq, EmptyResponse>(path, elevation)
/**
* Builds a DELETE route with one request parameter (encoded as URL parameter in the endpoint, like
* `/endpoint/arg`).
*/
inline fun <reified TReq: Any, reified TRes: Any> delete1(path: String, elevation: Elevation,
noinline encode: (TReq) -> String = { it.toString() }, noinline decode: (String?) -> TReq?
) = DeleteRoute1<TReq, TRes>(path, elevation, typeInfo<TRes>(), encode, decode)
/**
* Builds a DELETE route with one UUID request parameter (encoded as URL parameter in the endpoint, like
* `/endpoint/arg`).
*/
inline fun <reified TRes: Any> deleteUuid(path: String, elevation: Elevation) =
delete1<Uuid, TRes>(path, elevation, Uuid::toString, this::parseUuid)
/**
* Builds a DELETE route with one request parameter and no response (EmptyResponse).
*/
inline fun <reified TReq: Any> delete1NoRes(path: String, elevation: Elevation,
noinline encode: (TReq) -> String = { it.toString() }, noinline decode: (String?) -> TReq?
) = delete1<TReq, EmptyResponse>(path, elevation, encode, decode)
/**
* Builds a DELETE route with one UUID request parameter and no response (EmptyResponse).
*/
fun deleteUuidNoRes(path: String, elevation: Elevation) =
delete1NoRes<Uuid>(path, elevation, Uuid::toString, this::parseUuid)
/**
* Builds a PATCH route with one request parameter and a request body.
*/
inline fun <reified TReq: Any, reified TBody: Any, reified TRes: Any> patch1(
path: String, elevation: Elevation, noinline encode: (TReq) -> String, noinline decode: (String?) -> TReq?
) = PatchRoute1<TReq, TBody, TRes>(path, elevation, typeInfo<TRes>(), typeInfo<TBody>(), encode, decode)
/**
* Builds a PATCH route with a UUID request parameter and a request body.
*/
inline fun <reified TBody: Any, reified TRes: Any> patchUuid(path: String, elevation: Elevation) =
patch1<Uuid, TBody, TRes>(path, elevation, Uuid::toString, this::parseUuid)
/**
* Builds a PATCH route with a UUID request parameter and a request body, but without response (EmptyResponse).
*/
inline fun <reified TBody: Any> patchUuidNoRes(path: String, elevation: Elevation) =
patchUuid<TBody, EmptyResponse>(path, elevation)
}
}
@@ -0,0 +1,5 @@
package com.jaytux.phoebench.common
object Auth {
const val JWT_CLAIM = "pb-user-id"
}
@@ -0,0 +1,27 @@
package com.jaytux.phoebench.common
sealed class Either<out E, out V> {
class Error<E>(val errorData: E) : Either<E, Nothing>()
class Value<V>(val value: V) : Either<Nothing, V>()
}
fun <E> E.error(): Either<E, Nothing> = Either.Error(this)
fun <V> V.value(): Either<Nothing, V> = Either.Value(this)
inline fun <reified E, reified V, reified V2> Either<E, V>.bind(f: (V) -> Either<E, V2>): Either<E, V2> = when(this) {
is Either.Error<E> -> errorData.error()
is Either.Value<V> -> f(value)
}
inline fun <reified E, reified V, reified V2> Either<E, V>.map(f: (V) -> V2): Either<E, V2> = bind { f(it).value() }
inline fun <reified E, reified V, reified R> Either<E, V>.fold(fError: (E) -> R, fValue: (V) -> R) = when(this) {
is Either.Error<E> -> fError(errorData)
is Either.Value<V> -> fValue(value)
}
suspend inline fun <reified E, reified V, reified R> Either<E, V>.foldSuspend(fError: suspend (E) -> R, fValue: suspend (V) -> R) = when(this) {
is Either.Error<E> -> fError(errorData)
is Either.Value<V> -> fValue(value)
}
inline fun <reified E, reified V> Either<E, V>.isError() = this is Either.Error<E>
inline fun <reified E, reified V> Either<E, V>.isValue() = this is Either.Value<V>
inline fun <reified E, reified V> Either<E, V>.asError() = (this as? Either.Error<E>)?.errorData
inline fun <reified E, reified V> Either<E, V>.asValue() = (this as? Either.Value<V>)?.value
@@ -0,0 +1,10 @@
package com.jaytux.phoebench.common
import io.ktor.client.HttpClient
interface IClient {
val client: HttpClient
val serverUrl: String
data class Default(override val client: HttpClient, override val serverUrl: String) : IClient
}
@@ -0,0 +1,39 @@
package com.jaytux.phoebench.common
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.Serializable
import kotlin.time.Instant
import kotlin.uuid.Uuid
annotation class ToPartialize
@Serializable
data class LoginRequest(val name: String, val pass: String)
@Serializable
data class SignupRequest(val invite: Uuid, val name: String, val pass: String)
@Serializable
class EmptyRequest
@Serializable
data class RefreshRequest(val refreshToken: Uuid)
@Serializable @ToPartialize
data class ProjectRequest(val name: String, var isPublic: Boolean)
@Serializable @ToPartialize
data class LabelRequest(val name: String, val color: String, val projectId: Uuid)
@Serializable @ToPartialize
data class EntryRequest(val label: Uuid, val timestamp: Instant, val projectId: Uuid, val warmups: List<Float>,
val measurements: List<Float>, val unit: TimeUnit)
@Serializable
data class LogoutRequest(val refresh: Uuid)
@Serializable
data class UserUpdateRequest(val projectLimit: Int? = null, val isAdmin: Boolean? = null)
@Serializable
data class InviteRequest(val asAdmin: Boolean)
@@ -0,0 +1,61 @@
package com.jaytux.phoebench.common
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlin.time.Instant
import kotlin.uuid.Uuid
@Serializable
data class ErrorResponse(val msg: String)
@Serializable
class EmptyResponse
@Serializable
data class TokenResponse(val access: String, val refresh: Uuid)
@Serializable
data class UuidResponse(val uuid: Uuid)
@Serializable
data class InviteListResponse(val uuids: List<Invite>) {
@Serializable
data class Invite(val code: Uuid, val expires: Instant, val asAdmin: Boolean)
}
@Serializable
data class UserListResponse(val users: List<UserData>) {
@Serializable
data class UserData(val id: Uuid, val name: String, val isAdmin: Boolean, val projectLimit: Int, val usedProjects: Int)
}
@Serializable
data class NamedID(val name: String, val id: Uuid)
@Serializable
data class HomeResponse(val username: String, val isAdmin: Boolean, val projectLimit: Int, val ownProjects: List<ProjectSummary>, val publicProjects: List<ProjectSummary>) {
@Serializable
data class ProjectSummary(val id: Uuid, val name: String, val isPublic: Boolean, val owner: NamedID)
}
@Serializable
data class ProjectResponse(val id: Uuid, val name: String, val owner: NamedID, val isPublic: Boolean, val isEditable: Boolean,
val usedLabels: List<LabelResponse>, val entries: List<EntryResponse>)
@Serializable
data class LabelResponse(val id: Uuid, val name: String, val color: String)
@Serializable
data class EntryResponse(val id: Uuid, val labelId: Uuid, val timestamp: Instant, val warmups: List<Float>,
val measurements: List<Float>, val unit: TimeUnit)
@Serializable
data class HandshakeResponse(val version: String = ProtocolVersion.VERSION) {
}
@@ -0,0 +1,44 @@
package com.jaytux.phoebench.common
object Routes {
object Auth {
val login = ApiRoute.post<LoginRequest, TokenResponse>("/login", Elevation.UN_AUTH)
val signup = ApiRoute.post<SignupRequest, TokenResponse>("/signup", Elevation.UN_AUTH)
val logout = ApiRoute.post<LogoutRequest, EmptyResponse>("/logout", Elevation.AUTH)
val logoutEverywhere = ApiRoute.post<EmptyRequest, EmptyResponse>("/logout/all", Elevation.AUTH)
val refresh = ApiRoute.post<RefreshRequest, TokenResponse>("/refresh", Elevation.UN_AUTH)
object Invite {
val new = ApiRoute.post<InviteRequest, UuidResponse>("/invite", Elevation.ADMIN)
val list = ApiRoute.get<InviteListResponse>("/invite", Elevation.ADMIN)
val delete = ApiRoute.deleteUuidNoRes("/invite", Elevation.ADMIN)
}
object User {
val list = ApiRoute.get<UserListResponse>("/user", Elevation.ADMIN)
val update = ApiRoute.patchUuidNoRes<UserUpdateRequest>("/user", Elevation.ADMIN)
val delete = ApiRoute.deleteUuidNoRes("/user", Elevation.ADMIN)
}
}
val handshake = ApiRoute.get<HandshakeResponse>("/", Elevation.UN_AUTH)
val home = ApiRoute.get<HomeResponse>("/home", Elevation.AUTH)
object Project {
val new = ApiRoute.post<ProjectRequest, ProjectResponse>("/project", Elevation.AUTH)
val get = ApiRoute.getUuid<ProjectResponse>("/project", Elevation.AUTH)
val update = ApiRoute.patchUuidNoRes<PartialProjectRequest>("/project", Elevation.AUTH)
val delete = ApiRoute.deleteUuidNoRes("/project", Elevation.AUTH)
}
object Label {
val new = ApiRoute.post<LabelRequest, LabelResponse>("/label", Elevation.AUTH)
val update = ApiRoute.patchUuidNoRes<PartialLabelRequest>("/label", Elevation.AUTH)
val delete = ApiRoute.deleteUuidNoRes("/label", Elevation.AUTH)
}
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)
}
}
@@ -0,0 +1,15 @@
package com.jaytux.phoebench.common
import kotlinx.serialization.Serializable
@Serializable
enum class TimeUnit(val disp: String, val mulToSec: Float) {
NANOS("ns", 1e-9f),
MICROS("μs", 1e-6f),
MILLIS("ms", 1e-3f),
SECONDS("s", 1f),
MINUTES("min", 60f),
HOURS("h", 3600f);
fun convertTo(other: TimeUnit, valueInThis: Float): Float = valueInThis * (mulToSec / other.mulToSec)
}