0

I've inherited this codebase which uses RxJava2 and kotlin with a rather peculiar Result pattern for API calls. i.e. all API calls return Singles with a Result object (which is a sealed class of Success and Error types as shown below) i.e.

sealed class Result<T, E> {
    data class Success<T, E>(
            val data: T
    ): Result<T, E>()

    data class Error<T, E>(
            val error: E
    ): Result<T, E>()
}

Now I'm trying to chain together a bunch of API calls of these but need to terminate the chain on the first Result.Error in it and continue if not.

The only way I can think of is to zip all of the Singles and then have a zipper function that checks the type of each parameter and returns a Result.Error() with the first error it encounters. i.e. something like,

Singles.zip(
    repo1.makeCall1(arg),
    repo1.makeCall2(arg2),
    repo2.makeCall1(arg3)
) { result1, result2, result3 ->
    val data1 = when (result1) {
        is Result.Error -> return@zip Result.Error(result1.error)
        is Result.Success -> result1.data
    }
    val data2 = when (result2) {
        is Result.Error -> return@zip Result.Error(result2.error)
        is Result.Success -> result2.data
    }
    val data3 = when (result3) {
        is Result.Error -> return@zip Result.Error(result3.error)
        is Result.Success -> result3.data
    }

    return@zip Result.Success(MergedData(data1, data2, data3))
}

which works but looks really weird (and feels like a code smell with this huge ass zipper method). Also does not allow me to chain anything more after the last method (that checks if the Result is a Success / Error).

I feel it would be a lot more readable to be able to chain these calls and terminate on the first error but I don't know enough Rx to do this. Is there an operator or an approach that could help make this better?

EpicPandaForce
  • 79,669
  • 27
  • 256
  • 428
Bootstrapper
  • 1,089
  • 3
  • 14
  • 33

3 Answers3

1

You can get original Single behavior by reversing what your codebase already does.

Create transformer which will extract data from api call or throw error on error. First error will terminate zip.

public <T, E extends Throwable> SingleTransformer<Result<T, E>, T> transform() {
    return source -> source.flatMap(result -> {
        if (result instanceof Result.Success) 
            return Single.just(((Success<T, E>) result).getData());
          else
            return Single.error(((Error<T, E>) result).getError());
    });
}

Use it with repo.makeCall(arg).compose(transform())

Hope it helps.

Tuby
  • 3,158
  • 2
  • 17
  • 36
  • Yep, this is the most rx-like way to handle this; we've been using a similar pattern for some specific retrofit calls that return results. – Tassos Bassoukos Jan 31 '19 at 06:01
1

Out of the box, RxJava would "abort on the first error" because Observable and Single (which is akin to Task/Future/Promise) has "monadic qualities". But as Result<*, *> explicitly makes errors be handled on the "success" path to avoid aborting the stream, we might want to consider a different route than letting Rx go to terminal events - because the existing code expects it to be on the success path. Terminal events should be for "the world is ending" exceptions, not ones we actually expect and can handle.


I had some ideas but I think the only thing you can do is reduce the number of lines it takes to do this instead of flat-out removing it.

Technically we are trying to re-implement the Either<E, T> monad here from Arrow, but knowing that, we can reduce the number of lines with some tricks:

sealed class Result<T, E>(
    open val error: E? = null,
    open val data: T? = null
) {
    data class Success<T>(
        override val data: T
    ): Result<T, Nothing?>()

    data class Error<E>(
        override val error: E
    ): Result<Nothing?, E>()
}

fun <E> E.wrapWithError(): Result.Error<E> = Result.Error(this) // similar to `Either.asLeft()`
fun <T> T.wrapWithSuccess(): Result.Success<T> = Result.Success(this)  // similar to `Either.asRight()`

fun blah() {
    Singles.zip(
        repo1.makeCall1(arg),
        repo1.makeCall2(arg2),
        repo2.makeCall1(arg3)
    ) { result1, result2, result3 ->
        val data1 = result1.data ?: return@zip result1.error.wrapWithError()
        val data2 = result2.data ?: return@zip result2.error.wrapWithError()
        val data3 = result3.data ?: return@zip result3.error.wrapWithError()

        Result.Success(MergedData(data1, data2, data3))
    }
}
EpicPandaForce
  • 79,669
  • 27
  • 256
  • 428
  • would not merge work better here, OP wants to terminate on first error? – Mark Jan 26 '19 at 14:47
  • 1
    Merge would run on each completion, he wants to complete only after all 3 are done. Although now that you mention it, this means that `call2` and `call3` could happen even if `call1` failed... maybe this should really be a `flatMap` chain, basically what [Rx comprehensions](https://github.com/pakoito/Komprehensions/blob/8c258854644e76df0c34ec2ebfba7c188b56ba9a/komprehensions-rx2/src/main/kotlin/com/pacoworks/komprehensions/rx2/KomprehensionsRx.kt#L59) do. I was so focused on codegolfing the `when` statement that I didn't think about it. ¬¬ keeping the errors on the success path is tricky. – EpicPandaForce Jan 26 '19 at 14:48
  • I'd also like to be able to do more stuff with the data. For e.g. use a value from data2 to then decide if we need to make another couple API calls, which currently means all of this will also have to be inside the zipper function :( – Bootstrapper Jan 26 '19 at 14:59
  • Actually, my comment here about using tuples was wrong. This is *very* tricky. – EpicPandaForce Jan 26 '19 at 15:04
  • sorry about that (the example above was meant to be illustrative - there could be several more of these calls in some cases 6 or more) – Bootstrapper Jan 26 '19 at 15:04
  • Ah. In that case I'm not well-versed enough at this particular moment. All I know is that you're trying to solve the problem of flattening a `Future, Either, ..., Either>>` into a `Future>>`. I wonder how you can do that, I don't remember. >.< apparently [this is how you do it in Scala](https://stackoverflow.com/a/6490882/2413303), but that's not really helping – EpicPandaForce Jan 26 '19 at 15:07
  • Maybe you just need an extension function that does this whole `error vs success` dance for 1 to N arity, and tuples classes from 1 to N arity, and then you'd have an `Either>` to work with, on which you can `fold` or `mapRight` and stuff. I dunno I am getting confused because I'm not that well-versed in functional style. – EpicPandaForce Jan 26 '19 at 15:33
0

What do you think about this code block:

Single.zip(
        Single.just(Result.Error(error = 9)),
        Single.just(Result.Success(data = 10)),
        Single.just(Result.Success(data = 11)),
        Function3<Result<Int, Int>, Result<Int, Int>, Result<Int, Int>, List<Result<Int, Int>>> { t1, t2, t3 ->
            mutableListOf(t1, t2, t3)
        })
        .map { list ->
            list.forEach {
                if (it is Result.Error){
                    return@map it
                }
            }
            return@map Result
        } // or do more chain here.
        .subscribe()

I combine the results into a list, then map it to your expected result. It's much easier to read.

Kingfisher Phuoc
  • 8,052
  • 9
  • 46
  • 86
  • that does make the check for errors at the start however we'd still need to do it again to get the actual values I think? – Bootstrapper Jan 26 '19 at 17:11
  • @Bootstrapper I do not quite understand your argument. You can just return@map `Result.Success(MergedData(data1, data2, data3)`. The readableness here is much important than small optimization. – Kingfisher Phuoc Jan 26 '19 at 17:46
  • could you please share how you would get the data1, data2, data3 values? – Bootstrapper Jan 27 '19 at 09:27