diff --git a/state/build.gradle.kts b/state/build.gradle.kts index ec84d18..821d9ce 100644 --- a/state/build.gradle.kts +++ b/state/build.gradle.kts @@ -15,6 +15,7 @@ kotlin { val commonMain by getting { dependencies { api(KotlinX.coroutines.core) + api("io.arrow-kt:arrow-core:_") implementation(projects.logger) } diff --git a/versions.properties b/versions.properties index d64c63f..39e281f 100644 --- a/versions.properties +++ b/versions.properties @@ -15,6 +15,8 @@ plugin.org.jetbrains.kotlinx.kover=0.6.0 version.ch.qos.logback..logback-classic=1.2.11 +version.io.arrow-kt..arrow-core=1.1.3 + version.kotlin=1.7.20 version.kotlinx.coroutines=1.6.4 @@ -23,6 +25,6 @@ version.kotlinx.datetime=0.4.0 version.kotlinx.serialization=1.4.0 - version.ktor=2.1.1 +version.ktor=2.1.1 version.org.slf4j..slf4j-api=1.7.36 -- 2.51.2 From 4ebdb76644b731408fa2641ec585c41e614daab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 27 Oct 2022 00:16:38 +0200 Subject: [PATCH 2/8] feat(state): Report the current progression using CoroutineContext Closes https://gitlab.com/opensavvy/pedestal/-/issues/21 --- .../opensavvy.state/ProgressionReporter.kt | 162 ++++++++++++++++++ .../ProgressionReporterTest.kt | 75 ++++++++ 2 files changed, 237 insertions(+) create mode 100644 state/src/commonMain/kotlin/opensavvy.state/ProgressionReporter.kt create mode 100644 state/src/commonTest/kotlin/opensavvy.state/ProgressionReporterTest.kt diff --git a/state/src/commonMain/kotlin/opensavvy.state/ProgressionReporter.kt b/state/src/commonMain/kotlin/opensavvy.state/ProgressionReporter.kt new file mode 100644 index 0000000..d67b503 --- /dev/null +++ b/state/src/commonMain/kotlin/opensavvy.state/ProgressionReporter.kt @@ -0,0 +1,162 @@ +package opensavvy.state + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext +import opensavvy.state.Progression.Companion.loading +import opensavvy.state.ProgressionReporter.Companion.progressionReporter +import opensavvy.state.ProgressionReporter.Companion.report +import opensavvy.state.ProgressionReporter.Companion.transformProgress +import opensavvy.state.ProgressionReporter.Companion.transformQuantifiedProgress +import opensavvy.state.ProgressionReporter.StateFlowReporter +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext + +/** + * Stores progression information about the currently running task. + * + * Coroutines pass a [CoroutineContext] object around which stores information about the currently-running process. + * To report the current [Progression] of a coroutine, [ProgressionReporter] can be added to the context of a coroutine. + * It is then possible to access the current progression using [progress][StateFlowReporter.progress]. + * ```kotlin + * val scope = CoroutineScope() + * val reporter = progressionReporter() + * + * scope.launch { + * withContext(reporter) { + * repeat(100) { + * delay(10) + * report(loading(it / 100.0)) + * } + * } + * } + * + * reporter.progress.collect { + * println("Current progress: $it") + * } + * ``` + * + * To instantiate a progression reporter, see [progressionReporter]. + * To report the current loading, see [report]. + * To manage subtasks, see [transformProgress] and [transformQuantifiedProgress]. + */ +abstract class ProgressionReporter : AbstractCoroutineContextElement(Key) { + + /** + * Notifies this reporter of the current [progression]. + */ + abstract suspend fun emit(progression: Progression) + + /** + * Implementation of [ProgressionReporter] using a [StateFlow]. + * + * The internal StateFlow is exposed as [progress]. + */ + class StateFlowReporter : ProgressionReporter() { + private val state = MutableStateFlow(loading()) + + /** + * The current reported progress. + */ + val progress = state as StateFlow + + override suspend fun emit(progression: Progression) { + state.value = progression + } + + override fun toString() = state.value.toString() + } + + private class ChildReporter( + private val parent: ProgressionReporter, + private val transform: (Progression) -> Progression, + ) : ProgressionReporter() { + override suspend fun emit(progression: Progression) { + parent.emit(transform(progression)) + } + } + + object Key : CoroutineContext.Key + + companion object { + + //region Builders + + /** + * Creates a default [ProgressionReporter]. + */ + fun progressionReporter() = StateFlowReporter() + + /** + * Declares [block] as a subtask of the current coroutine. + * + * Inside [block], all progression events are passed to [transform] before being transmitted to the parent coroutine. + */ + @OptIn(ExperimentalContracts::class) + suspend fun transformProgress( + transform: (Progression) -> Progression, + block: suspend CoroutineScope.() -> Unit, + ) { + contract { + callsInPlace(block, InvocationKind.AT_MOST_ONCE) + } + + val reporter = currentCoroutineContext()[Key] + + if (reporter != null) + withContext(ChildReporter(reporter, transform), block) + } + + /** + * Declares [block] as a subtask of the current coroutine. + * + * Inside [block], loading events are processed by this function. + * - if the coroutine reports a [quantified loading][Progression.Loading.Quantified], it is passed through + * [transform] and emitted to the parent coroutine, + * - if the coroutine reports an [unquantified loading][Progression.Loading.Unquantified], it is passed through + * [transform] as a quantified loading of 50% and emitted to the parent coroutine, + * - if the coroutine reports a [done][Progression.Done], it is passed through unchanged to the parent. + */ + @OptIn(ExperimentalContracts::class) + suspend fun transformQuantifiedProgress( + transform: (Progression.Loading.Quantified) -> Progression, + block: suspend CoroutineScope.() -> Unit, + ) { + contract { + callsInPlace(block, InvocationKind.AT_MOST_ONCE) + } + + val reporter = currentCoroutineContext()[Key] + + if (reporter != null) + withContext( + ChildReporter(reporter) { + when (it) { + is Progression.Loading.Quantified -> transform(it) + is Progression.Loading.Unquantified -> transform(loading(0.5)) + Progression.Done -> it + } + }, + block + ) + } + + //endregion + + /** + * Report [progression] to the [ProgressionReporter] in the current coroutine context. + * + * If there is currently no [ProgressionReporter], this function does nothing. + */ + @Suppress("MemberVisibilityCanBePrivate") + suspend fun report(progression: Progression) { + val reporter = currentCoroutineContext()[Key] ?: return + reporter.emit(progression) + } + } +} diff --git a/state/src/commonTest/kotlin/opensavvy.state/ProgressionReporterTest.kt b/state/src/commonTest/kotlin/opensavvy.state/ProgressionReporterTest.kt new file mode 100644 index 0000000..f468f8f --- /dev/null +++ b/state/src/commonTest/kotlin/opensavvy.state/ProgressionReporterTest.kt @@ -0,0 +1,75 @@ +package opensavvy.state + +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield +import opensavvy.state.Progression.Companion.done +import opensavvy.state.Progression.Companion.loading +import opensavvy.state.ProgressionReporter.Companion.progressionReporter +import opensavvy.state.ProgressionReporter.Companion.report +import opensavvy.state.ProgressionReporter.Companion.transformQuantifiedProgress +import kotlin.test.Test +import kotlin.test.assertEquals + +class ProgressionReporterTest { + + @Test + fun simple() = runTest { + val reporter = progressionReporter() + + launch { + withContext(reporter) { + report(loading(0.0)) + delay(100) + report(loading(0.5)) + delay(100) + report(loading(1.0)) + } + } + + yield() + + assertEquals(loading(0.0), reporter.progress.value) + delay(150) + assertEquals(loading(0.5), reporter.progress.value) + delay(150) + assertEquals(loading(1.0), reporter.progress.value) + } + + @Test + fun nested() = runTest { + val reporter = progressionReporter() + + launch { + withContext(reporter) { + transformQuantifiedProgress({ loading(it.normalized / 2) }) { + report(loading()) + delay(10) + report(done()) + delay(10) + report(loading(0.5)) + } + delay(100) + report(loading(0.5)) + } + } + + yield() + + delay(50) + assertEquals(loading(0.25), reporter.progress.value) + delay(150) + assertEquals(loading(0.5), reporter.progress.value) + } + + @Test + fun noReporter() = runTest { + // There is no ProgressionReporter in this coroutine, so reporting progress should do nothing + report(loading()) + report(loading(0.5)) + report(done()) + } + +} -- 2.51.2 From 37797a084ac6648e9f2552ab1496e7430cd47802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 27 Oct 2022 00:31:21 +0200 Subject: [PATCH 3/8] ci(gitlab): Mark jobs as interruptible --- .gitlab-ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 57f2c59..f6b5705 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -23,6 +23,7 @@ include: - ./gradlew $module:publishKotlinMultiplatformPublicationToGitLabRepository $module:publishJvmPublicationToGitLabRepository $module:publishJsPublicationToGitLabRepository only: - tags + interruptible: false logger:publish: extends: [ .publish ] @@ -95,6 +96,7 @@ spine-ktor-server:publish: - merge_requests - main - tags + interruptible: true .test-jvm-convert-coverage: image: registry.gitlab.com/haynes/jacoco2cobertura:1.0.8 @@ -111,6 +113,7 @@ spine-ktor-server:publish: - merge_requests - main - tags + interruptible: true logger:test:jvm: extends: [ .test-jvm ] @@ -202,6 +205,7 @@ dokka: paths: - documentation expose_as: "Documentation" + interruptible: true pages: image: alpine:latest @@ -218,3 +222,4 @@ pages: rules: - if: '$CI_COMMIT_BRANCH == "main"' - when: never + interruptible: false -- 2.51.2 From e33230dab35026d61014d822e69308e2a3d2d13c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 27 Oct 2022 20:16:03 +0200 Subject: [PATCH 4/8] feat(state): State rewrite Now that progression is reported through the CoroutineContext and doesn't appear in the function's result, we can use normal suspending functions which return Either to model our errors. --- .../kotlin/opensavvy.state/Failure.kt | 54 ++++++ .../kotlin/opensavvy.state/Slice.kt | 141 -------------- .../kotlin/opensavvy.state/State.kt | 83 -------- .../kotlin/opensavvy.state/StateBuilder.kt | 183 ------------------ .../kotlin/opensavvy.state/Status.kt | 92 --------- .../kotlin/opensavvy.state/slice/Accessors.kt | 22 +++ .../kotlin/opensavvy.state/slice/Builder.kt | 27 +++ .../kotlin/opensavvy.state/slice/Factories.kt | 11 ++ .../opensavvy.state/slice/Predicates.kt | 62 ++++++ .../kotlin/opensavvy.state/slice/Slice.kt | 12 ++ .../opensavvy.state/ComprehensionsTest.kt | 74 ++----- .../kotlin/opensavvy.state/SliceTest.kt | 158 +++++---------- .../opensavvy.state/StateBuilderTest.kt | 64 +++--- 13 files changed, 274 insertions(+), 709 deletions(-) create mode 100644 state/src/commonMain/kotlin/opensavvy.state/Failure.kt delete mode 100644 state/src/commonMain/kotlin/opensavvy.state/Slice.kt delete mode 100644 state/src/commonMain/kotlin/opensavvy.state/State.kt delete mode 100644 state/src/commonMain/kotlin/opensavvy.state/StateBuilder.kt delete mode 100644 state/src/commonMain/kotlin/opensavvy.state/Status.kt create mode 100644 state/src/commonMain/kotlin/opensavvy.state/slice/Accessors.kt create mode 100644 state/src/commonMain/kotlin/opensavvy.state/slice/Builder.kt create mode 100644 state/src/commonMain/kotlin/opensavvy.state/slice/Factories.kt create mode 100644 state/src/commonMain/kotlin/opensavvy.state/slice/Predicates.kt create mode 100644 state/src/commonMain/kotlin/opensavvy.state/slice/Slice.kt diff --git a/state/src/commonMain/kotlin/opensavvy.state/Failure.kt b/state/src/commonMain/kotlin/opensavvy.state/Failure.kt new file mode 100644 index 0000000..b3f6178 --- /dev/null +++ b/state/src/commonMain/kotlin/opensavvy.state/Failure.kt @@ -0,0 +1,54 @@ +package opensavvy.state + +/** + * Common failure reasons. + * + * Encoding these reasons at all levels of the project allows APIs (such as Spine) to encode them during communication + * with other services, for example as HTTP status codes. + */ +data class Failure( + val kind: Kind, + val message: String, + val cause: Throwable? = null, +) { + + override fun toString() = "$kind: $message" + + fun toException() = FailureException(this, kind, message, cause) + + class FailureException(val failure: Failure, val kind: Kind, message: String, cause: Throwable? = null) : + RuntimeException(message, cause) + + enum class Kind { + /** + * The request is not valid. + * + * All prerequisites are correct (the user is correctly authenticated and authorized…) but the + * request payload is invalid. + */ + Invalid, + + /** + * The implementation could not determine which user is making the request. + */ + Unauthenticated, + + /** + * The implementation could determine which user is making the request, but they do not have + * sufficient rights to make the request. + */ + Unauthorized, + + /** + * The request is well-formed and the user is allowed to make it, + * but the resource it should apply to could not be found. + */ + NotFound, + + /** + * The implementation caught an unknown error. + */ + Unknown, + ; + } +} diff --git a/state/src/commonMain/kotlin/opensavvy.state/Slice.kt b/state/src/commonMain/kotlin/opensavvy.state/Slice.kt deleted file mode 100644 index 3a67a8a..0000000 --- a/state/src/commonMain/kotlin/opensavvy.state/Slice.kt +++ /dev/null @@ -1,141 +0,0 @@ -package opensavvy.state - -import opensavvy.state.Slice.Companion.failed -import opensavvy.state.Slice.Companion.pending -import opensavvy.state.Slice.Companion.successful - -/** - * Some data at a specific point in time. - * - * The [State] of a value can change over time. - * At each specific instant in time, the 'current state' is represented by this class. - * - * Instances can be generated with the provided factories: [pending], [successful] and [failed]. - */ -data class Slice( - val status: Status, - val progression: Progression, -) { - - //region toString - - override fun toString(): String { - val builder = StringBuilder() - - if (status != Status.Pending) { - builder.append(status) - builder.append(" ") - } - - builder.append(progression) - - return builder.toString() - } - - //endregion - - companion object { - - //region Factories - - /** - * Creates a [Slice] with a [pending status][Status.Pending]. - */ - fun pending( - progression: Progression.Loading, - ) = Slice(Status.Pending, progression) - - /** - * Creates a [Slice] with a [pending status][Status.Pending] and a current progression. - */ - fun pending() = Slice(Status.Pending, Progression.loading()) - - /** - * Creates a [Slice] with a [pending status][Status.Pending] and a current [progression]. - */ - fun pending( - progression: Double, - ) = Slice(Status.Pending, Progression.loading(progression)) - - /** - * Creates a [Slice] with a [successful status][Status.Successful]. - */ - fun successful( - value: T, - progression: Progression = Progression.Done, - ) = Slice(Status.Successful(value), progression) - - /** - * Creates a [Slice] with a [failed status][Status.Failed]. - * - * More specifically, the status is a [Status.ExceptionFailure]. - */ - fun failed( - exception: RuntimeException, - message: String, - progression: Progression = Progression.Done, - ) = Slice(Status.ExceptionFailure(message, exception), progression) - - /** - * Creates a [Slice] with a [failed status][Status.Failed]. - * - * More specifically, the status is a [Status.StandardFailure]. - */ - fun failed( - kind: Status.StandardFailure.Kind, - message: String, - cause: Throwable? = null, - progression: Progression = Progression.Done, - ) = Slice(Status.StandardFailure(kind, message, cause), progression) - - //endregion - //region Accessors - - /** - * Returns the value of this slice, or `null` if it isn't successful. - * - * @see Status.Successful.value - */ - val Slice.valueOrNull: T? - get() = (status as? Status.Successful)?.value - - /** - * Returns the value of this slice, or throws an exception if it isn't successful. - * - * @see Status.Successful.value - * @see Status - * @throws NoSuchElementException This slice is in the [pending][Status.Pending] status. - * @throws Status.Failed This slice is in the [failed][Status.Failed] status. - */ - val Slice.valueOrThrow: T - get() = when (status) { - is Status.Successful -> status.value - is Status.Pending -> throw NoSuchElementException("No value is available for this object, it is still pending: $this") - is Status.Failed -> throw status - } - - //endregion - - } -} - -//region Error management - -/** - * Maps a successful slice from [I] to [O] using [transform]. - * - * If the slice is not successful, it is kept unchanged. - */ -inline fun Slice.mapSuccess(transform: (I) -> O): Slice { - val (status, progression) = this - - val newStatus: Status = when (status) { - is Status.Failed -> status - is Status.Pending -> status - is Status.Successful -> Status.Successful(transform(status.value)) - } - - return Slice(newStatus, progression) -} - -//endregion diff --git a/state/src/commonMain/kotlin/opensavvy.state/State.kt b/state/src/commonMain/kotlin/opensavvy.state/State.kt deleted file mode 100644 index 293946d..0000000 --- a/state/src/commonMain/kotlin/opensavvy.state/State.kt +++ /dev/null @@ -1,83 +0,0 @@ -package opensavvy.state - -import kotlinx.coroutines.flow.* -import opensavvy.state.Slice.Companion.valueOrNull -import opensavvy.state.Slice.Companion.valueOrThrow - -/** - * Tracks changes to values through time. - * - * Stored values should be immutable. - * At any given time, the current value is represented by the immutable [Slice] class. - * This [State] flow allows to subscribe to the future slices. - * - * To create an instance of [State], see the [state] builder. - */ -typealias State = Flow> - -//region Event selection - -/** - * Skips the loading events in [State]. - */ -fun State.skipLoading() = filter { it.progression !is Progression.Loading } - -/** - * Returns the first non-loading element of this [State]. - * - * Because this transforms a flow into a single value, the reactivity is lost. - * Only use this method in contexts where being notified on new values is not important. - */ -suspend fun State.firstResult() = skipLoading().first() - -/** - * Returns the first non-loading element's [valueOrNull] of this [State]. - * - * Because this transforms a flow into a single value, the reactivity is lost. - * Only use this method in contexts where being notified on new values is not important. - */ -suspend fun State.firstResultOrNull() = firstResult().valueOrNull - -/** - * Returns the first non-loading element's [valueOrThrow] of this [State]. - * - * Because this transforms a flow into a single value, the reactivity is lost. - * Only use this method in contexts where being notified on new values is not important. - */ -suspend fun State.firstResultOrThrow() = firstResult().valueOrThrow - -//endregion -//region Error management - -inline fun State.onEachSuccess(crossinline block: (T) -> Unit): State = onEach { slice -> - val (status, _) = slice - - if (status is Status.Successful) - block(status.value) -} - -inline fun State.mapSuccess(crossinline transform: (I) -> O): State = map { slice -> - slice.mapSuccess(transform) -} - -inline fun State.mapSuccessSlice(crossinline transform: (I) -> Slice): State = map { - val (status, progression) = it - - when (status) { - is Status.Failed -> Slice(status, progression) - is Status.Pending -> Slice(status, progression) - is Status.Successful -> transform(status.value) - } -} - -fun State.flatMapSuccess(transform: suspend StateBuilder.(I) -> Unit): State = transform { - val (status, progression) = it - - when (status) { - is Status.Failed -> emit(Slice(status, progression)) - is Status.Pending -> emit(Slice(status, progression)) - is Status.Successful -> emitAll(state { transform(status.value) }) - } -} - -//endregion diff --git a/state/src/commonMain/kotlin/opensavvy.state/StateBuilder.kt b/state/src/commonMain/kotlin/opensavvy.state/StateBuilder.kt deleted file mode 100644 index bce0c3b..0000000 --- a/state/src/commonMain/kotlin/opensavvy.state/StateBuilder.kt +++ /dev/null @@ -1,183 +0,0 @@ -package opensavvy.state - -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.FlowCollector -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.flow -import opensavvy.state.Slice.Companion.failed -import kotlin.contracts.ExperimentalContracts -import kotlin.contracts.contract - -/** - * Builder for [State]. - * - * This type is used for conveniently building asynchronous flows in synchronous environments. - * Instances are generally built using the [state] builder. - */ -typealias StateBuilder = FlowCollector> - -/** - * Builder for [State]. - * - * ```kotlin - * val id = … - * val foo = state { - * emitLoading(id, 50.0) // 50% - * ensureValid(id, currentUser.isAllowedToRead(id)) { "You are not allowed to read the object $id" } - * emitSuccessful(id, database.dereference(id)) - * } - * ``` - */ -fun state(block: suspend StateBuilder.() -> Unit) = flow(block) - .catch { - when (it) { - // The user has requested the cancellation of the flow builder. - // It is their responsibility to emit the correct exception in the flow, so we have nothing more to do. - is StateBuilderCancellation -> Unit - - // The coroutine was cancelled. - // CancellationExceptions MUST be rethrown, or it breaks coroutines - // See https://kotlinlang.org/docs/cancellation-and-timeouts.html#cancellation-is-cooperative - is CancellationException -> throw CancellationException("A state builder was cancelled", it) - - is Status.StandardFailure -> emit( - failed( - it.kind, - it.message ?: "Caught an error without message", - it.cause, - Progression.done() - ) - ) - - is IllegalArgumentException -> emit( - failed( - Status.StandardFailure.Kind.Invalid, - it.message ?: "Caught an IllegalArgumentException without message", - cause = it, - Progression.done(), - ) - ) - - is IllegalStateException -> emit( - failed( - Status.StandardFailure.Kind.Invalid, - it.message ?: "Caught an IllegalStateException without message", - cause = it, - Progression.done(), - ) - ) - - // All other exceptions are caught into the Kind.Unknown standard failure. - else -> emit( - failed( - Status.StandardFailure.Kind.Unknown, - it.message ?: "Unknown error caught in the state builder", - it, - Progression.done() - ) - ) - } - } - -/** - * Exception used internally by the [state] function to provide cancellation functionality. - */ -private class StateBuilderCancellation : RuntimeException("The 'state' builder has been cancelled") - -//region Predicate checkers - -/** - * Stops the currently running [state] builder. - * - * If no [state] builder is active, acts as a [CancellationException]. - */ -@Suppress("UnusedReceiverParameter") // used for namespacing -fun StateBuilder.shortCircuit(): Nothing { - throw StateBuilderCancellation() -} - -/** - * Ensures that [condition] is `true`. - * - * If it is, does nothing. - * Otherwise, emits an [Invalid][Status.StandardFailure.Kind.Invalid] slice and cancels the [state] builder. - */ -@OptIn(ExperimentalContracts::class) -suspend inline fun StateBuilder.ensureValid( - condition: Boolean, - lazyMessage: () -> String, -) { - contract { - returns() implies condition - } - - if (!condition) { - emit(failed(Status.StandardFailure.Kind.Invalid, lazyMessage(), progression = Progression.done())) - shortCircuit() - } -} - -/** - * Ensures that [condition] is `true`. - * - * If it is, does nothing. - * Otherwise, emits an [Unauthenticated][Status.StandardFailure.Kind.Unauthenticated] slice and cancels the [state] builder. - */ -@OptIn(ExperimentalContracts::class) -suspend inline fun StateBuilder.ensureAuthenticated( - condition: Boolean, - lazyMessage: () -> String, -) { - contract { - returns() implies condition - } - - if (!condition) { - emit(failed(Status.StandardFailure.Kind.Unauthenticated, lazyMessage(), progression = Progression.done())) - shortCircuit() - } -} - -/** - * Ensures that [condition] is `true`. - * - * If it is, does nothing. - * Otherwise, emits an [Unauthorized][Status.StandardFailure.Kind.Unauthorized] slice and cancels the [state] builder. - */ -@OptIn(ExperimentalContracts::class) -suspend inline fun StateBuilder.ensureAuthorized( - condition: Boolean, - lazyMessage: () -> String, -) { - contract { - returns() implies condition - } - - if (!condition) { - emit(failed(Status.StandardFailure.Kind.Unauthorized, lazyMessage(), progression = Progression.done())) - shortCircuit() - } -} - -/** - * Ensures that [condition] is `true`. - * - * If it is, does nothing. - * Otherwise, emits an [NotFound][Status.StandardFailure.Kind.NotFound] slice and cancels the [state] builder. - */ -@OptIn(ExperimentalContracts::class) -suspend inline fun StateBuilder.ensureFound( - condition: Boolean, - lazyMessage: () -> String, -) { - contract { - returns() implies condition - } - - if (!condition) { - emit(failed(Status.StandardFailure.Kind.NotFound, lazyMessage(), progression = Progression.done())) - shortCircuit() - } -} - -//endregion diff --git a/state/src/commonMain/kotlin/opensavvy.state/Status.kt b/state/src/commonMain/kotlin/opensavvy.state/Status.kt deleted file mode 100644 index 5a00ad7..0000000 --- a/state/src/commonMain/kotlin/opensavvy.state/Status.kt +++ /dev/null @@ -1,92 +0,0 @@ -package opensavvy.state - -/** - * The status of a value. - * - * The status can be in three different states: - * - [Pending] represents a value which is not yet available. - * - [Successful] represents a successful result. - * - [Failed] represents an unsuccessful result. - */ -sealed interface Status { - - object Pending : Status { - override fun toString() = "Pending" - } - - data class Successful( - val value: T, - ) : Status { - override fun toString() = "$value" - } - - abstract class Failed(message: String?, cause: Throwable?) : RuntimeException(message, cause), Status - - //region Failure implementations - - class ExceptionFailure(message: String, cause: RuntimeException) : Failed(message, cause) - - class StandardFailure( - val kind: Kind, - message: String, - cause: Throwable? = null, - ) : Failed(message, cause) { - - //region toString, equals, hashCode - - override fun toString() = "$kind($message)" - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is StandardFailure) return false - - if (kind != other.kind) return false - if (message != other.message) return false - - return true - } - - override fun hashCode(): Int { - var result = kind.hashCode() - result = 31 * result + message.hashCode() - return result - } - - //endregion - - enum class Kind { - /** - * The request is not valid. - * - * All prerequisites are correct (the user is correctly authenticated and authorized…) but the - * request payload is invalid. - */ - Invalid, - - /** - * The implementation could not determine which user is making the request. - */ - Unauthenticated, - - /** - * The implementation could determine which user is making the request, but they do not have - * sufficient rights to make the request. - */ - Unauthorized, - - /** - * The request is well-formed and the user is allowed to make it, - * but the resource it should apply to could not be found. - */ - NotFound, - - /** - * The implementation caught an unknown error. - */ - Unknown, - ; - } - } - - //endregion - -} diff --git a/state/src/commonMain/kotlin/opensavvy.state/slice/Accessors.kt b/state/src/commonMain/kotlin/opensavvy.state/slice/Accessors.kt new file mode 100644 index 0000000..50f678e --- /dev/null +++ b/state/src/commonMain/kotlin/opensavvy.state/slice/Accessors.kt @@ -0,0 +1,22 @@ +package opensavvy.state.slice + +import arrow.core.Either +import opensavvy.state.Failure + +/** + * Returns the value of this slice, or `null` if it isn't successful. + */ +val Slice.valueOrNull: T? + get() = (this as? Either.Right)?.value + +/** + * Returns the value of this slice, or throws the [Failure] if it isn't successful. + * + * This function breaks the functional error handling paradigm, it shouldn't be used in regular code. + * It is useful in tests. + */ +val Slice.valueOrThrow: T + get() = when (this) { + is Either.Left -> throw value.toException() + is Either.Right -> value + } diff --git a/state/src/commonMain/kotlin/opensavvy.state/slice/Builder.kt b/state/src/commonMain/kotlin/opensavvy.state/slice/Builder.kt new file mode 100644 index 0000000..8a1e633 --- /dev/null +++ b/state/src/commonMain/kotlin/opensavvy.state/slice/Builder.kt @@ -0,0 +1,27 @@ +package opensavvy.state.slice + +import arrow.core.continuations.EffectScope +import arrow.core.continuations.either +import arrow.core.left +import kotlinx.coroutines.withContext +import opensavvy.state.Failure +import opensavvy.state.ProgressionReporter +import kotlin.coroutines.coroutineContext + +/** + * Performs some calculation which may fail. + */ +suspend fun slice(reporter: ProgressionReporter? = null, block: suspend EffectScope.() -> T): Slice = + withContext(reporter ?: coroutineContext) { + try { + either(block) + } catch (e: Failure.FailureException) { + e.failure.left() + } catch (e: IllegalArgumentException) { + failed(e.message ?: "IllegalArgumentException without a message", Failure.Kind.Invalid, cause = e) + } catch (e: IllegalStateException) { + failed(e.message ?: "IllegalStateException without a message", Failure.Kind.Invalid, cause = e) + } catch (e: NoSuchElementException) { + failed(e.message ?: "NoSuchElementException without a message", Failure.Kind.NotFound, cause = e) + } + } diff --git a/state/src/commonMain/kotlin/opensavvy.state/slice/Factories.kt b/state/src/commonMain/kotlin/opensavvy.state/slice/Factories.kt new file mode 100644 index 0000000..d2b409f --- /dev/null +++ b/state/src/commonMain/kotlin/opensavvy.state/slice/Factories.kt @@ -0,0 +1,11 @@ +package opensavvy.state.slice + +import arrow.core.left +import arrow.core.right +import opensavvy.state.Failure + +fun successful(value: T) = + value.right() + +fun failed(message: String, kind: Failure.Kind = Failure.Kind.Unknown, cause: RuntimeException? = null) = + Failure(kind, message, cause).left() diff --git a/state/src/commonMain/kotlin/opensavvy.state/slice/Predicates.kt b/state/src/commonMain/kotlin/opensavvy.state/slice/Predicates.kt new file mode 100644 index 0000000..d8f4b9f --- /dev/null +++ b/state/src/commonMain/kotlin/opensavvy.state/slice/Predicates.kt @@ -0,0 +1,62 @@ +package opensavvy.state.slice + +import arrow.core.continuations.EffectScope +import opensavvy.state.Failure +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +@OptIn(ExperimentalContracts::class) +suspend inline fun EffectScope.ensureOrShift( + condition: Boolean, + kind: Failure.Kind = Failure.Kind.Unknown, + lazyMessage: () -> String, +) { + contract { + returns() implies condition + callsInPlace(lazyMessage, InvocationKind.AT_MOST_ONCE) + } + + if (!condition) + shift(Failure(kind, lazyMessage())) +} + +@OptIn(ExperimentalContracts::class) +suspend inline fun EffectScope.ensureValid(condition: Boolean, lazyMessage: () -> String) { + contract { + returns() implies condition + callsInPlace(lazyMessage, InvocationKind.AT_MOST_ONCE) + } + + ensureOrShift(condition, Failure.Kind.Invalid, lazyMessage) +} + +@OptIn(ExperimentalContracts::class) +suspend inline fun EffectScope.ensureAuthenticated(condition: Boolean, lazyMessage: () -> String) { + contract { + returns() implies condition + callsInPlace(lazyMessage, InvocationKind.AT_MOST_ONCE) + } + + ensureOrShift(condition, Failure.Kind.Unauthenticated, lazyMessage) +} + +@OptIn(ExperimentalContracts::class) +suspend inline fun EffectScope.ensureAuthorized(condition: Boolean, lazyMessage: () -> String) { + contract { + returns() implies condition + callsInPlace(lazyMessage, InvocationKind.AT_MOST_ONCE) + } + + ensureOrShift(condition, Failure.Kind.Unauthorized, lazyMessage) +} + +@OptIn(ExperimentalContracts::class) +suspend inline fun EffectScope.ensureFound(condition: Boolean, lazyMessage: () -> String) { + contract { + returns() implies condition + callsInPlace(lazyMessage, InvocationKind.AT_MOST_ONCE) + } + + ensureOrShift(condition, Failure.Kind.NotFound, lazyMessage) +} diff --git a/state/src/commonMain/kotlin/opensavvy.state/slice/Slice.kt b/state/src/commonMain/kotlin/opensavvy.state/slice/Slice.kt new file mode 100644 index 0000000..132ced0 --- /dev/null +++ b/state/src/commonMain/kotlin/opensavvy.state/slice/Slice.kt @@ -0,0 +1,12 @@ +package opensavvy.state.slice + +import arrow.core.Either +import opensavvy.state.Failure + +/** + * The result of a calculation that may have failed. + * + * [Slice] is an implementation of the [Functional Error Handling](https://arrow-kt.io/docs/patterns/error_handling/) + * pattern recommended by Arrow. + */ +typealias Slice = Either diff --git a/state/src/commonTest/kotlin/opensavvy.state/ComprehensionsTest.kt b/state/src/commonTest/kotlin/opensavvy.state/ComprehensionsTest.kt index a04c04d..f5a83ef 100644 --- a/state/src/commonTest/kotlin/opensavvy.state/ComprehensionsTest.kt +++ b/state/src/commonTest/kotlin/opensavvy.state/ComprehensionsTest.kt @@ -1,15 +1,13 @@ -@file:OptIn(ExperimentalCoroutinesApi::class) +@file:OptIn(ExperimentalCoroutinesApi::class, ExperimentalCoroutinesApi::class) package opensavvy.state import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest -import opensavvy.state.Slice.Companion.failed -import opensavvy.state.Slice.Companion.pending -import opensavvy.state.Slice.Companion.successful import kotlin.test.Test import kotlin.test.assertEquals @@ -18,74 +16,24 @@ import kotlin.test.assertEquals */ class ComprehensionsTest { - private fun decodeStringOrFailed(str: String): Slice { - val decoded = str.toIntOrNull() - ?: return failed(Status.StandardFailure.Kind.Invalid, "The passed string is not an integer: '$str'") - return successful(decoded) - } - - private fun decodeString(str: String) = state { - emit(pending(0.0)) - delay(10) - emit(decodeStringOrFailed(str)) - } - - private fun strings() = state { - emit(successful("5", Progression.loading(0.0))) + private fun strings() = flow { + emit("5") delay(100) - emit(successful("10", Progression.loading(0.5))) + emit("10") delay(100) - emit(successful("wtf", Progression.done())) + emit("wtf") } @Test fun mapValue() = runTest { val results = strings() - .mapSuccess { it.toIntOrNull() } - .toList() - - val expected = listOf( - successful(5, Progression.loading(0.0)), - successful(10, Progression.loading(0.5)), - successful(null, Progression.done()) - ) - - assertEquals(expected, results) - } - - /** - * For each string instance, decode it into an integer if possible. - */ - @Test - fun mapSlice() = runTest { - val results = strings() - .mapSuccessSlice { decodeStringOrFailed(it) } - .toList() - - val expected = listOf( - successful(5), - successful(10), - failed(Status.StandardFailure.Kind.Invalid, "The passed string is not an integer: 'wtf'"), - ) - - assertEquals(expected, results) - } - - @Test - fun oneToMany() = runTest { - val results = strings() - .flatMapSuccess { - emitAll(decodeString(it)) - } + .map { it.toIntOrNull() } .toList() val expected = listOf( - pending(0.0), - successful(5), - pending(0.0), - successful(10), - pending(0.0), - failed(Status.StandardFailure.Kind.Invalid, "The passed string is not an integer: 'wtf'"), + 5, + 10, + null, ) assertEquals(expected, results) diff --git a/state/src/commonTest/kotlin/opensavvy.state/SliceTest.kt b/state/src/commonTest/kotlin/opensavvy.state/SliceTest.kt index d07eaea..6fa03cb 100644 --- a/state/src/commonTest/kotlin/opensavvy.state/SliceTest.kt +++ b/state/src/commonTest/kotlin/opensavvy.state/SliceTest.kt @@ -2,44 +2,50 @@ package opensavvy.state +import arrow.core.right import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest -import opensavvy.state.Slice.Companion.failed -import opensavvy.state.Slice.Companion.pending -import opensavvy.state.Slice.Companion.successful -import opensavvy.state.Slice.Companion.valueOrNull -import opensavvy.state.Slice.Companion.valueOrThrow +import opensavvy.state.Progression.Companion.done +import opensavvy.state.Progression.Companion.loading +import opensavvy.state.ProgressionReporter.Companion.progressionReporter +import opensavvy.state.ProgressionReporter.Companion.report +import opensavvy.state.slice.* import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFailsWith import kotlin.test.assertNotEquals class SliceTest { private data class IntId(val id: Int) : Identifier { - fun request() = state { + suspend fun request() = slice { val id = this@IntId - emit(pending(0.0)) + report(loading(0.0)) delay(20) // Just imagine the conditions are related to what we're testing ensureValid(id.id > 0) { "The number should be greater than 0: ${id.id}" } - emit(pending(0.2)) + report(loading(0.2)) + delay(20) ensureAuthenticated(id.id < 100) { "The number should be lesser than 100: ${id.id}" } - emit(pending(0.4)) + report(loading(0.4)) + delay(20) ensureAuthorized(id.id % 2 == 0) { "The number should be even: ${id.id}" } - emit(pending(0.6)) + report(loading(0.6)) + delay(20) ensureFound(id.id % 9 == 0) { "The number should be a multiple of 9: ${id.id}" } - emit(pending(0.8)) + report(loading(0.8)) + delay(20) - emit(successful(id.id)) + id.id } override fun toString() = "Id($id)" @@ -49,22 +55,29 @@ class SliceTest { @Test fun successful() = runTest { val id = IntId(18) + val reporter = progressionReporter() - val actual = id - .request() - .toList() - - val expected = listOf( - pending(0.0), - pending(0.2), - pending(0.4), - pending(0.6), - pending(0.8), - successful(18), + val actualAsync = async(reporter) { + id.request().also { report(done()) } + } + + val expectedLoading = listOf( + loading(), + loading(0.0), + loading(0.2), + loading(0.4), + loading(0.6), + loading(0.8), ) - assertEquals(expected, actual) - assertEquals(18, actual.last().valueOrThrow) + val actualLoading = reporter.progress.takeWhile { it !is Progression.Done }.toList() + + val actual = actualAsync.await() + assertEquals(18.right(), actual) + assertEquals(18, actual.valueOrNull) + assertEquals(18, actual.valueOrThrow) + + assertEquals(expectedLoading, actualLoading) } @Test @@ -73,12 +86,8 @@ class SliceTest { val actual = id .request() - .toList() - val expected = listOf( - pending(0.0), - failed(Status.StandardFailure.Kind.Invalid, "The number should be greater than 0: -50"), - ) + val expected = failed("The number should be greater than 0: -50", Failure.Kind.Invalid) assertEquals(expected, actual) } @@ -87,15 +96,9 @@ class SliceTest { fun unauthenticated() = runTest { val id = IntId(167) - val actual = id - .request() - .toList() + val actual = id.request() - val expected = listOf( - pending(0.0), - pending(0.2), - failed(Status.StandardFailure.Kind.Unauthenticated, "The number should be lesser than 100: 167"), - ) + val expected = failed("The number should be lesser than 100: 167", Failure.Kind.Unauthenticated) assertEquals(expected, actual) } @@ -104,16 +107,9 @@ class SliceTest { fun unauthorized() = runTest { val id = IntId(5) - val actual = id - .request() - .toList() + val actual = id.request() - val expected = listOf( - pending(0.0), - pending(0.2), - pending(0.4), - failed(Status.StandardFailure.Kind.Unauthorized, "The number should be even: 5"), - ) + val expected = failed("The number should be even: 5", Failure.Kind.Unauthorized) assertEquals(expected, actual) } @@ -122,27 +118,11 @@ class SliceTest { fun notFound() = runTest { val id = IntId(8) - val actual = id - .request() - .toList() - - val expected = listOf( - pending(0.0), - pending(0.2), - pending(0.4), - pending(0.6), - failed(Status.StandardFailure.Kind.NotFound, "The number should be a multiple of 9: 8"), - ) - - assertEquals(expected, actual) - } + val actual = id.request() - @Test - fun destructuration() { - val (status, progression) = successful(0) + val expected = failed("The number should be a multiple of 9: 8", Failure.Kind.NotFound) - assertEquals(Status.Successful(0), status) - assertEquals(Progression.done(), progression) + assertEquals(expected, actual) } @Test @@ -154,45 +134,11 @@ class SliceTest { assertEquals(successful(1), success1) assertNotEquals(success0, success1) - val pending0 = pending() - val pending1 = pending(0.5) - val pendingX = pending(1.0 / 3) - assertEquals(pending(), pending0) - assertEquals(pending(0.5), pending1) - assertEquals(pending(0.5).hashCode(), pending1.hashCode()) - assertNotEquals(pending(0.6), pending1) - assertEquals(pending(1.0 / 3), pendingX) - - val failed0 = failed(Status.StandardFailure.Kind.Invalid, "error") - assertEquals(failed(Status.StandardFailure.Kind.Invalid, "error"), failed0) - assertNotEquals(failed(Status.StandardFailure.Kind.NotFound, "error"), failed0) - assertNotEquals(failed(Status.StandardFailure.Kind.Unauthenticated, "error"), failed0) - assertNotEquals(failed(Status.StandardFailure.Kind.Invalid, "other"), failed0) - - assertEquals("0 Done", success0.toString()) - assertEquals("1 Done", success1.toString()) - assertEquals("Loading", pending0.toString()) - assertEquals("Loading(50%)", pending1.toString()) - assertEquals("Loading(33%)", pendingX.toString()) - assertEquals("Invalid(error) Done", failed0.toString()) - } - - @Test - fun value() { - val success = successful(0) - val pending = pending() - val failed1 = failed(IllegalArgumentException("whatever"), "whatever 2") - val failed2 = failed(Status.StandardFailure.Kind.Invalid, "whatever 3") - - assertEquals(0, success.valueOrNull) - - assertEquals(null, pending.valueOrNull) - assertEquals(null, failed1.valueOrNull) - assertEquals(null, failed2.valueOrNull) - - assertFailsWith { pending.valueOrThrow } - assertFailsWith("whatever 2") { failed1.valueOrThrow } - assertFailsWith("whatever 3") { failed2.valueOrThrow } + assertEquals("Either.Right(0)", success0.toString()) + assertEquals("Either.Right(1)", success1.toString()) + assertEquals("Loading", loading().toString()) + assertEquals("Loading(50%)", loading(0.5).toString()) + assertEquals("Loading(33%)", loading(0.33).toString()) } } diff --git a/state/src/commonTest/kotlin/opensavvy.state/StateBuilderTest.kt b/state/src/commonTest/kotlin/opensavvy.state/StateBuilderTest.kt index e6b1af9..2b9aee1 100644 --- a/state/src/commonTest/kotlin/opensavvy.state/StateBuilderTest.kt +++ b/state/src/commonTest/kotlin/opensavvy.state/StateBuilderTest.kt @@ -2,88 +2,70 @@ package opensavvy.state +import arrow.core.Either import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest -import opensavvy.state.Slice.Companion.failed -import opensavvy.state.Slice.Companion.successful +import opensavvy.state.slice.ensureValid +import opensavvy.state.slice.failed +import opensavvy.state.slice.slice +import opensavvy.state.slice.successful import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs class StateBuilderTest { @Test fun success() = runTest { - val data = state { - emit(successful(0)) - } + val data = slice { 0 } - assertEquals(successful(0), data.firstResult()) + assertEquals(successful(0), data) } @Test fun ensure() = runTest { - val data = state { + val data = slice { ensureValid(false) { "this is always invalid" } + 0 } assertEquals( - failed(Status.StandardFailure.Kind.Invalid, "this is always invalid"), - data.firstResult() + failed("this is always invalid", Failure.Kind.Invalid), + data ) } @Test fun throwStandard() = runTest { - val data = state { - throw Status.StandardFailure(Status.StandardFailure.Kind.Invalid, "this is always invalid") - } - - assertEquals( - failed(Status.StandardFailure.Kind.Invalid, "this is always invalid"), - data.firstResult() - ) - } - - @Test - fun throwOther() = runTest { - val data = state { - throw RuntimeException("some error") + val data = slice { + throw Failure(Failure.Kind.Invalid, "this is always invalid").toException() + @Suppress("UNREACHABLE_CODE") 0 } assertEquals( - failed( - Status.StandardFailure.Kind.Unknown, - "some error" - ), data.firstResult() + failed("this is always invalid", Failure.Kind.Invalid), + data ) } @Test fun throwIllegalArgumentException() = runTest { - val data = state { + val data = slice { require(false) { "some error" } + 0 } - assertEquals( - failed( - Status.StandardFailure.Kind.Invalid, - "some error" - ), data.firstResult() - ) + assertIs>(data) } @Test fun throwIllegalStateException() = runTest { - val data = state { + val data = slice { check(false) { "some error" } + 0 } - assertEquals( - failed( - Status.StandardFailure.Kind.Invalid, - "some error" - ), data.firstResult() - ) + assertIs>(data) } } -- 2.51.2 From fd1ac49aafd956e71aefa04f2c88b1ce2a5d92e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 30 Oct 2022 22:07:42 +0100 Subject: [PATCH 5/8] feat(cache): Cache updates after the state rewrite --- .../opensavvy.cache/BatchingCacheAdapter.kt | 41 ++++-- .../kotlin/opensavvy.cache/Cache.kt | 5 +- .../kotlin/opensavvy.cache/CacheAdapter.kt | 17 ++- .../kotlin/opensavvy.cache/ExpirationCache.kt | 5 +- .../kotlin/opensavvy.cache/MemoryCache.kt | 7 +- .../kotlin/opensavvy.cache/CacheTest.kt | 137 ++++++++---------- .../kotlin/opensavvy.state/State.kt | 12 ++ 7 files changed, 122 insertions(+), 102 deletions(-) create mode 100644 state/src/commonMain/kotlin/opensavvy.state/State.kt diff --git a/cache/src/commonMain/kotlin/opensavvy.cache/BatchingCacheAdapter.kt b/cache/src/commonMain/kotlin/opensavvy.cache/BatchingCacheAdapter.kt index 8ab6e87..5f047ab 100644 --- a/cache/src/commonMain/kotlin/opensavvy.cache/BatchingCacheAdapter.kt +++ b/cache/src/commonMain/kotlin/opensavvy.cache/BatchingCacheAdapter.kt @@ -5,19 +5,18 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.* import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import opensavvy.logger.Logger.Companion.error import opensavvy.logger.loggerFor -import opensavvy.state.* -import opensavvy.state.Slice.Companion.pending +import opensavvy.state.Identifier +import opensavvy.state.slice.Slice import kotlin.coroutines.CoroutineContext import kotlin.coroutines.coroutineContext +private typealias CacheStorage = CompletableDeferred?>> + /** * Cache implementation aimed to be the first link in a cache chain. * @@ -42,12 +41,12 @@ class BatchingCacheAdapter( private val log = loggerFor(this) - private val requests: SendChannel>>>> + private val requests: SendChannel>> init { require(workers > 0) { "There must be at least 1 worker: found $workers" } - val requests = Channel>>>>() + val requests = Channel>>() this.requests = requests val scope = CoroutineScope(context) @@ -58,14 +57,14 @@ class BatchingCacheAdapter( } } - private suspend fun worker(requests: ReceiveChannel>>>>) { + private suspend fun worker(requests: ReceiveChannel>>) { while (coroutineContext.isActive) { val batch = HashSet() // Store the results // We have to store lists of Deferred in case multiple requests to the same Ref happen to be in the same // batch. - val results = HashMap>>>>() + val results = HashMap>>() run { // Suspend until a first request arrives @@ -86,11 +85,11 @@ class BatchingCacheAdapter( .add(promise) } - val states = HashMap>>() + val states = HashMap?>>() // Tell all clients that their request is starting for ((id, promises) in results) { - val state: MutableStateFlow> = MutableStateFlow(pending(Progression.loading(0.0))) + val state: MutableStateFlow?> = MutableStateFlow(null) for (promise in promises) { promise.complete(state) @@ -115,12 +114,12 @@ class BatchingCacheAdapter( } } - override fun get(id: I): State = state { - val promise = CompletableDeferred>>() + override fun get(id: I): Flow> = flow { + val promise = CompletableDeferred?>>() requests.send(id to promise) - emitAll(promise.await()) + emitAll(promise.await().filterNotNull()) } override suspend fun update(values: Collection>) { @@ -134,4 +133,16 @@ class BatchingCacheAdapter( override suspend fun expireAll() { // This cache layer has no state, nothing to do } + + companion object { + fun batchingCache( + context: CoroutineContext, + workers: Int = 1, + transform: suspend FlowCollector>>.(Set) -> Unit, + ) = BatchingCacheAdapter(context, workers) { + flow { + transform(it) + } + } + } } diff --git a/cache/src/commonMain/kotlin/opensavvy.cache/Cache.kt b/cache/src/commonMain/kotlin/opensavvy.cache/Cache.kt index 6433f39..bd93f87 100644 --- a/cache/src/commonMain/kotlin/opensavvy.cache/Cache.kt +++ b/cache/src/commonMain/kotlin/opensavvy.cache/Cache.kt @@ -1,7 +1,8 @@ package opensavvy.cache +import kotlinx.coroutines.flow.Flow import opensavvy.state.Identifier -import opensavvy.state.State +import opensavvy.state.slice.Slice /** * Stores information temporarily to avoid unneeded network requests. @@ -43,7 +44,7 @@ interface Cache { * such as inside the body of a UI component. * You can then subscribe to the [State] to access the actual values. */ - operator fun get(id: I): State + operator fun get(id: I): Flow> /** * Forces the cache to accept [value] as a more recent value for the given [id] than whatever it was previously storing. diff --git a/cache/src/commonMain/kotlin/opensavvy.cache/CacheAdapter.kt b/cache/src/commonMain/kotlin/opensavvy.cache/CacheAdapter.kt index a31cc8e..72fa2cb 100644 --- a/cache/src/commonMain/kotlin/opensavvy.cache/CacheAdapter.kt +++ b/cache/src/commonMain/kotlin/opensavvy.cache/CacheAdapter.kt @@ -1,7 +1,12 @@ package opensavvy.cache +import arrow.core.continuations.EffectScope +import arrow.core.continuations.either +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import opensavvy.state.Failure import opensavvy.state.Identifier -import opensavvy.state.State +import opensavvy.state.slice.Slice /** * Cache implementation aimed to be the first link in a cache chain. @@ -10,9 +15,10 @@ import opensavvy.state.State * and the underlying network APIs. */ class CacheAdapter( - val query: (I) -> State, + val query: suspend (I) -> Slice, ) : Cache { - override fun get(id: I) = query(id) + + override fun get(id: I): Flow> = flow { emit(query(id)) } override suspend fun update(values: Collection>) { // This cache layer has no state, nothing to do @@ -25,4 +31,9 @@ class CacheAdapter( override suspend fun expireAll() { // This cache layer has no state, nothing to do } + + companion object { + fun cache(transform: suspend EffectScope.(I) -> T) = + CacheAdapter { either { transform(it) } } + } } diff --git a/cache/src/commonMain/kotlin/opensavvy.cache/ExpirationCache.kt b/cache/src/commonMain/kotlin/opensavvy.cache/ExpirationCache.kt index 55d670b..093f79e 100644 --- a/cache/src/commonMain/kotlin/opensavvy.cache/ExpirationCache.kt +++ b/cache/src/commonMain/kotlin/opensavvy.cache/ExpirationCache.kt @@ -1,6 +1,7 @@ package opensavvy.cache import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit @@ -11,7 +12,7 @@ import opensavvy.logger.Logger.Companion.debug import opensavvy.logger.Logger.Companion.trace import opensavvy.logger.loggerFor import opensavvy.state.Identifier -import opensavvy.state.State +import opensavvy.state.slice.Slice import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext import kotlin.time.Duration @@ -83,7 +84,7 @@ class ExpirationCache( } } - override fun get(id: I): State = upstream[id] + override fun get(id: I): Flow> = upstream[id] .onEach { markAsUpdatedNow(id) } override suspend fun update(values: Collection>) { diff --git a/cache/src/commonMain/kotlin/opensavvy.cache/MemoryCache.kt b/cache/src/commonMain/kotlin/opensavvy.cache/MemoryCache.kt index 40cf7b0..0b93ee9 100644 --- a/cache/src/commonMain/kotlin/opensavvy.cache/MemoryCache.kt +++ b/cache/src/commonMain/kotlin/opensavvy.cache/MemoryCache.kt @@ -8,9 +8,8 @@ import opensavvy.cache.MemoryCache.Companion.cachedInMemory import opensavvy.logger.Logger.Companion.trace import opensavvy.logger.loggerFor import opensavvy.state.Identifier -import opensavvy.state.Slice -import opensavvy.state.Slice.Companion.successful -import opensavvy.state.State +import opensavvy.state.slice.Slice +import opensavvy.state.slice.successful import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext @@ -61,7 +60,7 @@ class MemoryCache( /** **UNSAFE**: only call when owning the [cacheLock] */ private fun getUnsafe(id: I) = cache.getOrPut(id) { MutableStateFlow(null) } - override fun get(id: I): State = flow { + override fun get(id: I): Flow> = flow { val cached = cacheLock.withPermit { getUnsafe(id) } .onEach { slice -> if (slice == null) { diff --git a/cache/src/commonTest/kotlin/opensavvy.cache/CacheTest.kt b/cache/src/commonTest/kotlin/opensavvy.cache/CacheTest.kt index da129c9..c339dd2 100644 --- a/cache/src/commonTest/kotlin/opensavvy.cache/CacheTest.kt +++ b/cache/src/commonTest/kotlin/opensavvy.cache/CacheTest.kt @@ -2,12 +2,11 @@ package opensavvy.cache -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay +import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import kotlinx.coroutines.test.runTest +import opensavvy.cache.BatchingCacheAdapter.Companion.batchingCache +import opensavvy.cache.CacheAdapter.Companion.cache import opensavvy.cache.ExpirationCache.Companion.expireAfter import opensavvy.cache.MemoryCache.Companion.cachedInMemory import opensavvy.logger.LogLevel @@ -15,8 +14,9 @@ import opensavvy.logger.Logger.Companion.debug import opensavvy.logger.Logger.Companion.info import opensavvy.logger.loggerFor import opensavvy.state.* -import opensavvy.state.Slice.Companion.pending -import opensavvy.state.Slice.Companion.successful +import opensavvy.state.Progression.Companion.loading +import opensavvy.state.ProgressionReporter.Companion.report +import opensavvy.state.slice.* import kotlin.test.Test import kotlin.test.assertEquals import kotlin.time.Duration.Companion.seconds @@ -31,15 +31,13 @@ class CacheTest { override fun toString() = "Id($id)" } - private fun adapter() = CacheAdapter { - state { - log.debug(it) { "Requesting" } - delay(100L) - emit(pending(0.2)) - delay(10L) - ensureValid(it.id >= 0) { "Only positive integers are allowed: found ${it.id}" } - emit(successful(it.id)) - } + private fun adapter() = cache { + log.debug(it) { "Requesting" } + delay(100) + report(loading(0.2)) + delay(10) + ensureValid(it.id >= 0) { "Only positive integers are allowed: found ${it.id}" } + it.id } private suspend fun testCache(cache: Cache) { @@ -48,36 +46,36 @@ class CacheTest { val one = cache[IntId(1)] val minus = cache[IntId(-1)] - assertEquals(0, zero.firstResultOrNull()) - assertEquals(1, one.firstResultOrNull()) - assertEquals(null, minus.firstResultOrNull()) + assertEquals(0, zero.firstValueOrNull()) + assertEquals(1, one.firstValueOrNull()) + assertEquals(null, minus.firstValueOrNull()) } private suspend fun testUpdateExpire(cache: Cache) { log.info { "Checking normal behavior" } - assertEquals(0, cache[IntId(0)].firstResultOrThrow()) + assertEquals(0, cache[IntId(0)].firstValueOrThrow()) log.info { "Overwriting with a different value" } cache.update(IntId(0), 5) - assertEquals(5, cache[IntId(0)].firstResultOrThrow()) + assertEquals(5, cache[IntId(0)].firstValueOrThrow()) log.info { "Expiring the value re-downloads and replaces our fake value" } cache.expire(IntId(0)) - assertEquals(0, cache[IntId(0)].firstResultOrThrow()) + assertEquals(0, cache[IntId(0)].firstValueOrThrow()) } private suspend fun testAutoExpiration(cache: Cache) { log.info { "Adding 5 to the cache to make updates visible" } - assertEquals(0, cache[IntId(0)].firstResultOrThrow()) + assertEquals(0, cache[IntId(0)].firstValueOrThrow()) cache.update(IntId(0), 5) - assertEquals(5, cache[IntId(0)].firstResultOrThrow()) + assertEquals(5, cache[IntId(0)].firstValueOrThrow()) log.info { "Waiting for the cache to correct itself" } assertEquals(0, cache[IntId(0)] .onEach { log.debug(it) { "Found new value" } } .drop(1) // Skip the bad value we inserted - .firstResultOrThrow() + .firstValueOrThrow() ) } @@ -90,140 +88,127 @@ class CacheTest { @Test fun infiniteMemoryCache() = runTest { - val job = Job() val cache = adapter() - .cachedInMemory(coroutineContext + job) + .cachedInMemory(coroutineContext) testCache(cache) - job.cancel() + currentCoroutineContext().cancelChildren() } @Test fun infiniteMemoryCacheUpdateExpire() = runTest { - val job = Job() val cache = adapter() - .cachedInMemory(coroutineContext + job) + .cachedInMemory(coroutineContext) testUpdateExpire(cache) - job.cancel() + currentCoroutineContext().cancelChildren() } @Test fun expiringDefaultCache() = runTest { - val job = Job() val cache = adapter() - .expireAfter(1.seconds, coroutineContext + job) + .expireAfter(1.seconds, coroutineContext) testCache(cache) - job.cancel() + currentCoroutineContext().cancelChildren() } @Test fun expiringMemoryCache() = runTest { - val job = Job() val cache = adapter() - .cachedInMemory(coroutineContext + job) - .expireAfter(1.seconds, coroutineContext + job) + .cachedInMemory(coroutineContext) + .expireAfter(1.seconds, coroutineContext) testCache(cache) - job.cancel() + currentCoroutineContext().cancelChildren() } @Test fun expiringMemoryCacheUpdateExpire() = runTest { - val job = Job() val cache = adapter() - .cachedInMemory(coroutineContext + job) - .expireAfter(1.seconds, coroutineContext + job) + .cachedInMemory(coroutineContext) + .expireAfter(1.seconds, coroutineContext) testUpdateExpire(cache) - job.cancel() + currentCoroutineContext().cancelChildren() } @Test fun expiringMemoryCacheExpirationLayer() = runTest { - val job = Job() val cache = adapter() - .cachedInMemory(coroutineContext + job) - .expireAfter(1.seconds, coroutineContext + job) + .cachedInMemory(coroutineContext) + .expireAfter(1.seconds, coroutineContext) testAutoExpiration(cache) - job.cancel() + currentCoroutineContext().cancelChildren() } @Test fun expireAll() = runTest { - val job = Job() val cache = adapter() - .cachedInMemory(coroutineContext + job) - .expireAfter(1.seconds, coroutineContext + job) + .cachedInMemory(coroutineContext) + .expireAfter(1.seconds, coroutineContext) log.info { "Initial values" } val id0 = IntId(0) val id1 = IntId(1) - assertEquals(0, cache[id0].firstResultOrThrow()) - assertEquals(1, cache[id1].firstResultOrThrow()) + assertEquals(0, cache[id0].firstValueOrThrow()) + assertEquals(1, cache[id1].firstValueOrThrow()) log.info { "Adding 5" } cache.update( id0 to 5, id1 to 6, ) - assertEquals(5, cache[id0].firstResultOrThrow()) - assertEquals(6, cache[id1].firstResultOrThrow()) + assertEquals(5, cache[id0].firstValueOrThrow()) + assertEquals(6, cache[id1].firstValueOrThrow()) log.info { "Expiring all values" } cache.expireAll() - assertEquals(0, cache[id0].firstResultOrThrow()) - assertEquals(1, cache[id1].firstResultOrThrow()) + assertEquals(0, cache[id0].firstValueOrThrow()) + assertEquals(1, cache[id1].firstValueOrThrow()) - job.cancel() + currentCoroutineContext().cancelChildren() } - @OptIn(FlowPreview::class) @Test fun batching() = runTest { - val job = Job() - val cache = BatchingCacheAdapter(coroutineContext + job) { ids -> - ids.asFlow() - .map { ref -> - state { - emit(pending()) - delay(10L) - emit(successful(ref.id)) - }.map { ref to it } - } - .flattenConcat() + val cache = batchingCache(coroutineContext) { ids -> + for (ref in ids) { + report(loading()) + delay(10) + emit(ref to successful(ref.id)) + } } - .cachedInMemory(coroutineContext + job) - .expireAfter(1.seconds, coroutineContext + job) + .cachedInMemory(coroutineContext) + .expireAfter(1.seconds, coroutineContext) log.info { "Initial values" } val id0 = IntId(0) val id1 = IntId(1) - assertEquals(0, cache[id0].firstResultOrThrow()) - assertEquals(1, cache[id1].firstResultOrThrow()) + assertEquals(0, cache[id0].firstValueOrThrow()) + assertEquals(1, cache[id1].firstValueOrThrow()) log.info { "Adding 5" } cache.update( id0 to 5, id1 to 6, ) - assertEquals(5, cache[id0].firstResultOrThrow()) - assertEquals(6, cache[id1].firstResultOrThrow()) + assertEquals(5, cache[id0].firstValueOrThrow()) + assertEquals(6, cache[id1].firstValueOrThrow()) log.info { "Expiring all values" } cache.expireAll() - assertEquals(0, cache[id0].firstResultOrThrow()) - assertEquals(1, cache[id1].firstResultOrThrow()) + assertEquals(0, cache[id0].firstValueOrThrow()) + assertEquals(1, cache[id1].firstValueOrThrow()) - job.cancel() + currentCoroutineContext().cancelChildren() } } diff --git a/state/src/commonMain/kotlin/opensavvy.state/State.kt b/state/src/commonMain/kotlin/opensavvy.state/State.kt new file mode 100644 index 0000000..7eb6d72 --- /dev/null +++ b/state/src/commonMain/kotlin/opensavvy.state/State.kt @@ -0,0 +1,12 @@ +package opensavvy.state + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import opensavvy.state.slice.Slice +import opensavvy.state.slice.valueOrNull +import opensavvy.state.slice.valueOrThrow + +suspend fun Flow>.firstValueOrNull() = firstOrNull()?.valueOrNull + +suspend fun Flow>.firstValueOrThrow() = first().valueOrThrow -- 2.51.2 From 3cf7d3ce7bde3beb1abb2935ce4c3d7ee9f80f30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 30 Oct 2022 22:13:53 +0100 Subject: [PATCH 6/8] feat(cache): MemoryCache absorbs progression events and re-emits them to the downstream users --- .../kotlin/opensavvy.cache/MemoryCache.kt | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/cache/src/commonMain/kotlin/opensavvy.cache/MemoryCache.kt b/cache/src/commonMain/kotlin/opensavvy.cache/MemoryCache.kt index 0b93ee9..e9abe13 100644 --- a/cache/src/commonMain/kotlin/opensavvy.cache/MemoryCache.kt +++ b/cache/src/commonMain/kotlin/opensavvy.cache/MemoryCache.kt @@ -8,6 +8,9 @@ import opensavvy.cache.MemoryCache.Companion.cachedInMemory import opensavvy.logger.Logger.Companion.trace import opensavvy.logger.loggerFor import opensavvy.state.Identifier +import opensavvy.state.Progression +import opensavvy.state.ProgressionReporter.Companion.progressionReporter +import opensavvy.state.ProgressionReporter.Companion.report import opensavvy.state.slice.Slice import opensavvy.state.slice.successful import kotlin.coroutines.CoroutineContext @@ -48,7 +51,7 @@ class MemoryCache( * - 'expire' removed the cached value */ - private val cache = HashMap?>>() + private val cache = HashMap, Progression>?>>() private val cacheLock = Semaphore(1) private val jobs = HashMap() @@ -73,13 +76,16 @@ class MemoryCache( if (job == null || !job.isActive) { // No one is currently making the request, I'm taking the responsibility to do it - jobs[id] = scope.launch(CoroutineName("${this@MemoryCache} for $id")) { + val reporter = progressionReporter() + + jobs[id] = scope.launch(CoroutineName("${this@MemoryCache} for $id") + reporter) { log.trace(id) { "Subscribing to the previous layer for" } val state = cacheLock.withPermit { getUnsafe(id) } upstream[id] - .onEach { log.trace(it) { "Event for" } } + .zip(reporter.progress) { value, progress -> value to progress } + .onEach { log.trace(it) { "Event" } } .onEach { state.value = it } .collect() } @@ -89,7 +95,10 @@ class MemoryCache( } .filterNotNull() // 'null' is an internal value, it shouldn't be returned to downstream users - emitAll(cached) + cached.collect { (slice, progression) -> + report(progression) + emit(slice) + } } override suspend fun update(values: Collection>) { @@ -103,7 +112,7 @@ class MemoryCache( cacheLock.withPermit { for ((id, value) in values) { - getUnsafe(id).value = successful(value) + getUnsafe(id).value = successful(value) to Progression.done() } } -- 2.51.2 From 7dcf4eb6f14a20c09372484a703709ddbb6c9cee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 30 Oct 2022 22:25:56 +0100 Subject: [PATCH 7/8] feat(backbone): Backbone updates after the state rewrite --- .../kotlin/opensavvy/backbone/Backbone.kt | 10 +++---- .../kotlin/opensavvy/backbone/Ref.kt | 30 ++++++++++++++++--- .../kotlin/opensavvy/backbone/RefCache.kt | 4 +-- .../opensavvy.backbone/BackboneCacheTest.kt | 24 +++++++-------- 4 files changed, 42 insertions(+), 26 deletions(-) diff --git a/backbone/src/commonMain/kotlin/opensavvy/backbone/Backbone.kt b/backbone/src/commonMain/kotlin/opensavvy/backbone/Backbone.kt index e80ba3c..9c043b7 100644 --- a/backbone/src/commonMain/kotlin/opensavvy/backbone/Backbone.kt +++ b/backbone/src/commonMain/kotlin/opensavvy/backbone/Backbone.kt @@ -1,6 +1,6 @@ package opensavvy.backbone -import opensavvy.state.State +import opensavvy.state.slice.Slice /** * A common interface for API endpoints. @@ -23,10 +23,8 @@ interface Backbone { * * This function completely bypasses the [cache]: a request will be sent everytime it is called. * To avoid sending unnecessary requests, use [request] instead. - * - * The returned flow is **short-lived**: it is closed after the request finishes. */ - fun directRequest(ref: Ref): State + suspend fun directRequest(ref: Ref): Slice /** * Fetches the value associated with all [refs] in an external media (e.g. a remote server, a database). @@ -41,8 +39,8 @@ interface Backbone { * * The default implementation simply calls [directRequest] sequentially. */ - fun batchRequests(refs: Set>): Map, State> = refs - .map { it to directRequest(it) } + fun batchRequests(refs: Set>): Map, suspend () -> Slice> = refs + .map { it to suspend { directRequest(it) } } .associate { it } companion object { diff --git a/backbone/src/commonMain/kotlin/opensavvy/backbone/Ref.kt b/backbone/src/commonMain/kotlin/opensavvy/backbone/Ref.kt index 1b72684..ab82d8c 100644 --- a/backbone/src/commonMain/kotlin/opensavvy/backbone/Ref.kt +++ b/backbone/src/commonMain/kotlin/opensavvy/backbone/Ref.kt @@ -1,11 +1,13 @@ package opensavvy.backbone import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import opensavvy.backbone.Backbone.Companion.request import opensavvy.backbone.Ref.Companion.directRequest import opensavvy.backbone.Ref.Companion.request import opensavvy.state.Identifier -import opensavvy.state.firstResultOrThrow +import opensavvy.state.firstValueOrNull +import opensavvy.state.firstValueOrThrow /** * A reference to a specific [object][O]. @@ -39,7 +41,7 @@ interface Ref : Identifier { * * This is a convenience method around [Backbone.directRequest]. */ - fun Ref.directRequest() = backbone.directRequest(this) + suspend fun Ref.directRequest() = backbone.directRequest(this) /** * Requests the referenced data, returning a value from the cache if one is stored. @@ -58,13 +60,33 @@ interface Ref : Identifier { } /** - * Queries the value behind the reference, throws an exception if anything goes wrong. + * Queries the value behind the reference, without reactivity. * * This method can be used to easily access the value behind a reference. * However, because it returns a single value and not a [Flow], it is not possible to be notified when the value * is modified. * This method should therefore only be used in non-reactive contexts (tests, to write the value to the terminal, etc). */ - suspend fun Ref.requestValue() = request().firstResultOrThrow() + suspend fun Ref.requestValue() = request().first() + + /** + * Queries the value behind the reference or returns `null` if it is a failure, without reactivity. + * + * This method can be used to easily access the value behind a reference. + * However, because it returns a single value and not a [Flow], it is not possible to be notified when the value + * is modified. + * This method should therefore only be used in non-reactive contexts (tests, to write the value to the terminal, etc). + */ + suspend fun Ref.requestValueOrNull() = request().firstValueOrNull() + + /** + * Queries the value behind the reference or throws an exception if it is a failure, without reactivity. + * + * This method can be used to easily access the value behind a reference. + * However, because it returns a single value and not a [Flow], it is not possible to be notified when the value + * is modified. + * This method should therefore only be used in non-reactive contexts (tests, to write the value to the terminal, etc). + */ + suspend fun Ref.requestValueOrThrow() = request().firstValueOrThrow() } } diff --git a/backbone/src/commonMain/kotlin/opensavvy/backbone/RefCache.kt b/backbone/src/commonMain/kotlin/opensavvy/backbone/RefCache.kt index 918bd1f..8433d54 100644 --- a/backbone/src/commonMain/kotlin/opensavvy/backbone/RefCache.kt +++ b/backbone/src/commonMain/kotlin/opensavvy/backbone/RefCache.kt @@ -1,8 +1,6 @@ package opensavvy.backbone -import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map import opensavvy.backbone.Ref.Companion.directRequest import opensavvy.cache.BatchingCacheAdapter import opensavvy.cache.Cache @@ -27,7 +25,7 @@ fun batchingRefCache(context: CoroutineContext, workers: Int = 1) = for ((backbone, refs) in backbones) { val results = backbone.batchRequests(refs.toHashSet()) for ((ref, result) in results) { - emitAll(result.map { ref to it }) + emit(ref to result()) } } } diff --git a/backbone/src/commonTest/kotlin/opensavvy.backbone/BackboneCacheTest.kt b/backbone/src/commonTest/kotlin/opensavvy.backbone/BackboneCacheTest.kt index 49cb137..2f3eec0 100644 --- a/backbone/src/commonTest/kotlin/opensavvy.backbone/BackboneCacheTest.kt +++ b/backbone/src/commonTest/kotlin/opensavvy.backbone/BackboneCacheTest.kt @@ -6,11 +6,9 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.test.runTest import opensavvy.backbone.Ref.Companion.expire -import opensavvy.backbone.Ref.Companion.requestValue -import opensavvy.state.Slice.Companion.successful -import opensavvy.state.State -import opensavvy.state.ensureValid -import opensavvy.state.state +import opensavvy.backbone.Ref.Companion.requestValueOrThrow +import opensavvy.state.slice.ensureValid +import opensavvy.state.slice.slice import kotlin.test.Test import kotlin.test.assertEquals @@ -18,11 +16,11 @@ class BackboneCacheTest { // Id("12") -> 12 private class Bone(override val cache: RefCache) : Backbone { - override fun directRequest(ref: Ref): State = state { + override suspend fun directRequest(ref: Ref) = slice { ensureValid(ref is Ref.Basic) { "Only basic references are accepted by ${this@Bone}" } val int = ref.id.toIntOrNull() ensureValid(int != null) { "The reference $ref does not refer to a valid integer" } - emit(successful(int)) + int } fun of(int: Int) = Ref.Basic(int.toString(), this) @@ -34,11 +32,11 @@ class BackboneCacheTest { val id5 = bone.of(5) val id2 = bone.of(2) - assertEquals(5, id5.requestValue()) - assertEquals(2, id2.requestValue()) + assertEquals(5, id5.requestValueOrThrow()) + assertEquals(2, id2.requestValueOrThrow()) id2.expire() - assertEquals(2, id2.requestValue()) + assertEquals(2, id2.requestValueOrThrow()) } @Test @@ -49,11 +47,11 @@ class BackboneCacheTest { val id5 = bone.of(5) val id2 = bone.of(2) - assertEquals(5, id5.requestValue()) - assertEquals(2, id2.requestValue()) + assertEquals(5, id5.requestValueOrThrow()) + assertEquals(2, id2.requestValueOrThrow()) id2.expire() - assertEquals(2, id2.requestValue()) + assertEquals(2, id2.requestValueOrThrow()) job.cancel() } -- 2.51.2 From 944fdf6c1b1914ce1e792cd4070a364a3664f66a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Mon, 31 Oct 2022 12:15:03 +0100 Subject: [PATCH 8/8] feat(spine): Spine updates after the state rewrite --- .../opensavvy.spine.ktor.client/Client.kt | 31 ++++---- .../ResponseStateBuilder.kt | 9 +-- .../opensavvy.spine.ktor.server/Server.kt | 46 +++++------- .../opensavvy.spine.ktor.server/ServerTest.kt | 30 +++----- .../opensavvy.spine.ktor/StatusCodes.kt | 24 +++---- .../kotlin/opensavvy.spine/Operation.kt | 7 +- .../kotlin/opensavvy.spine/Resource.kt | 46 ++++++------ .../kotlin/opensavvy.spine/ServiceTest.kt | 70 ++++++++++--------- 8 files changed, 125 insertions(+), 138 deletions(-) diff --git a/spine-ktor/spine-ktor-client/src/commonMain/kotlin/opensavvy.spine.ktor.client/Client.kt b/spine-ktor/spine-ktor-client/src/commonMain/kotlin/opensavvy.spine.ktor.client/Client.kt index eec4ee1..e76e5de 100644 --- a/spine-ktor/spine-ktor-client/src/commonMain/kotlin/opensavvy.spine.ktor.client/Client.kt +++ b/spine-ktor/spine-ktor-client/src/commonMain/kotlin/opensavvy.spine.ktor.client/Client.kt @@ -12,12 +12,11 @@ import opensavvy.spine.ResourceGroup.AbstractResource import opensavvy.spine.ktor.NetworkResponse import opensavvy.spine.ktor.toHttp import opensavvy.spine.ktor.toSpine -import opensavvy.state.Progression.Companion.done -import opensavvy.state.Slice.Companion.failed -import opensavvy.state.Slice.Companion.pending -import opensavvy.state.Slice.Companion.successful -import opensavvy.state.State -import opensavvy.state.state +import opensavvy.state.Failure +import opensavvy.state.Progression.Companion.loading +import opensavvy.state.ProgressionReporter.Companion.report +import opensavvy.state.ProgressionReporter.Companion.transformQuantifiedProgress +import opensavvy.state.slice.slice /** * Executes a [HttpClient] request, with the information declared in an [Operation]. @@ -44,7 +43,7 @@ import opensavvy.state.state * @param configuration Additional configuration passed to Ktor's `request` function. * This configuration is applied after the parameters from this request are applied, it is possible to override data set by this function. */ -inline fun HttpClient.request( +suspend inline fun HttpClient.request( operation: Operation, id: Id, input: In, @@ -53,12 +52,14 @@ inline fun Unit = {}, crossinline configuration: HttpRequestBuilder.() -> Unit = {}, -): State = state { - emit(pending(0.0)) +) = slice { + report(loading(0.0)) - operation.validate(this, id, input, parameters, context) + transformQuantifiedProgress({ loading(it.normalized / 10) }) { + operation.validate(this@slice, id, input, parameters, context) + } - emit(pending(0.1)) + report(loading(0.1)) val result = request { method = operation.kind.toHttp() @@ -87,18 +88,18 @@ inline fun = result.body() - emit(successful(response.value)) + response.value } else { val body = result.body().ifBlank { "${result.status} with no provided body" } val kind = result.status.toSpine() - emit(failed(kind, body, progression = done())) + shift(Failure(kind, body)) } } diff --git a/spine-ktor/spine-ktor-server/src/commonMain/kotlin/opensavvy.spine.ktor.server/ResponseStateBuilder.kt b/spine-ktor/spine-ktor-server/src/commonMain/kotlin/opensavvy.spine.ktor.server/ResponseStateBuilder.kt index 6da57d1..8478d76 100644 --- a/spine-ktor/spine-ktor-server/src/commonMain/kotlin/opensavvy.spine.ktor.server/ResponseStateBuilder.kt +++ b/spine-ktor/spine-ktor-server/src/commonMain/kotlin/opensavvy.spine.ktor.server/ResponseStateBuilder.kt @@ -1,15 +1,16 @@ package opensavvy.spine.ktor.server +import arrow.core.continuations.EffectScope import io.ktor.server.application.* import opensavvy.spine.Id import opensavvy.spine.Parameters -import opensavvy.state.StateBuilder +import opensavvy.state.Failure /** * Information available in [route]. */ -class ResponseStateBuilder( - builder: StateBuilder, +class ResponseStateBuilder( + builder: EffectScope, /** * The identifier of the resource being requested. @@ -35,4 +36,4 @@ class ResponseStateBuilder( * The current request's context. */ val context: Context, -) : StateBuilder by builder +) : EffectScope by builder diff --git a/spine-ktor/spine-ktor-server/src/commonMain/kotlin/opensavvy.spine.ktor.server/Server.kt b/spine-ktor/spine-ktor-server/src/commonMain/kotlin/opensavvy.spine.ktor.server/Server.kt index 4dd5daf..9bb16ed 100644 --- a/spine-ktor/spine-ktor-server/src/commonMain/kotlin/opensavvy.spine.ktor.server/Server.kt +++ b/spine-ktor/spine-ktor-server/src/commonMain/kotlin/opensavvy.spine.ktor.server/Server.kt @@ -1,6 +1,5 @@ package opensavvy.spine.ktor.server -import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* @@ -11,9 +10,10 @@ import opensavvy.spine.Operation import opensavvy.spine.Parameters import opensavvy.spine.ktor.NetworkResponse import opensavvy.spine.ktor.toHttp -import opensavvy.state.Status -import opensavvy.state.firstResult -import opensavvy.state.state +import opensavvy.state.slice.slice +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.set object Server { val log = loggerFor(this) @@ -51,7 +51,7 @@ object Server { inline fun Route.route( operation: Operation, contextGenerator: ContextGenerator, - crossinline block: suspend ResponseStateBuilder.() -> Unit, + crossinline block: suspend ResponseStateBuilder.() -> Out, ) { val path = buildString { append(operation.resource.routeTemplate) @@ -91,35 +91,25 @@ inline fun call.receive() } - val state = state { + slice { operation.validate(this, id, body, params, context) val responseBuilder = ResponseStateBuilder(this, id, body, params, call, context) responseBuilder.block() - }.firstResult() // How can we send loading events via HTTP? - - when (val data = state.status) { - is Status.Successful -> call.respond( - NetworkResponse( - routes = emptyList(), //TODO in #22: advertise the endpoints - value = data.value + }.fold( + ifLeft = { + Server.log.warn(it.kind, it.message, it.cause?.stackTraceToString()) { "Failed request" } + call.respond(it.kind.toHttp(), it.message) + }, + ifRight = { + call.respond( + NetworkResponse( + routes = emptyList(), //TODO in #22: advertise the endpoints + value = it, + ) ) - ) - - // This should not be possible (it only happens for loading events, and we ignored them) - is Status.Pending -> error("The server did not find any value to return, this should not be possible") - - is Status.StandardFailure -> { - Server.log.warn(data.kind, data.message, data.cause?.stackTraceToString()) { "Failed request" } - val code = data.kind.toHttp() - call.respond(code, data.message ?: "No message") } - - is Status.Failed -> { - Server.log.warn(data.message, data.stackTraceToString()) { "Failed request" } - call.respond(HttpStatusCode.InternalServerError, data.message ?: "No message") - } - } + ) } } } diff --git a/spine-ktor/spine-ktor-server/src/commonTest/kotlin/opensavvy.spine.ktor.server/ServerTest.kt b/spine-ktor/spine-ktor-server/src/commonTest/kotlin/opensavvy.spine.ktor.server/ServerTest.kt index b3e11cb..d007376 100644 --- a/spine-ktor/spine-ktor-server/src/commonTest/kotlin/opensavvy.spine.ktor.server/ServerTest.kt +++ b/spine-ktor/spine-ktor-server/src/commonTest/kotlin/opensavvy.spine.ktor.server/ServerTest.kt @@ -4,8 +4,6 @@ import io.ktor.client.plugins.logging.* import io.ktor.serialization.kotlinx.json.* import io.ktor.server.plugins.callloging.* import io.ktor.server.testing.* -import kotlinx.coroutines.flow.onEach -import opensavvy.logger.Logger.Companion.debug import opensavvy.logger.Logger.Companion.info import opensavvy.logger.loggerFor import opensavvy.spine.Id @@ -13,9 +11,9 @@ import opensavvy.spine.Parameters import opensavvy.spine.Route import opensavvy.spine.Route.Companion.div import opensavvy.spine.ktor.client.request -import opensavvy.state.Slice.Companion.successful -import opensavvy.state.ensureFound -import opensavvy.state.firstResultOrThrow +import opensavvy.state.slice.ensureFound +import opensavvy.state.slice.successful +import opensavvy.state.slice.valueOrThrow import org.junit.Test import org.slf4j.event.Level import kotlin.test.assertEquals @@ -56,7 +54,7 @@ class ServerTest { val result = users .filter { parameters.includeArchived || !it.archived } .map { it.id } - emit(successful(result)) + result } route(api.users.create, context) { @@ -64,13 +62,13 @@ class ServerTest { val newId = api.users.id.idOf((nextId++).toString()) val new = User(newId, name, archived = false) users += new - emit(successful(newId to new)) + newId to new } route(api.users.id.get, context) { val user = users.find { it.id == id } ensureFound(user != null) { "Could not find the user $id" } - emit(successful(user)) + user } route(api.users.id.archive, context) { @@ -78,7 +76,6 @@ class ServerTest { ensureFound(userIndex >= 0) { "Could not find user $id" } val user = users.removeAt(userIndex) users.add(user.copy(archived = true)) - emit(successful(Unit)) } route(api.users.id.unarchive, context) { @@ -86,14 +83,12 @@ class ServerTest { ensureFound(userIndex >= 0) { "Could not find user $id" } val user = users.removeAt(userIndex) users.add(user.copy(archived = false)) - emit(successful(Unit)) } route(api.users.id.delete, context) { val userIndex = users.indexOfFirst { it.id == id } ensureFound(userIndex >= 0) { "Could not find user $id" } users.removeAt(userIndex) - emit(successful(Unit)) } } @@ -116,10 +111,8 @@ class ServerTest { run { val params = User.SearchParams().apply { includeArchived = true } val results = client.request(api.users.get, api.users.get.idOf(), Unit, params, Unit) - .onEach { log.debug(it) { "Received event for" } } - .firstResultOrThrow() - assertEquals(emptyList(), results) + assertEquals(successful(emptyList()), results) } log.info { "Step 2: creating two users" } @@ -127,21 +120,18 @@ class ServerTest { run { val first = client.request(api.users.create, api.users.create.idOf(), User.New("first"), Parameters.Empty, Unit) - .onEach { log.debug(it) { "Received event for" } } - .firstResultOrThrow() + .valueOrThrow val second = client.request(api.users.create, api.users.create.idOf(), User.New("second"), Parameters.Empty, Unit) - .onEach { log.debug(it) { "Received event for" } } - .firstResultOrThrow() + .valueOrThrow assertEquals(User(Id("test", Route / "users" / "0"), "first", archived = false), first.second) assertEquals(User(Id("test", Route / "users" / "1"), "second", archived = false), second.second) val params = User.SearchParams().apply { includeArchived = true } val results = client.request(api.users.get, api.users.get.idOf(), Unit, params, Unit) - .onEach { log.debug(it) { "Received event for" } } - .firstResultOrThrow() + .valueOrThrow assertEquals(listOf(first.first, second.first), results) } diff --git a/spine-ktor/src/commonMain/kotlin/opensavvy.spine.ktor/StatusCodes.kt b/spine-ktor/src/commonMain/kotlin/opensavvy.spine.ktor/StatusCodes.kt index 3aa4088..34247a0 100644 --- a/spine-ktor/src/commonMain/kotlin/opensavvy.spine.ktor/StatusCodes.kt +++ b/spine-ktor/src/commonMain/kotlin/opensavvy.spine.ktor/StatusCodes.kt @@ -1,20 +1,20 @@ package opensavvy.spine.ktor import io.ktor.http.* -import opensavvy.state.Status +import opensavvy.state.Failure fun HttpStatusCode.toSpine() = when (this) { - HttpStatusCode.NotFound -> Status.StandardFailure.Kind.NotFound - HttpStatusCode.Unauthorized -> Status.StandardFailure.Kind.Unauthenticated - HttpStatusCode.Forbidden -> Status.StandardFailure.Kind.Unauthorized - HttpStatusCode.UnprocessableEntity -> Status.StandardFailure.Kind.Invalid - else -> Status.StandardFailure.Kind.Unknown + HttpStatusCode.NotFound -> Failure.Kind.NotFound + HttpStatusCode.Unauthorized -> Failure.Kind.Unauthenticated + HttpStatusCode.Forbidden -> Failure.Kind.Unauthorized + HttpStatusCode.UnprocessableEntity -> Failure.Kind.Invalid + else -> Failure.Kind.Unknown } -fun Status.StandardFailure.Kind.toHttp() = when (this) { - Status.StandardFailure.Kind.Invalid -> HttpStatusCode.UnprocessableEntity - Status.StandardFailure.Kind.Unauthenticated -> HttpStatusCode.Unauthorized - Status.StandardFailure.Kind.Unauthorized -> HttpStatusCode.Forbidden - Status.StandardFailure.Kind.NotFound -> HttpStatusCode.NotFound - Status.StandardFailure.Kind.Unknown -> HttpStatusCode.InternalServerError +fun Failure.Kind.toHttp() = when (this) { + Failure.Kind.Invalid -> HttpStatusCode.UnprocessableEntity + Failure.Kind.Unauthenticated -> HttpStatusCode.Unauthorized + Failure.Kind.Unauthorized -> HttpStatusCode.Forbidden + Failure.Kind.NotFound -> HttpStatusCode.NotFound + Failure.Kind.Unknown -> HttpStatusCode.InternalServerError } diff --git a/spine/src/commonMain/kotlin/opensavvy.spine/Operation.kt b/spine/src/commonMain/kotlin/opensavvy.spine/Operation.kt index a6c6d8c..2124c9d 100644 --- a/spine/src/commonMain/kotlin/opensavvy.spine/Operation.kt +++ b/spine/src/commonMain/kotlin/opensavvy.spine/Operation.kt @@ -1,14 +1,15 @@ package opensavvy.spine -import opensavvy.state.StateBuilder +import arrow.core.continuations.EffectScope +import opensavvy.state.Failure -typealias OperationValidator = suspend StateBuilder.(Id, In, parameters: Params, context: Context) -> Unit +typealias OperationValidator = suspend EffectScope.(Id, In, parameters: Params, context: Context) -> Unit class Operation( val resource: ResourceGroup.AbstractResource, val kind: Kind, val route: Route? = null, - val validate: OperationValidator, + val validate: OperationValidator, ) { /** diff --git a/spine/src/commonMain/kotlin/opensavvy.spine/Resource.kt b/spine/src/commonMain/kotlin/opensavvy.spine/Resource.kt index f8cc677..fe4aa99 100644 --- a/spine/src/commonMain/kotlin/opensavvy.spine/Resource.kt +++ b/spine/src/commonMain/kotlin/opensavvy.spine/Resource.kt @@ -1,9 +1,10 @@ package opensavvy.spine +import arrow.core.continuations.EffectScope import opensavvy.spine.ResourceGroup.AbstractResource import opensavvy.spine.Route.Companion.div -import opensavvy.state.StateBuilder -import opensavvy.state.ensureValid +import opensavvy.state.Failure +import opensavvy.state.slice.ensureValid /** * Common ancestor of [Service] and [AbstractResource]. @@ -77,7 +78,7 @@ sealed class ResourceGroup { /** * Validates that [id] identifies this resource. */ - suspend fun StateBuilder.validateCorrectId(id: Id) { + suspend fun EffectScope.validateCorrectId(id: Id) { ensureValid( id.service == service.name ) { "The passed identifier refers to the service '${id.service}', but this resource belongs to the service '${service.name}'" } @@ -122,36 +123,37 @@ sealed class ResourceGroup { * For example, you can override this function to check access rights for read operations. * By default, this function does nothing. */ - open suspend fun StateBuilder.validateId(id: Id, context: Context) {} + open suspend fun EffectScope.validateId(id: Id, context: Context) {} protected fun create( route: Route? = null, - validate: OperationValidator, Params, Context> = { _, _, _, _ -> }, - ) = - Operation(this, Operation.Kind.Create, route, validate) + validate: OperationValidator = { _, _, _, _ -> }, + ): Operation, Params, Context> = Operation(this, Operation.Kind.Create, route, validate) protected fun edit( route: Route? = null, - validate: OperationValidator = { _, _, _, _ -> }, - ) = Operation(this, Operation.Kind.Edit, route) { id: Id, it: In, params: Params, context -> - validateCorrectId(id) - validateId(id, context) - validate(id, it, params, context) - } + validate: OperationValidator = { _, _, _, _ -> }, + ): Operation = + Operation(this, Operation.Kind.Edit, route) { id: Id, it: In, params: Params, context -> + validateCorrectId(id) + validateId(id, context) + validate(id, it, params, context) + } protected fun action( route: Route, - validate: OperationValidator = { _, _, _, _ -> }, - ) = Operation(this, Operation.Kind.Action, route) { id: Id, it: In, params: Params, context -> - validateCorrectId(id) - validateId(id, context) - validate(id, it, params, context) - } + validate: OperationValidator = { _, _, _, _ -> }, + ): Operation = + Operation(this, Operation.Kind.Action, route) { id: Id, it: In, params: Params, context -> + validateCorrectId(id) + validateId(id, context) + validate(id, it, params, context) + } protected fun delete( route: Route? = null, - validate: OperationValidator = { _, _, _, _ -> }, - ) = + validate: OperationValidator = { _, _, _, _ -> }, + ): Operation = Operation( this, Operation.Kind.Delete, @@ -211,7 +213,7 @@ sealed class ResourceGroup { * * You should override this function if the parameters impact the access rights. */ - open suspend fun StateBuilder.validateGetParams(id: Id, params: GetParams, context: Context) {} + open suspend fun EffectScope.validateGetParams(id: Id, params: GetParams, context: Context) {} @Suppress("LeakingThis") // Not dangerous because Operation's constructor does nothing val get = diff --git a/spine/src/commonTest/kotlin/opensavvy.spine/ServiceTest.kt b/spine/src/commonTest/kotlin/opensavvy.spine/ServiceTest.kt index 145cbd3..ba1fbfd 100644 --- a/spine/src/commonTest/kotlin/opensavvy.spine/ServiceTest.kt +++ b/spine/src/commonTest/kotlin/opensavvy.spine/ServiceTest.kt @@ -8,13 +8,11 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.test.runTest import opensavvy.backbone.Backbone import opensavvy.backbone.Ref -import opensavvy.backbone.Ref.Companion.request import opensavvy.backbone.Ref.Companion.requestValue import opensavvy.backbone.defaultRefCache import opensavvy.spine.Route.Companion.div import opensavvy.state.* -import opensavvy.state.Slice.Companion.failed -import opensavvy.state.Slice.Companion.successful +import opensavvy.state.slice.* import kotlin.test.Test import kotlin.test.assertEquals @@ -66,9 +64,7 @@ private class Api : Service("v2") { val leave = action(Route / "leave") val rename = edit(Route / "name") { _, newName, _, _ -> - ensureValid( - newName.name.isNotBlank() - ) { "A user's name may not be empty: '${newName.name}'" } + ensureValid(newName.name.isNotBlank()) { "A user's name may not be empty: '${newName.name}'" } } val departments = Departments() @@ -77,7 +73,7 @@ private class Api : Service("v2") { val create = create { _, it, _, context: Context -> ensureValid(it.name.isNotBlank()) { "A user's name may not be empty: '${it.name}'" } ensureValid(it.name.length < 100) { "A user's name may not be longer than 100 characters, found ${it.name.length} characters: '${it.name}'" } - ensureAuthorized(context.user.requestValue().admin) { "Only admins can create new users" } + ensureAuthorized(context.user.requestValue().bind().admin) { "Only admins can create new users" } } val id = Unique() @@ -117,13 +113,13 @@ class ServiceTest { users["1"] = User("Admin", admin = true) } - override fun directRequest(ref: Ref): State = state { + override suspend fun directRequest(ref: Ref) = slice { ensureValid(ref is Ref.Basic) { "The reference type ${ref::class} is not supported by UserBone" } val result = users[ref.id] ensureFound(result != null) { "No user has the ID $ref" } - emit(successful(result)) + result } } @@ -140,10 +136,10 @@ class ServiceTest { val id1 = endpoint.idOf("0") assertEquals( successful(User("Employee", false)), - state { + slice { endpoint.validate(this, id1, Unit, Parameters.Empty, employee) - emitAll(employee.user.request()) - }.firstResult() + employee.user.requestValue().bind() + } ) // Scenario 2: access with an invalid service ID @@ -151,12 +147,12 @@ class ServiceTest { val id2 = Id("this-is-not-the-correct-service-name", Route / "users" / "0") assertEquals( failed( - Status.StandardFailure.Kind.Invalid, - "The passed identifier refers to the service 'this-is-not-the-correct-service-name', but this resource belongs to the service 'v2'" + "The passed identifier refers to the service 'this-is-not-the-correct-service-name', but this resource belongs to the service 'v2'", + Failure.Kind.Invalid ), - state { + slice { endpoint.validate(this, id2, Unit, Parameters.Empty, employee) - }.firstResult() + } ) // Scenario 3: access with an invalid ID (too short) @@ -164,12 +160,12 @@ class ServiceTest { val id3 = Id("v2", Route / "users") // should be /users/0 assertEquals( failed( - Status.StandardFailure.Kind.Invalid, - "The passed identifier's URI length is too short for this resource: 'v2/users' for resource 'v2/users/{user}'" + "The passed identifier's URI length is too short for this resource: 'v2/users' for resource 'v2/users/{user}'", + Failure.Kind.Invalid ), - state { + slice { endpoint.validate(this, id3, Unit, Parameters.Empty, employee) - }.firstResult() + } ) // Scenario 4: access with an invalid ID (wrong resource) @@ -177,12 +173,12 @@ class ServiceTest { val id4 = Id("v2", Route / "departments" / "0") // should be /users/0 assertEquals( failed( - Status.StandardFailure.Kind.Invalid, - "The passed identifier's segment #0 doesn't match the resource; expected 'users' but found 'departments'" + "The passed identifier's segment #0 doesn't match the resource; expected 'users' but found 'departments'", + Failure.Kind.Invalid ), - state { + slice { endpoint.validate(this, id4, Unit, Parameters.Empty, employee) - }.firstResult() + } ) // Scenario 5: access with an invalid ID (too long) @@ -190,12 +186,12 @@ class ServiceTest { val id5 = Id("v2", Route / "departments" / "users" / "0") // should be /users/0 assertEquals( failed( - Status.StandardFailure.Kind.Invalid, - "The passed identifier's URI length is too long for this resource: 'v2/departments/users/0' for resource 'v2/users/{user}'" + "The passed identifier's URI length is too long for this resource: 'v2/departments/users/0' for resource 'v2/users/{user}'", + Failure.Kind.Invalid ), - state { + slice { endpoint.validate(this, id5, Unit, Parameters.Empty, employee) - }.firstResult() + } ) } @@ -207,9 +203,12 @@ class ServiceTest { val admin = Context(Ref.Basic("1", bone)) - assertEquals(emptyList(), state { - endpoint.validate(this, endpoint.idOf(), User.New("Third user"), Parameters.Empty, admin) - }.skipLoading().toList()) + assertEquals( + successful(Unit), + slice { + endpoint.validate(this, endpoint.idOf(), User.New("Third user"), Parameters.Empty, admin) + } + ) } @Test @@ -221,9 +220,12 @@ class ServiceTest { val admin = Context(Ref.Basic("1", bone)) val id = endpoint.idOf("0") - assertEquals(emptyList(), state { - endpoint.validate(this, id, User.Rename("Another name"), Parameters.Empty, admin) - }.skipLoading().toList()) + assertEquals( + successful(Unit), + slice { + endpoint.validate(this, id, User.Rename("Another name"), Parameters.Empty, admin) + } + ) } }