From da4e8e2d837213f65d163c40f8a5bb7e487a1bb4 Mon Sep 17 00:00:00 2001 From: Nacho Lopez Date: Sun, 15 Feb 2026 22:07:10 +0100 Subject: [PATCH] feat: add resultFlow builder with ResultFlowScope Co-Authored-By: Claude Opus 4.6 --- .../asyncresult/AsyncResult.resultflow.kt | 94 +++++++++++ .../asyncresult/AsyncResultResultFlowTest.kt | 156 ++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 asyncresult/src/commonMain/kotlin/io/nlopez/asyncresult/AsyncResult.resultflow.kt create mode 100644 asyncresult/src/commonTest/kotlin/io/nlopez/asyncresult/AsyncResultResultFlowTest.kt diff --git a/asyncresult/src/commonMain/kotlin/io/nlopez/asyncresult/AsyncResult.resultflow.kt b/asyncresult/src/commonMain/kotlin/io/nlopez/asyncresult/AsyncResult.resultflow.kt new file mode 100644 index 00000000..db65e461 --- /dev/null +++ b/asyncresult/src/commonMain/kotlin/io/nlopez/asyncresult/AsyncResult.resultflow.kt @@ -0,0 +1,94 @@ +// Copyright 2026 Nacho Lopez +// SPDX-License-Identifier: MIT +package io.nlopez.asyncresult + +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.flow + +/** + * Scope for the [resultFlow] builder. Provides explicit emission functions for each [AsyncResult] + * state: [success], [error], [loading], and [notStarted]. + * + * All functions return [Unit] and can be called multiple times to emit multiple values. + */ +public interface ResultFlowScope { + /** Emits a [Success] wrapping [value]. */ + public suspend fun success(value: T) + + /** Emits the given [Error]. */ + public suspend fun error(error: Error) + + /** Emits [Loading]. */ + public suspend fun loading() + + /** Emits [NotStarted]. */ + public suspend fun notStarted() +} + +/** Emits an [Error] wrapping the given [throwable] and optional [errorId]. */ +public suspend fun ResultFlowScope.error( + throwable: Throwable, + errorId: ErrorId? = null, +): Unit = error(Error(throwable, errorId = errorId)) + +/** Emits an [Error] carrying typed [metadata] and optional [errorId]. */ +public suspend fun ResultFlowScope.errorWithMetadata( + metadata: Any, + errorId: ErrorId? = null, +): Unit = error(ErrorWithMetadata(metadata, errorId)) + +/** + * Creates a [Flow] of [AsyncResult] with a DSL that provides explicit + * [success][ResultFlowScope.success], [error][ResultFlowScope.error], + * [loading][ResultFlowScope.loading], and [notStarted][ResultFlowScope.notStarted] emissions. + * + * Unlike the [result] DSL which produces a single [AsyncResult], this builder produces a [Flow] + * that can emit multiple [AsyncResult] values over time. + * + * Uncaught exceptions (except [CancellationException]) are caught and emitted as [Error]. + * + * Example: + * ```kotlin + * val userFlow: Flow> = resultFlow { + * loading() + * val user = fetchUser(id) + * success(user) + * } + * ``` + * + * @param block The builder block that emits [AsyncResult] values via the [ResultFlowScope]. + * @return A [Flow] of [AsyncResult] values. + */ +public fun resultFlow( + block: suspend ResultFlowScope.() -> Unit, +): Flow> = flow { + try { + ResultFlowScopeImpl(this).block() + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + emit(Error(e)) + } +} + +internal class ResultFlowScopeImpl( + private val collector: FlowCollector>, +) : ResultFlowScope { + override suspend fun success(value: T) { + collector.emit(Success(value)) + } + + override suspend fun error(error: Error) { + collector.emit(error) + } + + override suspend fun loading() { + collector.emit(Loading) + } + + override suspend fun notStarted() { + collector.emit(NotStarted) + } +} diff --git a/asyncresult/src/commonTest/kotlin/io/nlopez/asyncresult/AsyncResultResultFlowTest.kt b/asyncresult/src/commonTest/kotlin/io/nlopez/asyncresult/AsyncResultResultFlowTest.kt new file mode 100644 index 00000000..c6d00709 --- /dev/null +++ b/asyncresult/src/commonTest/kotlin/io/nlopez/asyncresult/AsyncResultResultFlowTest.kt @@ -0,0 +1,156 @@ +// Copyright 2026 Nacho Lopez +// SPDX-License-Identifier: MIT +package io.nlopez.asyncresult + +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotNull +import kotlin.test.Test +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest + +class AsyncResultResultFlowTest { + + @Test + fun `success emits Success value`() = runTest { + val results = resultFlow { success(42) }.toList() + assertThat(results).containsExactly(Success(42)) + } + + @Test + fun `error emits Error instance`() = runTest { + val err = Error(Throwable("boom")) + val results = resultFlow { error(err) }.toList() + assertThat(results).containsExactly(err) + } + + @Test + fun `loading emits Loading`() = runTest { + val results = resultFlow { loading() }.toList() + assertThat(results).containsExactly(Loading) + } + + @Test + fun `notStarted emits NotStarted`() = runTest { + val results = resultFlow { notStarted() }.toList() + assertThat(results).containsExactly(NotStarted) + } + + @Test + fun `multiple emissions in sequence`() = runTest { + val results = + resultFlow { + loading() + success(1) + success(2) + } + .toList() + assertThat(results).containsExactly(Loading, Success(1), Success(2)) + } + + @Test + fun `error with Throwable and errorId`() = runTest { + val throwable = RuntimeException("fail") + val errorId = ErrorId("err-1") + val results = resultFlow { error(throwable, errorId) }.toList() + + assertThat(results.size).isEqualTo(1) + val err = results[0] as Error + assertThat(err.throwable).isEqualTo(throwable) + assertThat(err.errorId).isEqualTo(errorId) + } + + @Test + fun `error with Throwable only`() = runTest { + val throwable = RuntimeException("fail") + val results = resultFlow { error(throwable) }.toList() + + assertThat(results).containsExactly(Error(throwable)) + } + + @Test + fun `errorWithMetadata emits Error with typed metadata`() = runTest { + data class MyMeta(val code: Int) + val results = resultFlow { errorWithMetadata(MyMeta(404)) }.toList() + + assertThat(results.size).isEqualTo(1) + val err = results[0] as Error + assertThat(err.metadataOrNull()).isEqualTo(MyMeta(404)) + } + + @Test + fun `errorWithMetadata with errorId`() = runTest { + data class MyMeta(val code: Int) + val errorId = ErrorId("not-found") + val results = resultFlow { errorWithMetadata(MyMeta(404), errorId) }.toList() + + assertThat(results.size).isEqualTo(1) + val err = results[0] as Error + assertThat(err.metadataOrNull()).isEqualTo(MyMeta(404)) + assertThat(err.errorId).isEqualTo(errorId) + } + + @Test + fun `uncaught exception emitted as Error`() = runTest { + val exception = IllegalStateException("unexpected") + val results = resultFlow { throw exception }.toList() + + assertThat(results.size).isEqualTo(1) + val err = results[0] as Error + assertThat(err.throwable).isNotNull() + assertThat(err.throwable).isEqualTo(exception) + } + + @Test + fun `CancellationException propagates and is not caught`() = runTest { + var caught = false + try { + resultFlow { throw kotlinx.coroutines.CancellationException("cancelled") }.toList() + } catch (e: kotlinx.coroutines.CancellationException) { + caught = true + assertThat(e.message).isEqualTo("cancelled") + } + assertThat(caught).isEqualTo(true) + } + + @Test + fun `empty block emits nothing`() = runTest { + val results = resultFlow {}.toList() + assertThat(results).containsExactly() + } + + @Test + fun `uncaught exception after partial emissions preserves earlier values`() = runTest { + val exception = IllegalStateException("mid-stream failure") + val results = + resultFlow { + loading() + success(42) + throw exception + } + .toList() + + assertThat(results.size).isEqualTo(3) + assertThat(results[0]).isEqualTo(Loading) + assertThat(results[1]).isEqualTo(Success(42)) + val err = results[2] as Error + assertThat(err.throwable).isEqualTo(exception) + } + + @Test + fun `mixed emissions loading then error then success`() = runTest { + val results = + resultFlow { + loading() + error(Error(Throwable("oops"))) + success(99) + } + .toList() + assertThat(results.size).isEqualTo(3) + assertThat(results[0]).isEqualTo(Loading) + assertThat(results[1]).isInstanceOf() + assertThat(results[2]).isEqualTo(Success(99)) + } +}