diff --git a/spine/src/commonMain/kotlin/opensavvy.spine/Resource.kt b/spine/src/commonMain/kotlin/opensavvy.spine/Resource.kt index e0dbf44..dff63a5 100644 --- a/spine/src/commonMain/kotlin/opensavvy.spine/Resource.kt +++ b/spine/src/commonMain/kotlin/opensavvy.spine/Resource.kt @@ -75,12 +75,9 @@ sealed class ResourceGroup { abstract val parent: ResourceGroup /** - * Verifies that [id] is a valid identifier for this resource. - * - * This function is automatically called to verify all identifiers passed to all endpoints to this resource ([get] and the other functions). - * It can be overridden to add checks for user rights, etc. + * Validates that [id] identifies this resource. */ - open suspend fun StateBuilder, O>.validateId(id: Id, context: Context) { + suspend fun StateBuilder, O>.validateCorrectId(id: Id) { ensureValid( id, id.service == service.name @@ -123,6 +120,14 @@ sealed class ResourceGroup { ) { "The passed identifier's URI length is too long for this resource: '$id' for resource '${this@AbstractResource}'" } } + /** + * Custom validation based on [id]. + * + * For example, you can override this function to check access rights for read operations. + * By default, this function does nothing. + */ + open suspend fun StateBuilder, O>.validateId(id: Id, context: Context) {} + protected fun create( route: Route? = null, validate: OperationValidator = { _, _, _, _ -> }, @@ -133,6 +138,7 @@ sealed class ResourceGroup { 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) } @@ -141,6 +147,7 @@ sealed class ResourceGroup { 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) } @@ -151,6 +158,7 @@ sealed class ResourceGroup { Operation.Kind.Delete, route = null ) { id: Id, it: In, _: Parameters.Empty, context -> + validateCorrectId(id) validateId(id, context) validate(id, it, Parameters.Empty, context) } @@ -209,6 +217,7 @@ sealed class ResourceGroup { @Suppress("LeakingThis") // Not dangerous because Operation's constructor does nothing val get = Operation(this, Operation.Kind.Read) { id: Id, _, params, context -> + validateCorrectId(id) validateId(id, context) validateGetParams(params, context) } @@ -249,10 +258,8 @@ sealed class ResourceGroup { @Suppress("LeakingThis") // Not dangerous because Operation's constructor does nothing val get = Operation(this, Operation.Kind.Read) { id, _, _, context -> - validateId( - id, - context - ) + validateCorrectId(id) + validateId(id, context) } final override val routeTemplate get() = "${this@ResourceGroup.routeTemplate}/{$name}" -- 2.51.2 From ed2a1e3cb64314313ebf517c010333e4639080f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 13 Oct 2022 17:26:56 +0200 Subject: [PATCH 2/4] refactor(spine): The get query parameters now have access to the ID Closes https://gitlab.com/opensavvy/pedestal/-/issues/25 --- spine/src/commonMain/kotlin/opensavvy.spine/Resource.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spine/src/commonMain/kotlin/opensavvy.spine/Resource.kt b/spine/src/commonMain/kotlin/opensavvy.spine/Resource.kt index dff63a5..f7a571b 100644 --- a/spine/src/commonMain/kotlin/opensavvy.spine/Resource.kt +++ b/spine/src/commonMain/kotlin/opensavvy.spine/Resource.kt @@ -212,14 +212,14 @@ sealed class ResourceGroup { * * You should override this function if the parameters impact the access rights. */ - open suspend fun StateBuilder, O>.validateGetParams(params: GetParams, context: Context) {} + open suspend fun StateBuilder, O>.validateGetParams(id: Id, params: GetParams, context: Context) {} @Suppress("LeakingThis") // Not dangerous because Operation's constructor does nothing val get = Operation(this, Operation.Kind.Read) { id: Id, _, params, context -> validateCorrectId(id) validateId(id, context) - validateGetParams(params, context) + validateGetParams(id, params, context) } final override val routeTemplate get() = "${this@ResourceGroup.routeTemplate}/$route" -- 2.51.2 From 9a2d4159769deb15c049fbce77d2b04473b1f68c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 13 Oct 2022 17:28:10 +0200 Subject: [PATCH 3/4] refactor(spine): Renamed BackboneCache to RefCache Closes https://gitlab.com/opensavvy/pedestal/-/issues/24 --- .../kotlin/opensavvy/auth/jwt/JwtAuthenticatorTest.kt | 6 +++--- .../src/commonMain/kotlin/opensavvy/backbone/Backbone.kt | 2 +- .../src/commonMain/kotlin/opensavvy/backbone/RefCache.kt | 6 +++--- .../kotlin/opensavvy.backbone/BackboneCacheTest.kt | 6 +++--- spine/src/commonTest/kotlin/opensavvy.spine/ServiceTest.kt | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/auth/src/jvmTest/kotlin/opensavvy/auth/jwt/JwtAuthenticatorTest.kt b/auth/src/jvmTest/kotlin/opensavvy/auth/jwt/JwtAuthenticatorTest.kt index c8ecb7f..a7dd6cc 100644 --- a/auth/src/jvmTest/kotlin/opensavvy/auth/jwt/JwtAuthenticatorTest.kt +++ b/auth/src/jvmTest/kotlin/opensavvy/auth/jwt/JwtAuthenticatorTest.kt @@ -5,9 +5,9 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.runTest import opensavvy.auth.Account import opensavvy.auth.Roles -import opensavvy.backbone.BackboneCache +import opensavvy.backbone.RefCache import opensavvy.backbone.RefState -import opensavvy.backbone.defaultBackboneCache +import opensavvy.backbone.defaultRefCache import opensavvy.logger.LogLevel import opensavvy.logger.Logger.Companion.debug import opensavvy.logger.loggerFor @@ -38,7 +38,7 @@ class JwtAuthenticatorTest { class Accounts : Account.Bone { override fun fromId(id: String) = Ref(id, this) - override val cache: BackboneCache> = defaultBackboneCache() + override val cache: RefCache> = defaultRefCache() override fun directRequest(ref: opensavvy.backbone.Ref>): RefState> = flow { require(ref is Ref) { "$this doesn't support the reference $ref" } diff --git a/backbone/src/commonMain/kotlin/opensavvy/backbone/Backbone.kt b/backbone/src/commonMain/kotlin/opensavvy/backbone/Backbone.kt index 822bd7e..a068c08 100644 --- a/backbone/src/commonMain/kotlin/opensavvy/backbone/Backbone.kt +++ b/backbone/src/commonMain/kotlin/opensavvy/backbone/Backbone.kt @@ -17,7 +17,7 @@ interface Backbone { * * As a convenience, you can use [request] to launch a request through the cache. */ - val cache: BackboneCache + val cache: RefCache /** * Fetches the value associated with a [ref] in an external media (e.g. a remote server, a database). diff --git a/backbone/src/commonMain/kotlin/opensavvy/backbone/RefCache.kt b/backbone/src/commonMain/kotlin/opensavvy/backbone/RefCache.kt index e33331f..174d367 100644 --- a/backbone/src/commonMain/kotlin/opensavvy/backbone/RefCache.kt +++ b/backbone/src/commonMain/kotlin/opensavvy/backbone/RefCache.kt @@ -8,11 +8,11 @@ import opensavvy.cache.CacheAdapter import opensavvy.state.state import kotlin.coroutines.CoroutineContext -typealias BackboneCache = Cache, O> +typealias RefCache = Cache, O> -fun defaultBackboneCache() = CacheAdapter, O> { it.directRequest() } +fun defaultRefCache() = CacheAdapter, O> { it.directRequest() } -fun batchingBackboneCache(context: CoroutineContext, workers: Int = 1) = +fun batchingRefCache(context: CoroutineContext, workers: Int = 1) = BatchingCacheAdapter, O>(context, workers) { requests -> val results = requests.groupBy { it.backbone } .map { (backbone, refs) -> backbone.batchRequests(refs.toHashSet()) } diff --git a/backbone/src/commonTest/kotlin/opensavvy.backbone/BackboneCacheTest.kt b/backbone/src/commonTest/kotlin/opensavvy.backbone/BackboneCacheTest.kt index 03e409f..2bd2d53 100644 --- a/backbone/src/commonTest/kotlin/opensavvy.backbone/BackboneCacheTest.kt +++ b/backbone/src/commonTest/kotlin/opensavvy.backbone/BackboneCacheTest.kt @@ -16,7 +16,7 @@ import kotlin.test.assertEquals class BackboneCacheTest { // Id("12") -> 12 - private class Bone(override val cache: BackboneCache) : Backbone { + private class Bone(override val cache: RefCache) : Backbone { override fun directRequest(ref: Ref): RefState = state { ensureValid(ref, ref is Ref.Basic) { "Only basic references are accepted by ${this@Bone}" } val int = ref.id.toIntOrNull() @@ -29,7 +29,7 @@ class BackboneCacheTest { @Test fun default() = runTest { - val bone = Bone(defaultBackboneCache()) + val bone = Bone(defaultRefCache()) val id5 = bone.of(5) val id2 = bone.of(2) @@ -44,7 +44,7 @@ class BackboneCacheTest { fun batching() = runTest { val job = Job() - val bone = Bone(batchingBackboneCache(coroutineContext + job)) + val bone = Bone(batchingRefCache(coroutineContext + job)) val id5 = bone.of(5) val id2 = bone.of(2) diff --git a/spine/src/commonTest/kotlin/opensavvy.spine/ServiceTest.kt b/spine/src/commonTest/kotlin/opensavvy.spine/ServiceTest.kt index ae25ed6..a75408a 100644 --- a/spine/src/commonTest/kotlin/opensavvy.spine/ServiceTest.kt +++ b/spine/src/commonTest/kotlin/opensavvy.spine/ServiceTest.kt @@ -11,7 +11,7 @@ import opensavvy.backbone.Ref import opensavvy.backbone.Ref.Companion.request import opensavvy.backbone.Ref.Companion.requestValue import opensavvy.backbone.RefState -import opensavvy.backbone.defaultBackboneCache +import opensavvy.backbone.defaultRefCache import opensavvy.spine.Route.Companion.div import opensavvy.state.* import opensavvy.state.Slice.Companion.failed @@ -114,7 +114,7 @@ class ServiceTest { } private class UserBone : Backbone { - override val cache = defaultBackboneCache() + override val cache = defaultRefCache() private val users = HashMap() -- 2.51.2 From e7adbf02ac1a1b475b1b619cf118032c8c67222e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 14 Oct 2022 11:43:03 +0200 Subject: [PATCH 4/4] refactor: States are not identified anymore Previously, State instances always knew their ID. It made the code harder to write because everything had to know all IDs. Now, it's not necessary anymore. Closes https://gitlab.com/opensavvy/pedestal/-/issues/27 --- .../auth/jwt/JwtAuthenticatorTest.kt | 8 +- .../kotlin/opensavvy/backbone/Backbone.kt | 13 +- .../kotlin/opensavvy/backbone/Ref.kt | 5 +- .../kotlin/opensavvy/backbone/RefCache.kt | 22 +++- .../opensavvy.backbone/BackboneCacheTest.kt | 11 +- .../opensavvy.cache/BatchingCacheAdapter.kt | 40 +++--- .../kotlin/opensavvy.cache/Cache.kt | 4 +- .../kotlin/opensavvy.cache/CacheAdapter.kt | 4 +- .../kotlin/opensavvy.cache/ExpirationCache.kt | 6 +- .../kotlin/opensavvy.cache/MemoryCache.kt | 10 +- .../kotlin/opensavvy.cache/CacheTest.kt | 29 ++-- .../opensavvy.spine.ktor.client/Client.kt | 21 +-- .../opensavvy.spine.ktor.server/GenerateId.kt | 2 +- .../ResponseStateBuilder.kt | 8 +- .../opensavvy.spine.ktor.server/Server.kt | 3 +- .../opensavvy.spine.ktor.server/ServerTest.kt | 28 ++-- .../opensavvy.spine.ktor.server/TestApi.kt | 4 +- .../opensavvy.spine.ktor/NetworkResponse.kt | 2 - .../commonMain/kotlin/opensavvy.spine/Id.kt | 14 +- .../kotlin/opensavvy.spine/Operation.kt | 4 +- .../kotlin/opensavvy.spine/Resource.kt | 34 +++-- .../kotlin/opensavvy.spine/Service.kt | 2 +- .../kotlin/opensavvy.spine/IdTest.kt | 2 +- .../kotlin/opensavvy.spine/ServiceTest.kt | 48 +++---- .../kotlin/opensavvy.state/Identifier.kt | 4 +- .../kotlin/opensavvy.state/Slice.kt | 79 ++--------- .../kotlin/opensavvy.state/State.kt | 10 +- .../kotlin/opensavvy.state/StateBuilder.kt | 118 ++++------------- .../kotlin/opensavvy.state/SliceTest.kt | 124 +++++++++--------- .../opensavvy.state/StateBuilderTest.kt | 21 ++- 30 files changed, 275 insertions(+), 405 deletions(-) diff --git a/auth/src/jvmTest/kotlin/opensavvy/auth/jwt/JwtAuthenticatorTest.kt b/auth/src/jvmTest/kotlin/opensavvy/auth/jwt/JwtAuthenticatorTest.kt index a7dd6cc..59eb3fa 100644 --- a/auth/src/jvmTest/kotlin/opensavvy/auth/jwt/JwtAuthenticatorTest.kt +++ b/auth/src/jvmTest/kotlin/opensavvy/auth/jwt/JwtAuthenticatorTest.kt @@ -6,12 +6,12 @@ import kotlinx.coroutines.test.runTest import opensavvy.auth.Account import opensavvy.auth.Roles import opensavvy.backbone.RefCache -import opensavvy.backbone.RefState import opensavvy.backbone.defaultRefCache import opensavvy.logger.LogLevel import opensavvy.logger.Logger.Companion.debug import opensavvy.logger.loggerFor -import opensavvy.state.emitSuccessful +import opensavvy.state.Slice.Companion.successful +import opensavvy.state.State import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -40,7 +40,7 @@ class JwtAuthenticatorTest { override val cache: RefCache> = defaultRefCache() - override fun directRequest(ref: opensavvy.backbone.Ref>): RefState> = flow { + override fun directRequest(ref: opensavvy.backbone.Ref>): State> = flow { require(ref is Ref) { "$this doesn't support the reference $ref" } val account = when (ref.id) { @@ -50,7 +50,7 @@ class JwtAuthenticatorTest { else -> error("Invalid user id: ${ref.id}") } - emitSuccessful(ref, account) + emit(successful(account)) } } diff --git a/backbone/src/commonMain/kotlin/opensavvy/backbone/Backbone.kt b/backbone/src/commonMain/kotlin/opensavvy/backbone/Backbone.kt index a068c08..e80ba3c 100644 --- a/backbone/src/commonMain/kotlin/opensavvy/backbone/Backbone.kt +++ b/backbone/src/commonMain/kotlin/opensavvy/backbone/Backbone.kt @@ -1,7 +1,6 @@ package opensavvy.backbone -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.flow +import opensavvy.state.State /** * A common interface for API endpoints. @@ -27,7 +26,7 @@ interface Backbone { * * The returned flow is **short-lived**: it is closed after the request finishes. */ - fun directRequest(ref: Ref): RefState + fun directRequest(ref: Ref): State /** * Fetches the value associated with all [refs] in an external media (e.g. a remote server, a database). @@ -42,11 +41,9 @@ interface Backbone { * * The default implementation simply calls [directRequest] sequentially. */ - fun batchRequests(refs: Set>): RefState = flow { - for (ref in refs) { - emitAll(directRequest(ref)) - } - } + fun batchRequests(refs: Set>): Map, State> = refs + .map { it to 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 e46ba73..1b72684 100644 --- a/backbone/src/commonMain/kotlin/opensavvy/backbone/Ref.kt +++ b/backbone/src/commonMain/kotlin/opensavvy/backbone/Ref.kt @@ -5,11 +5,8 @@ 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.State import opensavvy.state.firstResultOrThrow -typealias RefState = State, O> - /** * A reference to a specific [object][O]. * @@ -22,7 +19,7 @@ typealias RefState = State, O> * * @param O The object this reference refers to. */ -interface Ref : Identifier { +interface Ref : Identifier { /** * The [Backbone] responsible for this reference. diff --git a/backbone/src/commonMain/kotlin/opensavvy/backbone/RefCache.kt b/backbone/src/commonMain/kotlin/opensavvy/backbone/RefCache.kt index 174d367..918bd1f 100644 --- a/backbone/src/commonMain/kotlin/opensavvy/backbone/RefCache.kt +++ b/backbone/src/commonMain/kotlin/opensavvy/backbone/RefCache.kt @@ -1,11 +1,12 @@ 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 import opensavvy.cache.CacheAdapter -import opensavvy.state.state import kotlin.coroutines.CoroutineContext typealias RefCache = Cache, O> @@ -14,11 +15,20 @@ fun defaultRefCache() = CacheAdapter, O> { it.directRequest() } fun batchingRefCache(context: CoroutineContext, workers: Int = 1) = BatchingCacheAdapter, O>(context, workers) { requests -> - val results = requests.groupBy { it.backbone } - .map { (backbone, refs) -> backbone.batchRequests(refs.toHashSet()) } + val backbones = requests + .groupBy { it.backbone } - state { - for (result in results) - emitAll(result) + flow { + // This implementation is sequential + // Because the actual network request is batched, we assume that the sequential reading of the results is not + // an issue. + // If it ever becomes one, this function should be rewritten to wait for the results in parallel. + + for ((backbone, refs) in backbones) { + val results = backbone.batchRequests(refs.toHashSet()) + for ((ref, result) in results) { + emitAll(result.map { ref to it }) + } + } } } diff --git a/backbone/src/commonTest/kotlin/opensavvy.backbone/BackboneCacheTest.kt b/backbone/src/commonTest/kotlin/opensavvy.backbone/BackboneCacheTest.kt index 2bd2d53..49cb137 100644 --- a/backbone/src/commonTest/kotlin/opensavvy.backbone/BackboneCacheTest.kt +++ b/backbone/src/commonTest/kotlin/opensavvy.backbone/BackboneCacheTest.kt @@ -7,7 +7,8 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.test.runTest import opensavvy.backbone.Ref.Companion.expire import opensavvy.backbone.Ref.Companion.requestValue -import opensavvy.state.emitSuccessful +import opensavvy.state.Slice.Companion.successful +import opensavvy.state.State import opensavvy.state.ensureValid import opensavvy.state.state import kotlin.test.Test @@ -17,11 +18,11 @@ class BackboneCacheTest { // Id("12") -> 12 private class Bone(override val cache: RefCache) : Backbone { - override fun directRequest(ref: Ref): RefState = state { - ensureValid(ref, ref is Ref.Basic) { "Only basic references are accepted by ${this@Bone}" } + override fun directRequest(ref: Ref): State = state { + ensureValid(ref is Ref.Basic) { "Only basic references are accepted by ${this@Bone}" } val int = ref.id.toIntOrNull() - ensureValid(ref, int != null) { "The reference $ref does not refer to a valid integer" } - emitSuccessful(ref, int) + ensureValid(int != null) { "The reference $ref does not refer to a valid integer" } + emit(successful(int)) } fun of(int: Int) = Ref.Basic(int.toString(), this) diff --git a/cache/src/commonMain/kotlin/opensavvy.cache/BatchingCacheAdapter.kt b/cache/src/commonMain/kotlin/opensavvy.cache/BatchingCacheAdapter.kt index fdf67b5..8ab6e87 100644 --- a/cache/src/commonMain/kotlin/opensavvy.cache/BatchingCacheAdapter.kt +++ b/cache/src/commonMain/kotlin/opensavvy.cache/BatchingCacheAdapter.kt @@ -5,11 +5,14 @@ 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.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 kotlin.coroutines.CoroutineContext @@ -23,7 +26,7 @@ import kotlin.coroutines.coroutineContext * * Unlike [CacheAdapter], this class is able to group requests together. */ -class BatchingCacheAdapter, T>( +class BatchingCacheAdapter( context: CoroutineContext, /** * The number of workers batching the requests. @@ -34,15 +37,17 @@ class BatchingCacheAdapter, T>( * Increasing the number of workers may increase latency. */ workers: Int = 1, - val queryBatch: (Set) -> State, + val queryBatch: (Set) -> Flow>>, ) : Cache { - private val requests: SendChannel>>>> + private val log = loggerFor(this) + + 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) @@ -53,14 +58,14 @@ class BatchingCacheAdapter, T>( } } - 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 @@ -81,11 +86,11 @@ class BatchingCacheAdapter, T>( .add(promise) } - val states = HashMap>>() + val states = HashMap>>() // Tell all clients that their request is starting for ((id, promises) in results) { - val state = MutableStateFlow(pending(id, Progression.loading(0.0))) + val state: MutableStateFlow> = MutableStateFlow(pending(Progression.loading(0.0))) for (promise in promises) { promise.complete(state) @@ -96,17 +101,22 @@ class BatchingCacheAdapter, T>( // The channel is now empty, request all of them queryBatch(batch) - .collect { - val state = states[it.id] - ?: error("Could not find the state for the reference ${it.id}, this means we received an update for a reference we did not ask for.") - - state.value = it + .collect { (id, it) -> + val state = states[id] + + if (state != null) + state.value = it + else + log.error( + id, + it + ) { "Could not find the state for the reference $id, this means we received an update for a reference we did not ask for. It has been ignored." } } } } - override fun get(id: I): State = state { - val promise = CompletableDeferred>>() + override fun get(id: I): State = state { + val promise = CompletableDeferred>>() requests.send(id to promise) diff --git a/cache/src/commonMain/kotlin/opensavvy.cache/Cache.kt b/cache/src/commonMain/kotlin/opensavvy.cache/Cache.kt index b297c17..6433f39 100644 --- a/cache/src/commonMain/kotlin/opensavvy.cache/Cache.kt +++ b/cache/src/commonMain/kotlin/opensavvy.cache/Cache.kt @@ -34,7 +34,7 @@ import opensavvy.state.State * The first element of the chain, and therefore the one responsible for actually starting the request, is [CacheAdapter] or [BatchingCacheAdapter]. * Note that both have a few implementation differences, it is not recommended to use them directly without chaining under another implementation. */ -interface Cache, T> { +interface Cache { /** * Gets the value associated with an [id] in this cache. @@ -43,7 +43,7 @@ interface Cache, T> { * 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): State /** * 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 ca18cc7..a31cc8e 100644 --- a/cache/src/commonMain/kotlin/opensavvy.cache/CacheAdapter.kt +++ b/cache/src/commonMain/kotlin/opensavvy.cache/CacheAdapter.kt @@ -9,8 +9,8 @@ import opensavvy.state.State * This is not a valid implementation of a cache (it doesn't do any caching), and only serves as a link between caches * and the underlying network APIs. */ -class CacheAdapter, T>( - val query: (I) -> State, +class CacheAdapter( + val query: (I) -> State, ) : Cache { override fun get(id: I) = query(id) diff --git a/cache/src/commonMain/kotlin/opensavvy.cache/ExpirationCache.kt b/cache/src/commonMain/kotlin/opensavvy.cache/ExpirationCache.kt index 198970c..55d670b 100644 --- a/cache/src/commonMain/kotlin/opensavvy.cache/ExpirationCache.kt +++ b/cache/src/commonMain/kotlin/opensavvy.cache/ExpirationCache.kt @@ -26,7 +26,7 @@ import kotlin.time.Duration.Companion.minutes * .expireAfter(5.minutes, Job()) * ``` */ -class ExpirationCache, T>( +class ExpirationCache( /** * The previous cache layer, from which values will be expired. */ @@ -83,7 +83,7 @@ class ExpirationCache, T>( } } - override fun get(id: I): State = upstream[id] + override fun get(id: I): State = upstream[id] .onEach { markAsUpdatedNow(id) } override suspend fun update(values: Collection>) { @@ -113,7 +113,7 @@ class ExpirationCache, T>( * * @see ExpirationCache */ - fun , T> Cache.expireAfter(duration: Duration, context: CoroutineContext) = + fun Cache.expireAfter(duration: Duration, context: CoroutineContext) = ExpirationCache(this, duration, context) } } diff --git a/cache/src/commonMain/kotlin/opensavvy.cache/MemoryCache.kt b/cache/src/commonMain/kotlin/opensavvy.cache/MemoryCache.kt index 89fdb04..40cf7b0 100644 --- a/cache/src/commonMain/kotlin/opensavvy.cache/MemoryCache.kt +++ b/cache/src/commonMain/kotlin/opensavvy.cache/MemoryCache.kt @@ -31,7 +31,7 @@ import kotlin.coroutines.EmptyCoroutineContext * .expireAfter(2.minutes) * ``` */ -class MemoryCache, T>( +class MemoryCache( private val upstream: Cache, context: CoroutineContext = EmptyCoroutineContext, ) : Cache { @@ -49,7 +49,7 @@ class MemoryCache, T>( * - 'expire' removed the cached value */ - private val cache = HashMap?>>() + private val cache = HashMap?>>() private val cacheLock = Semaphore(1) private val jobs = HashMap() @@ -61,7 +61,7 @@ class MemoryCache, T>( /** **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): State = flow { val cached = cacheLock.withPermit { getUnsafe(id) } .onEach { slice -> if (slice == null) { @@ -104,7 +104,7 @@ class MemoryCache, T>( cacheLock.withPermit { for ((id, value) in values) { - getUnsafe(id).value = successful(id, value) + getUnsafe(id).value = successful(value) } } @@ -168,6 +168,6 @@ class MemoryCache, T>( } companion object { - fun , T> Cache.cachedInMemory(context: CoroutineContext) = MemoryCache(this, context) + fun Cache.cachedInMemory(context: CoroutineContext) = MemoryCache(this, context) } } diff --git a/cache/src/commonTest/kotlin/opensavvy.cache/CacheTest.kt b/cache/src/commonTest/kotlin/opensavvy.cache/CacheTest.kt index 67157e1..da129c9 100644 --- a/cache/src/commonTest/kotlin/opensavvy.cache/CacheTest.kt +++ b/cache/src/commonTest/kotlin/opensavvy.cache/CacheTest.kt @@ -3,10 +3,10 @@ package opensavvy.cache import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.* import kotlinx.coroutines.test.runTest import opensavvy.cache.ExpirationCache.Companion.expireAfter import opensavvy.cache.MemoryCache.Companion.cachedInMemory @@ -15,6 +15,8 @@ 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 kotlin.test.Test import kotlin.test.assertEquals import kotlin.time.Duration.Companion.seconds @@ -25,7 +27,7 @@ class CacheTest { level = LogLevel.TRACE } - private data class IntId(val id: Int) : Identifier { + private data class IntId(val id: Int) : Identifier { override fun toString() = "Id($id)" } @@ -33,10 +35,10 @@ class CacheTest { state { log.debug(it) { "Requesting" } delay(100L) - emitPending(it, 0.2) + emit(pending(0.2)) delay(10L) - ensureValid(it, it.id >= 0) { "Only positive integers are allowed: found ${it.id}" } - emitSuccessful(it, it.id) + ensureValid(it.id >= 0) { "Only positive integers are allowed: found ${it.id}" } + emit(successful(it.id)) } } @@ -184,17 +186,20 @@ class CacheTest { job.cancel() } + @OptIn(FlowPreview::class) @Test fun batching() = runTest { val job = Job() val cache = BatchingCacheAdapter(coroutineContext + job) { ids -> - state { - for (id in ids) { - emitPending(id) - delay(10L) - emitSuccessful(id, id.id) + ids.asFlow() + .map { ref -> + state { + emit(pending()) + delay(10L) + emit(successful(ref.id)) + }.map { ref to it } } - } + .flattenConcat() } .cachedInMemory(coroutineContext + job) .expireAfter(1.seconds, coroutineContext + job) 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 1ffed14..b9ef781 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 @@ -11,7 +11,12 @@ import opensavvy.spine.ResourceGroup.AbstractResource import opensavvy.spine.ktor.NetworkResponse import opensavvy.spine.ktor.toHttp import opensavvy.spine.ktor.toSpine -import opensavvy.state.* +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 /** * Executes a [HttpClient] request, with the information declared in an [Operation]. @@ -40,18 +45,18 @@ import opensavvy.state.* */ inline fun HttpClient.request( operation: Operation, - id: Id, + id: Id, input: In, parameters: Params, context: Context, contentType: ContentType = ContentType.Application.Json, crossinline configuration: HttpRequestBuilder.() -> Unit = {}, -): State, Out> = state { - emitPending(id = null, 0.0) +): State = state { + emit(pending(0.0)) operation.validate(this, id, input, parameters, context) - emitPending(id = null, 0.1) + emit(pending(0.1)) val result = request { method = operation.kind.toHttp() @@ -80,14 +85,14 @@ inline fun = result.body() - emitSuccessful(response.id, response.value) + emit(successful(response.value)) } else { val body = result.body().ifBlank { "${result.status} with no provided body" } val kind = result.status.toSpine() - emitFailed(id = null, kind, body, Progression.done()) + emit(failed(kind, body, progression = done())) } } diff --git a/spine-ktor/spine-ktor-server/src/commonMain/kotlin/opensavvy.spine.ktor.server/GenerateId.kt b/spine-ktor/spine-ktor-server/src/commonMain/kotlin/opensavvy.spine.ktor.server/GenerateId.kt index 7502892..9a2c734 100644 --- a/spine-ktor/spine-ktor-server/src/commonMain/kotlin/opensavvy.spine.ktor.server/GenerateId.kt +++ b/spine-ktor/spine-ktor-server/src/commonMain/kotlin/opensavvy.spine.ktor.server/GenerateId.kt @@ -5,7 +5,7 @@ import opensavvy.spine.Id import opensavvy.spine.ResourceGroup import opensavvy.spine.Route -fun ApplicationCall.generateId(resource: ResourceGroup.AbstractResource): Id { +fun ApplicationCall.generateId(resource: ResourceGroup.AbstractResource): Id { val values = ArrayList() var cursor: ResourceGroup = resource 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 25d51d7..6da57d1 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 @@ -8,13 +8,13 @@ import opensavvy.state.StateBuilder /** * Information available in [route]. */ -class ResponseStateBuilder( - builder: StateBuilder, Out>, +class ResponseStateBuilder( + builder: StateBuilder, /** * The identifier of the resource being requested. */ - val id: Id, + val id: Id, /** * The body of the request. @@ -35,4 +35,4 @@ class ResponseStateBuilder( * The current request's context. */ val context: Context, -) : StateBuilder, Out> by builder +) : StateBuilder 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 c3194a2..3d330f8 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 @@ -45,7 +45,7 @@ import opensavvy.state.state inline fun Route.route( operation: Operation, contextGenerator: ContextGenerator, - crossinline block: suspend ResponseStateBuilder.() -> Unit, + crossinline block: suspend ResponseStateBuilder.() -> Unit, ) { val path: String = operation.resource.routeTemplate + (operation.route ?: "") @@ -88,7 +88,6 @@ inline fun call.respond( NetworkResponse( - id = state.id ?: error("It's not possible to have a successful result without an ID: $state"), routes = emptyList(), //TODO in #22: advertise the endpoints value = data.value ) 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 b8a76e1..467c488 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 @@ -13,7 +13,7 @@ import opensavvy.spine.Parameters import opensavvy.spine.Route import opensavvy.spine.Route.Companion.div import opensavvy.spine.ktor.client.request -import opensavvy.state.emitSuccessful +import opensavvy.state.Slice.Companion.successful import opensavvy.state.ensureFound import opensavvy.state.firstResultOrThrow import org.junit.Test @@ -56,7 +56,7 @@ class ServerTest { val result = users .filter { parameters.includeArchived || !it.archived } .map { it.id } - emitSuccessful(api.users.get.idOf(), result) + emit(successful(result)) } route(api.users.create, context) { @@ -64,36 +64,36 @@ class ServerTest { val newId = api.users.id.idOf((nextId++).toString()) val new = User(newId, name, archived = false) users += new - emitSuccessful(new.id, new) + emit(successful(newId to new)) } route(api.users.id.get, context) { val user = users.find { it.id == id } - ensureFound(id, user != null) { "Could not find the user $id" } - emitSuccessful(id, user) + ensureFound(user != null) { "Could not find the user $id" } + emit(successful(user)) } route(api.users.id.archive, context) { val userIndex = users.indexOfFirst { it.id == id } - ensureFound(id, userIndex >= 0) { "Could not find user $id" } + ensureFound(userIndex >= 0) { "Could not find user $id" } val user = users.removeAt(userIndex) users.add(user.copy(archived = true)) - emitSuccessful(id, user) + emit(successful(user)) } route(api.users.id.unarchive, context) { val userIndex = users.indexOfFirst { it.id == id } - ensureFound(id, userIndex >= 0) { "Could not find user $id" } + ensureFound(userIndex >= 0) { "Could not find user $id" } val user = users.removeAt(userIndex) users.add(user.copy(archived = false)) - emitSuccessful(id, user) + emit(successful(user)) } route(api.users.id.delete, context) { val userIndex = users.indexOfFirst { it.id == id } - ensureFound(id, userIndex >= 0) { "Could not find user $id" } + ensureFound(userIndex >= 0) { "Could not find user $id" } val user = users.removeAt(userIndex) - emitSuccessful(id, user) + emit(successful(user)) } } @@ -135,15 +135,15 @@ class ServerTest { .onEach { log.debug(it) { "Received event for" } } .firstResultOrThrow() - assertEquals(User(Id("test", Route / "users" / "0"), "first", archived = false), first) - assertEquals(User(Id("test", Route / "users" / "1"), "second", archived = false), second) + 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() - assertEquals(listOf(first.id, second.id), results) + assertEquals(listOf(first.first, second.first), results) } //endregion diff --git a/spine-ktor/spine-ktor-server/src/commonTest/kotlin/opensavvy.spine.ktor.server/TestApi.kt b/spine-ktor/spine-ktor-server/src/commonTest/kotlin/opensavvy.spine.ktor.server/TestApi.kt index 67b3658..7955ec9 100644 --- a/spine-ktor/spine-ktor-server/src/commonTest/kotlin/opensavvy.spine.ktor.server/TestApi.kt +++ b/spine-ktor/spine-ktor-server/src/commonTest/kotlin/opensavvy.spine.ktor.server/TestApi.kt @@ -10,7 +10,7 @@ import opensavvy.spine.Service //region API objects declaration @Serializable -data class User(val id: Id, val name: String, val archived: Boolean) { +data class User(val id: Id, val name: String, val archived: Boolean) { init { checkUsername(name) @@ -40,7 +40,7 @@ data class User(val id: Id, val name: String, val archived: Boolean) { class TestApi : Service("test") { - inner class Users : StaticResource>, User.SearchParams, Unit>("users") { + inner class Users : StaticResource, User.SearchParams, Unit>("users") { inner class Unique : DynamicResource("user") { val archive = action(Route / "archive") diff --git a/spine-ktor/src/commonMain/kotlin/opensavvy.spine.ktor/NetworkResponse.kt b/spine-ktor/src/commonMain/kotlin/opensavvy.spine.ktor/NetworkResponse.kt index f757e48..706aa88 100644 --- a/spine-ktor/src/commonMain/kotlin/opensavvy.spine.ktor/NetworkResponse.kt +++ b/spine-ktor/src/commonMain/kotlin/opensavvy.spine.ktor/NetworkResponse.kt @@ -1,11 +1,9 @@ package opensavvy.spine.ktor import kotlinx.serialization.Serializable -import opensavvy.spine.Id @Serializable data class NetworkResponse( - val id: Id, val routes: List, val value: T, ) diff --git a/spine/src/commonMain/kotlin/opensavvy.spine/Id.kt b/spine/src/commonMain/kotlin/opensavvy.spine/Id.kt index 6e006bf..f28886b 100644 --- a/spine/src/commonMain/kotlin/opensavvy.spine/Id.kt +++ b/spine/src/commonMain/kotlin/opensavvy.spine/Id.kt @@ -4,22 +4,12 @@ import kotlinx.serialization.Serializable import opensavvy.state.Identifier @Serializable -data class Id( +data class Id( val service: Route.Segment, val resource: Route, -) : Identifier { +) : Identifier { constructor(service: String, resource: Route) : this(Route.Segment(service), resource) - /** - * Untyped [Id] with the same data as this instance. - * - * Operations that have no resulting data (i.e. only have side effects) are represented by returning [Unit]. - * However, it is still useful to return an ID to mark which object caused an error. - * This property is used to obtain an [Id] instance useful in methods that return no result. - */ - val unit: Id - get() = Id(service, resource) - override fun toString() = "$service/$resource" } diff --git a/spine/src/commonMain/kotlin/opensavvy.spine/Operation.kt b/spine/src/commonMain/kotlin/opensavvy.spine/Operation.kt index a221560..a6c6d8c 100644 --- a/spine/src/commonMain/kotlin/opensavvy.spine/Operation.kt +++ b/spine/src/commonMain/kotlin/opensavvy.spine/Operation.kt @@ -2,13 +2,13 @@ package opensavvy.spine import opensavvy.state.StateBuilder -typealias OperationValidator = suspend StateBuilder, Out>.(Id, In, parameters: Params, context: Context) -> Unit +typealias OperationValidator = suspend StateBuilder.(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 f7a571b..9ea2762 100644 --- a/spine/src/commonMain/kotlin/opensavvy.spine/Resource.kt +++ b/spine/src/commonMain/kotlin/opensavvy.spine/Resource.kt @@ -77,9 +77,8 @@ sealed class ResourceGroup { /** * Validates that [id] identifies this resource. */ - suspend fun StateBuilder, O>.validateCorrectId(id: Id) { + suspend fun StateBuilder.validateCorrectId(id: Id) { ensureValid( - id, id.service == service.name ) { "The passed identifier refers to the service '${id.service}', but this resource belongs to the service '${service.name}'" } @@ -89,7 +88,6 @@ sealed class ResourceGroup { while (resource is AbstractResource<*, *>) { val segment = id.resource.segments.getOrNull(index) ensureValid( - id, segment != null ) { "The passed identifier's URI length is too short for this resource: '$id' for resource '${this@AbstractResource}'" } @@ -97,7 +95,6 @@ sealed class ResourceGroup { when (val resource: AbstractResource<*, *> = resource) { is StaticResource<*, *, *> -> { ensureValid( - id, segment == resource.route ) { "The passed identifier's segment #$index doesn't match the resource; expected '${resource.route}' but found '$segment'" } } @@ -115,7 +112,6 @@ sealed class ResourceGroup { } ensureValid( - id, index == -1 ) { "The passed identifier's URI length is too long for this resource: '$id' for resource '${this@AbstractResource}'" } } @@ -126,18 +122,18 @@ 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, O>.validateId(id: Id, context: Context) {} + open suspend fun StateBuilder.validateId(id: Id, context: Context) {} protected fun create( route: Route? = null, - validate: OperationValidator = { _, _, _, _ -> }, + validate: OperationValidator, 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 -> + 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) @@ -145,19 +141,19 @@ sealed class ResourceGroup { protected fun action( route: Route, - validate: OperationValidator = { _, _, _, _ -> }, - ) = Operation(this, Operation.Kind.Action, route) { id: Id, it: In, params: Params, context -> + 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) } - protected fun delete(validate: OperationValidator = { _, _, _, _ -> }) = + protected fun delete(validate: OperationValidator = { _, _, _, _ -> }) = Operation( this, Operation.Kind.Delete, route = null - ) { id: Id, it: In, _: Parameters.Empty, context -> + ) { id: Id, it: In, _: Parameters.Empty, context -> validateCorrectId(id) validateId(id, context) validate(id, it, Parameters.Empty, context) @@ -176,7 +172,7 @@ sealed class ResourceGroup { * - for resource `/users/{user}`: `idOf("52f8")` generates the ID `/users/52f8` * - for resource `/users/{user}/{pet}`: `idOf("52f8", "a32b")` generates the ID `/users/52f8/a32b` */ - fun idOf(vararg dynamic: String): Id = idOf(dynamic.asSequence().map { Route.Segment(it) }.iterator()) + fun idOf(vararg dynamic: String): Id = idOf(dynamic.asSequence().map { Route.Segment(it) }.iterator()) /** * Instantiates an [Id] for this resource. @@ -186,7 +182,7 @@ sealed class ResourceGroup { * Implementations of this method should consume the number of dynamic elements they need from the [dynamic] iterator * and ignore any other value (calling this function with an iterator that has too many elements is correct). */ - abstract fun idOf(dynamic: Iterator): Id + abstract fun idOf(dynamic: Iterator): Id } /** @@ -212,11 +208,11 @@ sealed class ResourceGroup { * * You should override this function if the parameters impact the access rights. */ - open suspend fun StateBuilder, O>.validateGetParams(id: Id, params: GetParams, context: Context) {} + open suspend fun StateBuilder.validateGetParams(id: Id, params: GetParams, context: Context) {} @Suppress("LeakingThis") // Not dangerous because Operation's constructor does nothing val get = - Operation(this, Operation.Kind.Read) { id: Id, _, params, context -> + Operation(this, Operation.Kind.Read) { id: Id, _, params, context -> validateCorrectId(id) validateId(id, context) validateGetParams(id, params, context) @@ -227,7 +223,7 @@ sealed class ResourceGroup { final override val parent get() = this@ResourceGroup final override val service get() = this@ResourceGroup.service - final override fun idOf(dynamic: Iterator): Id { + final override fun idOf(dynamic: Iterator): Id { val parentId = when (val parent = parent) { is AbstractResource<*, *> -> parent.idOf(dynamic) is Service -> parent.idOf() @@ -267,7 +263,7 @@ sealed class ResourceGroup { final override val parent get() = this@ResourceGroup final override val service get() = this@ResourceGroup.service - final override fun idOf(dynamic: Iterator): Id { + final override fun idOf(dynamic: Iterator): Id { val parentId = when (val parent = parent) { is AbstractResource<*, *> -> parent.idOf(dynamic) is Service -> parent.idOf() diff --git a/spine/src/commonMain/kotlin/opensavvy.spine/Service.kt b/spine/src/commonMain/kotlin/opensavvy.spine/Service.kt index 335dc22..97311f4 100644 --- a/spine/src/commonMain/kotlin/opensavvy.spine/Service.kt +++ b/spine/src/commonMain/kotlin/opensavvy.spine/Service.kt @@ -53,6 +53,6 @@ abstract class Service( * Services are not resources, they cannot be queried. * However, it may be convenient to generate an ID for them. */ - fun idOf() = Id(name, Route.Root) + fun idOf() = Id(name, Route.Root) } diff --git a/spine/src/commonTest/kotlin/opensavvy.spine/IdTest.kt b/spine/src/commonTest/kotlin/opensavvy.spine/IdTest.kt index aef9845..a5e4ac0 100644 --- a/spine/src/commonTest/kotlin/opensavvy.spine/IdTest.kt +++ b/spine/src/commonTest/kotlin/opensavvy.spine/IdTest.kt @@ -8,7 +8,7 @@ class IdTest { @Test fun test() { - val id = Id(service = "v2", resource = Route / "int" / "35eb") + val id = Id(service = "v2", resource = Route / "int" / "35eb") assertEquals("v2/int/35eb", id.toString()) } diff --git a/spine/src/commonTest/kotlin/opensavvy.spine/ServiceTest.kt b/spine/src/commonTest/kotlin/opensavvy.spine/ServiceTest.kt index a75408a..145cbd3 100644 --- a/spine/src/commonTest/kotlin/opensavvy.spine/ServiceTest.kt +++ b/spine/src/commonTest/kotlin/opensavvy.spine/ServiceTest.kt @@ -10,12 +10,10 @@ import opensavvy.backbone.Backbone import opensavvy.backbone.Ref import opensavvy.backbone.Ref.Companion.request import opensavvy.backbone.Ref.Companion.requestValue -import opensavvy.backbone.RefState import opensavvy.backbone.defaultRefCache import opensavvy.spine.Route.Companion.div import opensavvy.state.* import opensavvy.state.Slice.Companion.failed -import opensavvy.state.Slice.Companion.mapIdentifier import opensavvy.state.Slice.Companion.successful import kotlin.test.Test import kotlin.test.assertEquals @@ -49,9 +47,9 @@ private data class User(val name: String, val admin: Boolean) { private class Context(val user: Ref) private class Api : Service("v2") { - inner class Departments : StaticResource>, Department.SearchParams, Context>("departments") { + inner class Departments : StaticResource, Department.SearchParams, Context>("departments") { inner class Unique : DynamicResource("department") { - inner class Users : StaticResource>, Parameters.Empty, Context>("users") + inner class Users : StaticResource, Parameters.Empty, Context>("users") val users = Users() } @@ -59,17 +57,16 @@ private class Api : Service("v2") { val id = Unique() } - inner class Users : StaticResource>, Parameters.Empty, Context>("users") { + inner class Users : StaticResource, Parameters.Empty, Context>("users") { inner class Unique : DynamicResource("user") { - inner class Departments : StaticResource>, Parameters.Empty, Context>("departments") + inner class Departments : StaticResource, Parameters.Empty, Context>("departments") val join = action(Route / "join") val leave = action(Route / "leave") - val rename = edit(Route / "name") { id, newName, _, _ -> + val rename = edit(Route / "name") { _, newName, _, _ -> ensureValid( - id.unit, newName.name.isNotBlank() ) { "A user's name may not be empty: '${newName.name}'" } } @@ -77,13 +74,10 @@ private class Api : Service("v2") { val departments = Departments() } - val create = create { id, it: User.New, _: Parameters.Empty, context: Context -> - ensureValid(id.unit, it.name.isNotBlank()) { "A user's name may not be empty: '${it.name}'" } - ensureValid( - id.unit, - it.name.length < 100 - ) { "A user's name may not be longer than 100 characters, found ${it.name.length} characters: '${it.name}'" } - ensureAuthorized(id = null, context.user.requestValue().admin) { "Only admins can create new users" } + 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" } } val id = Unique() @@ -123,13 +117,13 @@ class ServiceTest { users["1"] = User("Admin", admin = true) } - override fun directRequest(ref: Ref): RefState = flow { - ensureValid(ref, ref is Ref.Basic) { "The reference type ${ref::class} is not supported by UserBone" } + override fun directRequest(ref: Ref): State = state { + ensureValid(ref is Ref.Basic) { "The reference type ${ref::class} is not supported by UserBone" } val result = users[ref.id] - ensureFound(ref, result != null) { "No user has the ID $ref" } + ensureFound(result != null) { "No user has the ID $ref" } - emitSuccessful(ref, result) + emit(successful(result)) } } @@ -145,19 +139,18 @@ class ServiceTest { val id1 = endpoint.idOf("0") assertEquals( - successful(id1, User("Employee", false)), + successful(User("Employee", false)), state { endpoint.validate(this, id1, Unit, Parameters.Empty, employee) - emitAll(employee.user.request().mapIdentifier { endpoint.idOf((it as Ref.Basic).id) }) + emitAll(employee.user.request()) }.firstResult() ) // Scenario 2: access with an invalid service ID - val id2 = Id("this-is-not-the-correct-service-name", Route / "users" / "0") + val id2 = Id("this-is-not-the-correct-service-name", Route / "users" / "0") assertEquals( failed( - id2, 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'" ), @@ -168,10 +161,9 @@ class ServiceTest { // Scenario 3: access with an invalid ID (too short) - val id3 = Id("v2", Route / "users") // should be /users/0 + val id3 = Id("v2", Route / "users") // should be /users/0 assertEquals( failed( - id3, Status.StandardFailure.Kind.Invalid, "The passed identifier's URI length is too short for this resource: 'v2/users' for resource 'v2/users/{user}'" ), @@ -182,10 +174,9 @@ class ServiceTest { // Scenario 4: access with an invalid ID (wrong resource) - val id4 = Id("v2", Route / "departments" / "0") // should be /users/0 + val id4 = Id("v2", Route / "departments" / "0") // should be /users/0 assertEquals( failed( - id4, Status.StandardFailure.Kind.Invalid, "The passed identifier's segment #0 doesn't match the resource; expected 'users' but found 'departments'" ), @@ -196,10 +187,9 @@ class ServiceTest { // Scenario 5: access with an invalid ID (too long) - val id5 = Id("v2", Route / "departments" / "users" / "0") // should be /users/0 + val id5 = Id("v2", Route / "departments" / "users" / "0") // should be /users/0 assertEquals( failed( - id5, 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}'" ), diff --git a/state/src/commonMain/kotlin/opensavvy.state/Identifier.kt b/state/src/commonMain/kotlin/opensavvy.state/Identifier.kt index bb5e16f..af361da 100644 --- a/state/src/commonMain/kotlin/opensavvy.state/Identifier.kt +++ b/state/src/commonMain/kotlin/opensavvy.state/Identifier.kt @@ -1,7 +1,7 @@ package opensavvy.state /** - * Simple marker interface to identify an object of type [T]. + * Simple marker interface to identify an object. * * ### Contract * @@ -9,4 +9,4 @@ package opensavvy.state * - If two implementations of [Identifier] are the same, the object their refer to must be the same. * - Different implementations of [Identifier] are allowed to refer to the same object, however this is not recommended as it negatively affects caches. */ -interface Identifier<@Suppress("unused") out T> +interface Identifier diff --git a/state/src/commonMain/kotlin/opensavvy.state/Slice.kt b/state/src/commonMain/kotlin/opensavvy.state/Slice.kt index a128ac5..b5d9a11 100644 --- a/state/src/commonMain/kotlin/opensavvy.state/Slice.kt +++ b/state/src/commonMain/kotlin/opensavvy.state/Slice.kt @@ -1,6 +1,5 @@ package opensavvy.state -import kotlinx.coroutines.flow.map import opensavvy.state.Slice.Companion.failed import opensavvy.state.Slice.Companion.pending import opensavvy.state.Slice.Companion.successful @@ -13,50 +12,16 @@ import opensavvy.state.Slice.Companion.successful * * Instances can be generated with the provided factories: [pending], [successful] and [failed]. */ -class Slice, out T> private constructor( - val id: I?, +data class Slice( val status: Status, val progression: Progression, ) { - //region Destructuration - - operator fun component1() = status - operator fun component2() = progression - operator fun component3() = id - - //endregion - //region equals & hashCode - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is Slice<*, *>) return false - - if (id != other.id) return false - if (status != other.status) return false - if (progression != other.progression) return false - - return true - } - - override fun hashCode(): Int { - var result = id?.hashCode() ?: 0 - result = 31 * result + status.hashCode() - result = 31 * result + progression.hashCode() - return result - } - - //endregion //region toString override fun toString(): String { val builder = StringBuilder() - if (id != null) { - builder.append(id.toString()) - builder.append(": ") - } - if (status != Status.Pending) { builder.append(status) builder.append(" ") @@ -76,59 +41,52 @@ class Slice, out T> private constructor( /** * Creates a [Slice] with a [pending status][Status.Pending]. */ - fun , T> pending( - id: I?, + fun pending( progression: Progression.Loading, - ) = Slice(id, Status.Pending, progression) + ) = Slice(Status.Pending, progression) /** * Creates a [Slice] with a [pending status][Status.Pending] and a current progression. */ - fun , T> pending( - id: I?, - ) = Slice(id, Status.Pending, Progression.loading()) + fun pending() = Slice(Status.Pending, Progression.loading()) /** * Creates a [Slice] with a [pending status][Status.Pending] and a current [progression]. */ - fun , T> pending( - id: I?, + fun pending( progression: Double, - ) = Slice(id, Status.Pending, Progression.loading(progression)) + ) = Slice(Status.Pending, Progression.loading(progression)) /** * Creates a [Slice] with a [successful status][Status.Successful]. */ - fun , T> successful( - id: I, + fun successful( value: T, progression: Progression = Progression.Done, - ) = Slice(id, Status.Successful(value), progression) + ) = Slice(Status.Successful(value), progression) /** * Creates a [Slice] with a [failed status][Status.Failed]. * * More specifically, the status is a [Status.ExceptionFailure]. */ - fun , T> failed( - id: I?, + fun failed( exception: RuntimeException, message: String, progression: Progression = Progression.Done, - ) = Slice(id, Status.ExceptionFailure(message, exception), progression) + ) = Slice(Status.ExceptionFailure(message, exception), progression) /** * Creates a [Slice] with a [failed status][Status.Failed]. * * More specifically, the status is a [Status.StandardFailure]. */ - fun , T> failed( - id: I?, + fun failed( kind: Status.StandardFailure.Kind, message: String, cause: Throwable? = null, progression: Progression = Progression.Done, - ) = Slice(id, Status.StandardFailure(kind, message, cause), progression) + ) = Slice(Status.StandardFailure(kind, message, cause), progression) //endregion //region Accessors @@ -138,7 +96,7 @@ class Slice, out T> private constructor( * * @see Status.Successful.value */ - val , T : Any> Slice.valueOrNull: T? + val Slice.valueOrNull: T? get() = (status as? Status.Successful)?.value /** @@ -149,7 +107,7 @@ class Slice, out T> private constructor( * @throws NoSuchElementException This slice is in the [pending][Status.Pending] status. * @throws Status.Failed This slice is in the [failed][Status.Failed] status. */ - val , T> Slice.valueOrThrow: T + 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") @@ -157,15 +115,6 @@ class Slice, out T> private constructor( } //endregion - //region Operators - - /** - * Converts the identifier of the incoming state from [I] to [O] using [transform]. - */ - fun , O : Identifier, T> State.mapIdentifier(transform: (I) -> O) = - map { Slice(it.id?.let(transform), it.status, it.progression) } - - //endregion } } diff --git a/state/src/commonMain/kotlin/opensavvy.state/State.kt b/state/src/commonMain/kotlin/opensavvy.state/State.kt index 54bd1a7..691a629 100644 --- a/state/src/commonMain/kotlin/opensavvy.state/State.kt +++ b/state/src/commonMain/kotlin/opensavvy.state/State.kt @@ -15,12 +15,12 @@ import opensavvy.state.Slice.Companion.valueOrThrow * * To create an instance of [State], see the [state] builder. */ -typealias State = Flow> +typealias State = Flow> /** * Skips the loading events in [State]. */ -fun , T> State.skipLoading() = filter { it.progression !is Progression.Loading } +fun State.skipLoading() = filter { it.progression !is Progression.Loading } /** * Returns the first non-loading element of this [State]. @@ -28,7 +28,7 @@ fun , T> State.skipLoading() = filter { it.progression ! * 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 , T> State.firstResult() = skipLoading().first() +suspend fun State.firstResult() = skipLoading().first() /** * Returns the first non-loading element's [valueOrNull] of this [State]. @@ -36,7 +36,7 @@ suspend fun , T> State.firstResult() = skipLoading().fir * 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 , T : Any> State.firstResultOrNull() = firstResult().valueOrNull +suspend fun State.firstResultOrNull() = firstResult().valueOrNull /** * Returns the first non-loading element's [valueOrThrow] of this [State]. @@ -44,4 +44,4 @@ suspend fun , T : Any> State.firstResultOrNull() = first * 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 , T> State.firstResultOrThrow() = firstResult().valueOrThrow +suspend fun State.firstResultOrThrow() = firstResult().valueOrThrow diff --git a/state/src/commonMain/kotlin/opensavvy.state/StateBuilder.kt b/state/src/commonMain/kotlin/opensavvy.state/StateBuilder.kt index be246c4..8c349f6 100644 --- a/state/src/commonMain/kotlin/opensavvy.state/StateBuilder.kt +++ b/state/src/commonMain/kotlin/opensavvy.state/StateBuilder.kt @@ -5,8 +5,6 @@ import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flow import opensavvy.state.Slice.Companion.failed -import opensavvy.state.Slice.Companion.pending -import opensavvy.state.Slice.Companion.successful import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract @@ -16,7 +14,7 @@ import kotlin.contracts.contract * This type is used for conveniently building asynchronous flows in synchronous environments. * Instances are generally built using the [state] builder. */ -typealias StateBuilder = FlowCollector> +typealias StateBuilder = FlowCollector> /** * Builder for [State]. @@ -30,7 +28,7 @@ typealias StateBuilder = FlowCollector> * } * ``` */ -fun , T> state(block: suspend StateBuilder.() -> Unit) = flow(block) +fun state(block: suspend StateBuilder.() -> Unit) = flow(block) .catch { when (it) { // The user has requested the cancellation of the flow builder. @@ -42,21 +40,23 @@ fun , T> state(block: suspend StateBuilder.() -> Unit) = // 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 -> emitFailed( - id = null, - kind = it.kind, - message = it.message ?: "Caught a downstream error", - progression = Progression.done(), - cause = it.cause + is Status.StandardFailure -> emit( + failed( + it.kind, + it.message ?: "Caught a downstream error", + it.cause, + Progression.done() + ) ) // All other exceptions are caught into the Kind.Unknown standard failure. - else -> emitFailed( - id = null, - Status.StandardFailure.Kind.Unknown, - "Unknown error caught in the state builder", - progression = Progression.done(), - cause = it + else -> emit( + failed( + Status.StandardFailure.Kind.Unknown, + "Unknown error caught in the state builder", + it, + Progression.done() + ) ) } } @@ -66,72 +66,6 @@ fun , T> state(block: suspend StateBuilder.() -> Unit) = */ class StateBuilderCancellation : RuntimeException("The 'state' builder has been cancelled") -//region Pending markers - -/** - * Emits a [pending] slice with the given [progression]. - */ -suspend fun , T> StateBuilder.emitPending(id: I?, progression: Progression.Loading) { - emit(pending(id, progression)) -} - -/** - * Emits a [pending] slice with an [Unquantified][Progression.Loading.Unquantified] progression. - */ -suspend fun , T> StateBuilder.emitPending(id: I?) { - emitPending(id, Progression.loading()) -} - -/** - * Emits a [pending] slice with a [Quantified][Progression.Loading.Quantified] [progression]. - */ -suspend fun , T> StateBuilder.emitPending(id: I?, progression: Double) { - emitPending(id, Progression.loading(progression)) -} - -//endregion -//region Successful markers - -/** - * Emits a [successful] slice with the given [value]. - */ -suspend fun , T> StateBuilder.emitSuccessful( - id: I, - value: T, - progression: Progression = Progression.done(), -) { - emit(successful(id, value, progression)) -} - -//endregion -//region Failed markers - -/** - * Emits a [failed] slice with the given [exception] and [message]. - */ -suspend fun , T> StateBuilder.emitFailed( - id: I?, - exception: RuntimeException, - message: String, - progression: Progression, -) { - emit(failed(id, exception, message, progression)) -} - -/** - * Emits a [failed] slice with the given [kind] and [message]. - */ -suspend fun , T> StateBuilder.emitFailed( - id: I?, - kind: Status.StandardFailure.Kind, - message: String, - progression: Progression, - cause: Throwable? = null, -) { - emit(failed(id, kind, message, cause, progression)) -} - -//endregion //region Predicate checkers /** @@ -141,8 +75,7 @@ suspend fun , T> StateBuilder.emitFailed( * Otherwise, emits an [Invalid][Status.StandardFailure.Kind.Invalid] slice and cancels the [state] builder. */ @OptIn(ExperimentalContracts::class) -suspend inline fun , T> StateBuilder.ensureValid( - id: I?, +suspend inline fun StateBuilder.ensureValid( condition: Boolean, lazyMessage: () -> String, ) { @@ -151,7 +84,7 @@ suspend inline fun , T> StateBuilder.ensureValid( } if (!condition) { - emitFailed(id, Status.StandardFailure.Kind.Invalid, lazyMessage(), progression = Progression.done()) + emit(failed(Status.StandardFailure.Kind.Invalid, lazyMessage(), progression = Progression.done())) throw StateBuilderCancellation() } } @@ -163,8 +96,7 @@ suspend inline fun , T> StateBuilder.ensureValid( * Otherwise, emits an [Unauthenticated][Status.StandardFailure.Kind.Unauthenticated] slice and cancels the [state] builder. */ @OptIn(ExperimentalContracts::class) -suspend inline fun , T> StateBuilder.ensureAuthenticated( - id: I?, +suspend inline fun StateBuilder.ensureAuthenticated( condition: Boolean, lazyMessage: () -> String, ) { @@ -173,7 +105,7 @@ suspend inline fun , T> StateBuilder.ensureAuthenticated } if (!condition) { - emitFailed(id, Status.StandardFailure.Kind.Unauthenticated, lazyMessage(), progression = Progression.done()) + emit(failed(Status.StandardFailure.Kind.Unauthenticated, lazyMessage(), progression = Progression.done())) throw StateBuilderCancellation() } } @@ -185,8 +117,7 @@ suspend inline fun , T> StateBuilder.ensureAuthenticated * Otherwise, emits an [Unauthorized][Status.StandardFailure.Kind.Unauthorized] slice and cancels the [state] builder. */ @OptIn(ExperimentalContracts::class) -suspend inline fun , T> StateBuilder.ensureAuthorized( - id: I?, +suspend inline fun StateBuilder.ensureAuthorized( condition: Boolean, lazyMessage: () -> String, ) { @@ -195,7 +126,7 @@ suspend inline fun , T> StateBuilder.ensureAuthorized( } if (!condition) { - emitFailed(id, Status.StandardFailure.Kind.Unauthorized, lazyMessage(), progression = Progression.done()) + emit(failed(Status.StandardFailure.Kind.Unauthorized, lazyMessage(), progression = Progression.done())) throw StateBuilderCancellation() } } @@ -207,8 +138,7 @@ suspend inline fun , T> StateBuilder.ensureAuthorized( * Otherwise, emits an [NotFound][Status.StandardFailure.Kind.NotFound] slice and cancels the [state] builder. */ @OptIn(ExperimentalContracts::class) -suspend inline fun , T> StateBuilder.ensureFound( - id: I?, +suspend inline fun StateBuilder.ensureFound( condition: Boolean, lazyMessage: () -> String, ) { @@ -217,7 +147,7 @@ suspend inline fun , T> StateBuilder.ensureFound( } if (!condition) { - emitFailed(id, Status.StandardFailure.Kind.NotFound, lazyMessage(), progression = Progression.done()) + emit(failed(Status.StandardFailure.Kind.NotFound, lazyMessage(), progression = Progression.done())) throw StateBuilderCancellation() } } diff --git a/state/src/commonTest/kotlin/opensavvy.state/SliceTest.kt b/state/src/commonTest/kotlin/opensavvy.state/SliceTest.kt index 3ed8d6e..d07eaea 100644 --- a/state/src/commonTest/kotlin/opensavvy.state/SliceTest.kt +++ b/state/src/commonTest/kotlin/opensavvy.state/SliceTest.kt @@ -18,28 +18,28 @@ import kotlin.test.assertNotEquals class SliceTest { - private data class IntId(val id: Int) : Identifier { + private data class IntId(val id: Int) : Identifier { fun request() = state { val id = this@IntId - emitPending(id, 0.0) + emit(pending(0.0)) delay(20) // Just imagine the conditions are related to what we're testing - ensureValid(id, id.id > 0) { "The number should be greater than 0: ${id.id}" } - emitPending(id, 0.2) + ensureValid(id.id > 0) { "The number should be greater than 0: ${id.id}" } + emit(pending(0.2)) - ensureAuthenticated(id, id.id < 100) { "The number should be lesser than 100: ${id.id}" } - emitPending(id, 0.4) + ensureAuthenticated(id.id < 100) { "The number should be lesser than 100: ${id.id}" } + emit(pending(0.4)) - ensureAuthorized(id, id.id % 2 == 0) { "The number should be even: ${id.id}" } - emitPending(id, 0.6) + ensureAuthorized(id.id % 2 == 0) { "The number should be even: ${id.id}" } + emit(pending(0.6)) - ensureFound(id, id.id % 9 == 0) { "The number should be a multiple of 9: ${id.id}" } - emitPending(id, 0.8) + ensureFound(id.id % 9 == 0) { "The number should be a multiple of 9: ${id.id}" } + emit(pending(0.8)) - emitSuccessful(id, id.id) + emit(successful(id.id)) } override fun toString() = "Id($id)" @@ -55,12 +55,12 @@ class SliceTest { .toList() val expected = listOf( - pending(id, 0.0), - pending(id, 0.2), - pending(id, 0.4), - pending(id, 0.6), - pending(id, 0.8), - successful(id, 18), + pending(0.0), + pending(0.2), + pending(0.4), + pending(0.6), + pending(0.8), + successful(18), ) assertEquals(expected, actual) @@ -76,8 +76,8 @@ class SliceTest { .toList() val expected = listOf( - pending(id, 0.0), - failed(id, Status.StandardFailure.Kind.Invalid, "The number should be greater than 0: -50"), + pending(0.0), + failed(Status.StandardFailure.Kind.Invalid, "The number should be greater than 0: -50"), ) assertEquals(expected, actual) @@ -92,9 +92,9 @@ class SliceTest { .toList() val expected = listOf( - pending(id, 0.0), - pending(id, 0.2), - failed(id, Status.StandardFailure.Kind.Unauthenticated, "The number should be lesser than 100: 167"), + pending(0.0), + pending(0.2), + failed(Status.StandardFailure.Kind.Unauthenticated, "The number should be lesser than 100: 167"), ) assertEquals(expected, actual) @@ -109,10 +109,10 @@ class SliceTest { .toList() val expected = listOf( - pending(id, 0.0), - pending(id, 0.2), - pending(id, 0.4), - failed(id, Status.StandardFailure.Kind.Unauthorized, "The number should be even: 5"), + pending(0.0), + pending(0.2), + pending(0.4), + failed(Status.StandardFailure.Kind.Unauthorized, "The number should be even: 5"), ) assertEquals(expected, actual) @@ -127,64 +127,62 @@ class SliceTest { .toList() val expected = listOf( - pending(id, 0.0), - pending(id, 0.2), - pending(id, 0.4), - pending(id, 0.6), - failed(id, Status.StandardFailure.Kind.NotFound, "The number should be a multiple of 9: 8"), + 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) } - @Suppress("DestructuringWrongName") // false positive, see https://youtrack.jetbrains.com/issue/KT-54374 @Test fun destructuration() { - val (status, progression, id) = successful(IntId(0), 0) + val (status, progression) = successful(0) assertEquals(Status.Successful(0), status) assertEquals(Progression.done(), progression) - assertEquals(IntId(0), id) } @Test fun identity() { - val success0 = successful(IntId(0), 0) - val success1 = successful(IntId(1), 1) - assertEquals(successful(IntId(0), 0), success0) - assertEquals(successful(IntId(0), 0).hashCode(), success0.hashCode()) - assertEquals(successful(IntId(1), 1), success1) + val success0 = successful(0) + val success1 = successful(1) + assertEquals(successful(0), success0) + assertEquals(successful(0).hashCode(), success0.hashCode()) + assertEquals(successful(1), success1) assertNotEquals(success0, success1) - val pending0 = pending(IntId(0)) - val pending1 = pending(IntId(1), 0.5) - val pendingX = pending(id = null, 1.0 / 3) - assertEquals(pending(IntId(0)), pending0) - assertEquals(pending(IntId(1), 0.5), pending1) - assertEquals(pending(IntId(1), 0.5).hashCode(), pending1.hashCode()) - assertNotEquals(pending(IntId(1), 0.6), pending1) - assertEquals(pending(id = null, 1.0 / 3), pendingX) - - val failed0 = failed(IntId(0), Status.StandardFailure.Kind.Invalid, "error") - assertEquals(failed(IntId(0), Status.StandardFailure.Kind.Invalid, "error"), failed0) - assertNotEquals(failed(IntId(0), Status.StandardFailure.Kind.NotFound, "error"), failed0) - assertNotEquals(failed(IntId(0), Status.StandardFailure.Kind.Unauthenticated, "error"), failed0) - assertNotEquals(failed(IntId(0), Status.StandardFailure.Kind.Invalid, "other"), failed0) - - assertEquals("Id(0): 0 Done", success0.toString()) - assertEquals("Id(1): 1 Done", success1.toString()) - assertEquals("Id(0): Loading", pending0.toString()) - assertEquals("Id(1): Loading(50%)", pending1.toString()) + 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("Id(0): Invalid(error) Done", failed0.toString()) + assertEquals("Invalid(error) Done", failed0.toString()) } @Test fun value() { - val success = successful(IntId(0), 0) - val pending = pending(IntId(0)) - val failed1 = failed(IntId(0), IllegalArgumentException("whatever"), "whatever 2") - val failed2 = failed(IntId(0), Status.StandardFailure.Kind.Invalid, "whatever 3") + 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) diff --git a/state/src/commonTest/kotlin/opensavvy.state/StateBuilderTest.kt b/state/src/commonTest/kotlin/opensavvy.state/StateBuilderTest.kt index 6cee65e..2ef243d 100644 --- a/state/src/commonTest/kotlin/opensavvy.state/StateBuilderTest.kt +++ b/state/src/commonTest/kotlin/opensavvy.state/StateBuilderTest.kt @@ -11,52 +11,47 @@ import kotlin.test.assertEquals class StateBuilderTest { - private data class IntId(val id: Int) : Identifier - @Test fun success() = runTest { val data = state { - emitSuccessful(IntId(0), 0) + emit(successful(0)) } - assertEquals(successful(IntId(0), 0), data.firstResult()) + assertEquals(successful(0), data.firstResult()) } @Test fun ensure() = runTest { - val data = state { - ensureValid(IntId(0), condition = false) { "this is always invalid" } + val data = state { + ensureValid(false) { "this is always invalid" } } assertEquals( - failed(IntId(0), Status.StandardFailure.Kind.Invalid, "this is always invalid"), + failed(Status.StandardFailure.Kind.Invalid, "this is always invalid"), data.firstResult() ) } @Test fun throwStandard() = runTest { - val data = state { + val data = state { throw Status.StandardFailure(Status.StandardFailure.Kind.Invalid, "this is always invalid") - @Suppress("UNREACHABLE_CODE") emitSuccessful(IntId(0), 0) } assertEquals( - failed(id = null, Status.StandardFailure.Kind.Invalid, "this is always invalid"), + failed(Status.StandardFailure.Kind.Invalid, "this is always invalid"), data.firstResult() ) } @Test fun throwOther() = runTest { - val data = state { + val data = state { error("some error") - @Suppress("UNREACHABLE_CODE") emitSuccessful(IntId(0), 0) } assertEquals( failed( - id = null, Status.StandardFailure.Kind.Unknown, "Unknown error caught in the state builder" ), data.firstResult()