From 1e6ad5b11fb24a9cabf4170a9cc96659ef15038f Mon Sep 17 00:00:00 2001 From: skyecodes Date: Mon, 2 Feb 2026 00:37:59 +0100 Subject: [PATCH 1/2] fix(server-arrow): Add Failure type argument in routeWithRaise methods --- .../src/commonMain/kotlin/ServerRaise.kt | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/server-arrow/src/commonMain/kotlin/ServerRaise.kt b/server-arrow/src/commonMain/kotlin/ServerRaise.kt index fc54a88..af2871f 100644 --- a/server-arrow/src/commonMain/kotlin/ServerRaise.kt +++ b/server-arrow/src/commonMain/kotlin/ServerRaise.kt @@ -8,6 +8,7 @@ import io.ktor.server.routing.* import io.ktor.utils.io.* import opensavvy.spine.api.Endpoint import opensavvy.spine.api.FailureSpec.ByCode +import opensavvy.spine.api.FailureSpec.Never import opensavvy.spine.api.FailureSpec.Or import opensavvy.spine.api.Parameters import opensavvy.spine.server.TypedResponseScope @@ -17,9 +18,9 @@ import kotlin.jvm.JvmName @KtorDsl @JvmName("routeOrRaise1") -inline fun Route.routeWithRaise( - endpoint: Endpoint>, Params>, - crossinline block: suspend context(Raise) TypedResponseScope>, Params>.() -> Unit, +inline fun >, reified Params : Parameters> Route.routeWithRaise( + endpoint: Endpoint, + crossinline block: suspend context(Raise) TypedResponseScope>, Params>.() -> Unit, ) { route(endpoint) response@{ recover( @@ -31,9 +32,9 @@ inline fun Route.routeWithRaise( - endpoint: Endpoint>, ByCode>, Params>, - crossinline block: suspend context(Raise, Raise) TypedResponseScope>, ByCode>, Params>.() -> Unit, +inline fun >, ByCode>, reified Params : Parameters> Route.routeWithRaise( + endpoint: Endpoint, + crossinline block: suspend context(Raise, Raise) TypedResponseScope>, ByCode>, Params>.() -> Unit, ) { route(endpoint) response@{ recover( @@ -50,9 +51,9 @@ inline fun Route.routeWithRaise( - endpoint: Endpoint>, ByCode>, ByCode>, Params>, - crossinline block: suspend context(Raise, Raise, Raise) TypedResponseScope>, ByCode>, ByCode>, Params>.() -> Unit, +inline fun >, ByCode>, ByCode>, reified Params : Parameters> Route.routeWithRaise( + endpoint: Endpoint, + crossinline block: suspend context(Raise, Raise, Raise) TypedResponseScope>, ByCode>, ByCode>, Params>.() -> Unit, ) { route(endpoint) response@{ recover( @@ -74,9 +75,9 @@ inline fun Route.routeWithRaise( - endpoint: Endpoint>, ByCode>, ByCode>, ByCode>, Params>, - crossinline block: suspend context(Raise, Raise, Raise, Raise) TypedResponseScope>, ByCode>, ByCode>, ByCode>, Params>.() -> Unit, +inline fun >, ByCode>, ByCode>, ByCode>, reified Params : Parameters> Route.routeWithRaise( + endpoint: Endpoint, + crossinline block: suspend context(Raise, Raise, Raise, Raise) TypedResponseScope>, ByCode>, ByCode>, ByCode>, Params>.() -> Unit, ) { route(endpoint) response@{ recover( @@ -103,9 +104,9 @@ inline fun Route.routeWithRaise( - endpoint: Endpoint>, ByCode>, ByCode>, ByCode>, ByCode>, Params>, - crossinline block: suspend context(Raise, Raise, Raise, Raise, Raise) TypedResponseScope>, ByCode>, ByCode>, ByCode>, ByCode>, Params>.() -> Unit, +inline fun >, ByCode>, ByCode>, ByCode>, ByCode>, reified Params : Parameters> Route.routeWithRaise( + endpoint: Endpoint, + crossinline block: suspend context(Raise, Raise, Raise, Raise, Raise) TypedResponseScope>, ByCode>, ByCode>, ByCode>, ByCode>, Params>.() -> Unit, ) { route(endpoint) response@{ recover( @@ -137,9 +138,9 @@ inline fun Route.routeWithRaise( - endpoint: Endpoint>, ByCode>, ByCode>, ByCode>, ByCode>, ByCode>, Params>, - crossinline block: suspend context(Raise, Raise, Raise, Raise, Raise, Raise) TypedResponseScope>, ByCode>, ByCode>, ByCode>, ByCode>, ByCode>, Params>.() -> Unit, +inline fun >, ByCode>, ByCode>, ByCode>, ByCode>, ByCode>, reified Params : Parameters> Route.routeWithRaise( + endpoint: Endpoint, + crossinline block: suspend context(Raise, Raise, Raise, Raise, Raise, Raise) TypedResponseScope>, ByCode>, ByCode>, ByCode>, ByCode>, ByCode>, Params>.() -> Unit, ) { route(endpoint) response@{ recover( -- 2.51.2 From 926e8db1d0dd30061207080b522c1a935b099100 Mon Sep 17 00:00:00 2001 From: skyecodes Date: Mon, 2 Feb 2026 00:43:59 +0100 Subject: [PATCH 2/2] test(server-arrow): Add unit tests for server-arrow --- .../src/commonTest/kotlin/RouteTest.kt | 225 ++++++++++++++++++ .../src/commonTest/kotlin/ServerTests.kt | 7 + 2 files changed, 232 insertions(+) create mode 100644 server-arrow/src/commonTest/kotlin/RouteTest.kt create mode 100644 server-arrow/src/commonTest/kotlin/ServerTests.kt diff --git a/server-arrow/src/commonTest/kotlin/RouteTest.kt b/server-arrow/src/commonTest/kotlin/RouteTest.kt new file mode 100644 index 0000000..53b2d70 --- /dev/null +++ b/server-arrow/src/commonTest/kotlin/RouteTest.kt @@ -0,0 +1,225 @@ +@file:OptIn(ExperimentalTraceApi::class) + +package opensavvy.spine.server.arrow + +import arrow.core.raise.ExperimentalTraceApi +import arrow.core.raise.Raise +import arrow.core.raise.context.ensure +import arrow.core.raise.context.ensureNotNull +import io.ktor.client.* +import io.ktor.http.* +import io.ktor.http.HttpStatusCode.Companion.Created +import io.ktor.serialization.kotlinx.json.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.Serializable +import opensavvy.prepared.compat.arrow.core.failOnRaise +import opensavvy.prepared.compat.ktor.preparedClient +import opensavvy.prepared.compat.ktor.preparedServer +import opensavvy.prepared.suite.SuiteDsl +import opensavvy.prepared.suite.assertions.checkThrows +import opensavvy.prepared.suite.map +import opensavvy.prepared.suite.prepared +import opensavvy.prepared.suite.random.nextInt +import opensavvy.prepared.suite.random.random +import opensavvy.prepared.suite.random.randomInt +import opensavvy.spine.api.* +import opensavvy.spine.api.Parameters +import opensavvy.spine.client.arrow.body +import opensavvy.spine.client.bodyOrThrow +import opensavvy.spine.client.handle +import opensavvy.spine.client.request +import opensavvy.spine.server.arrow.Routes.Users +import opensavvy.spine.server.arrow.Routes.Users.User +import opensavvy.spine.server.respond +import opensavvy.spine.server.route +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation + +// region API declaration + +@Serializable +private data class UserDto(val id: String, val name: String, val enabled: Boolean) + +@Serializable +private data class NotFound(val id: String) { + + companion object : FailureCompanion(HttpStatusCode.NotFound) +} + +@Serializable +private data class AlreadyExists(val id: String) + +@Serializable +private data class NotAllowed(val reason: String) { + + companion object : FailureCompanion(HttpStatusCode.Forbidden) +} + +private class UserSearchParams(data: ParameterStorage) : Parameters(data) { + var includeDisabled by parameter(false) +} + +private object Routes : RootResource("routes") { + + object Users : StaticResource("users", Routes) { + + val list by get() + .response>() + .parameters(::UserSearchParams) + + val create by post() + .request() + .failure(HttpStatusCode.Conflict) + + object User : DynamicResource("user", Users) { + + val get by get() + .response() + .failure(NotFound) + + val delete by delete() + .failure(NotFound) + .failure(NotAllowed) + } + } +} + +// endregion +// region Server-side implementation + +private val server by preparedServer { + install(ServerContentNegotiation) { + json() + } + + val data = ArrayList() + val dataLock = Mutex() + + routing { + route(Users.list) { + respond( + dataLock.withLock("list") { data.filter { it.enabled || parameters.includeDisabled } } + ) + } + + routeWithRaise(Users.create) { + dataLock.withLock("create $body") { + ensure(!data.any { it.id == body.id }) { AlreadyExists(body.id) } + data += body + } + respond(Created) + } + + routeWithRaise(User.get) { + val id = idOf(User) + + val user = dataLock.withLock("get $id") { data.find { it.id == id } } + + ensureNotNull(user) { NotFound(id) } + + respond(user) + } + + routeWithRaise(User.delete) { + val id = idOf(User) + + dataLock.withLock("delete $id") { data.removeAll { it.id == id } } + + respond() + } + } +} + +// endregion +// region Client-side implementation + +val client by server.preparedClient { + install(ClientContentNegotiation) { + json() + } +} + +private suspend fun HttpClient.listUsers(includeDisabled: Boolean = false) = request( + endpoint = Routes / Users / Users.list, + parameters = { + this.includeDisabled = includeDisabled + }, +).bodyOrThrow() + +private suspend fun HttpClient.createUser(user: UserDto) = request(Routes / Users / Users.create, user).handle( + handle1 = { throw RuntimeException("Could not find user ${it.id}") }, + transform = { }, +) + +private suspend fun HttpClient.getUser(id: String) = request(Routes / Users / User(id) / User.get).handle( + handle1 = { null }, + transform = { it }, +) + +context(_: Raise, _: Raise) +private suspend fun HttpClient.deleteUser(id: String) = request(Routes / Users / User(id) / User.delete).body() + +// endregion + +fun SuiteDsl.routeTest() = suite("Route test") { + val userId by randomInt(0, 999).map { it.toString() } + + test("Listing users when there are no users should return an empty list") { + check(client().listUsers(includeDisabled = false) == emptyList()) + } + + test("Listing users when there are no users should return an empty list, even if we want to access disabled users") { + check(client().listUsers(includeDisabled = true) == emptyList()) + } + + test("Creating a user") { + client().createUser(UserDto(userId(), "test", true)) + } + + test("Cannot create two users with the same ID") { + client().createUser(UserDto(userId(), "test", true)) + + val e = checkThrows { + client().createUser(UserDto(userId(), "test", true)) + } + check(e.message == "Could not find user ${userId()}") + } + + val enabledUser by prepared { + UserDto(random.nextInt(0, 999).toString(), "enabled user", true) + .also { client().createUser(it) } + } + + val disabledUser by prepared { + UserDto(random.nextInt(0, 999).toString(), "disabled user", false) + .also { client().createUser(it) } + } + + test("Listing enabled users") { + enabledUser() + disabledUser() + + check(client().listUsers(includeDisabled = false) == listOf(enabledUser())) + } + + test("Listing all users") { + enabledUser() + disabledUser() + + check(client().listUsers(includeDisabled = true) == listOf(enabledUser(), disabledUser())) + } + + test("Accessing the details of a user") { + val user = enabledUser() + + check(client().getUser(user.id) == user) + } + + test("Deleting a user") { + val user = enabledUser() + + failOnRaise { client().deleteUser(user.id) } + check(client().listUsers() == emptyList()) + } +} diff --git a/server-arrow/src/commonTest/kotlin/ServerTests.kt b/server-arrow/src/commonTest/kotlin/ServerTests.kt new file mode 100644 index 0000000..fbdcbed --- /dev/null +++ b/server-arrow/src/commonTest/kotlin/ServerTests.kt @@ -0,0 +1,7 @@ +package opensavvy.spine.server.arrow + +import opensavvy.prepared.runner.testballoon.preparedSuite + +val ServerTests by preparedSuite { + routeTest() +} -- 2.51.2