8

How to convert URLSession.DataTaskPublisher to Future in Combine framework. In my opinion, the Future publisher is more appropriate here because the call can emit only one response and fails eventually.

In RxSwift there is helper method like asSingle.

I have achieved this transformation using the following approach but have no idea if this is the best method.

        return Future<ResponseType, Error>.init { (observer) in
        self.urlSession.dataTaskPublisher(for: urlRequest)
            .tryMap { (object) -> Data in
            //......
            }
            .receive(on: RunLoop.main)
            .sink(receiveCompletion: { (completion) in
                if case let .failure(error) = completion {
                    observer(.failure(error))
                }
            }) { (response) in
                observer(.success(response))
            }.store(in: &self.cancellable)
    }
}

Is there any easy way to do this?

mkowal87
  • 596
  • 4
  • 19
  • 1
    `URLSession.DataTaskPublisher` also guarantees that it can only emit 1 value or fail, so I really don't see any reason why converting it into a `Future` would yield any real benefits. – Dávid Pásztor Feb 27 '20 at 14:27
  • 1
    This question is **not** “opinion-based”. Whether this idea is good is opinion-based, but the question is not asking whether it's a good idea. It's asking how to write the code. – rob mayoff Feb 27 '20 at 16:52
  • @robmayoff I didn't vote to close, but no it isn't. It _presents_ some code and asks it it is "best" and for an "easy" way. Those are both flags of opinion-based-ness. – matt Feb 28 '20 at 02:13
  • “In RxSwift there is helper method like `asSingle`?” is a question of fact. “Is there any easy way to do this?” is opinion based but I think what counts as easy is something we can find widespread agreement on. Shrug. – rob mayoff Feb 28 '20 at 02:25

3 Answers3

19

As I understand it, the reason to use .asSingle in RxSwift is that, when you subscribe, your subscriber receives a SingleEvent which is either a .success(value) or a .error(error). So your subscriber doesn't have to worry about receiving a .completion type of event, because there isn't one.

There is no equivalent to that in Combine. In Combine, from the subscriber's point of view, Future is just another sort of Publisher which can emit output values and a .finished or a .failure(error). The type system doesn't enforce the fact that a Future never emits a .finished.

Because of this, there's no programmatic reason to return a Future specifically. You could argue that returning a Future documents your intent to always return either exactly one output, or a failure. But it doesn't change the way you write your subscriber.

Furthermore, because of Combine's heavy use of generics, as soon as you want to apply any operator to a Future, you don't have a future anymore. If you apply map to some Future<V, E>, you get a Map<Future<V, E>, V2>, and similar for every other operator. The types quickly get out of hand and obscure the fact that there's a Future at the bottom.

If you really want to, you can implement your own operator to convert any Publisher to a Future. But you'll have to decide what to do if the upstream emits .finished, since a Future cannot emit .finished.

extension Publisher {
    func asFuture() -> Future<Output, Failure> {
        return Future { promise in
            var ticket: AnyCancellable? = nil
            ticket = self.sink(
                receiveCompletion: {
                    ticket?.cancel()
                    ticket = nil
                    switch $0 {
                    case .failure(let error):
                        promise(.failure(error))
                    case .finished:
                        // WHAT DO WE DO HERE???
                        fatalError()
                    }
            },
                receiveValue: {
                    ticket?.cancel()
                    ticket = nil
                    promise(.success($0))
            })
        }
    }
}
rob mayoff
  • 375,296
  • 67
  • 796
  • 848
  • 1
    "there's no programmatic reason to return a Future specifically" <- this is true but there _is_ a reason to wrap a publisher in a Future regardless of whether you keep Future as the actual return type: it makes the publisher eager and allows you to make use of Future's caching and replaying mechanism. So if you want to initiate a data task immediately so that the result is cached and can be used as soon as possible once you subscribe, or used by two subscribers without performing the request twice, something like this `asFuture` operator can be quite useful (and imo should be built in). – jjoelson May 28 '20 at 16:18
  • 2
    Regarding "WHAT DO WE DO HERE", I think it's worth noting that this is no different from Futures generally. Calling `asFuture` on a publisher that never sends a value or error is a programmer error in the same way that creating a Future with a closure that never calls `promise` is a programmer error. – jjoelson May 28 '20 at 16:25
  • 3
    A `Future` can emit `.finished` if the promise was fulfilled. I think in your code, it doesn't emit `.finished` because you cancel the chain when receiving a value. This also does not work well if the downstream chain gets canceled. Since the upstream chain is sort of retained because of the inner sink, it still continues to do the upper chain even though the downstream chain has been cancelled already. – Dj S Jun 14 '20 at 09:24
3

Instead of converting a data task publisher into a Future, convert a data task into a Future. Just wrap a Future around a call to a URLSession's dataTask(...){...}.resume() and the problem is solved. This is exactly what a future is for: to turn any asynchronous operation into a publisher.

matt
  • 515,959
  • 87
  • 875
  • 1,141
-1

How to return a future from a function

Instead of trying to return a 'future' from a function you need to convert your existing publisher to a AnyPublisher<Value, Error>. You do this by using the .eraseToAnyPublisher() operator.

func getUser() -> AnyPublisher<User, Error> {
    URLSession.shared.dataTaskPublisher(for: request)
        .tryMap { output -> Data in
            // handle response
            return output.data
        }
        .decode(type: User.self, decoder: JSONDecoder())
        .mapError { error in
            // handle error
            return error
        }
        .eraseToAnyPublisher()
}
Andrew Morris
  • 254
  • 4
  • 11