Search code examples
arraysswiftcombine

Swift combine - return Future conditionally


I have a function with return type Future<Void, Error> that returns and saves items from an API

What I'm hoping to do is check whether the items array returned is empty and if so make a call to another service endpoint to receive / save items

Obviously the second call needs to happen conditionally and after the first has completed

I've found examples using flatMap that seem very close to what I want but these use AnyPublisher rather than Future and trying with Futures seems to yield me strange return types

You can probably tell I only know the fundamentals of combine! If it was simple I would convert these calls to async await and make a conditional call to the alternative endpoint but combine is used throughout the network and API layers and if possible hope to do it with the established combine pattern

If anyone has a skeleton example of returning conditional futures from a function I'd be very grateful (example of current function below)

Thanks!

    func sync() -> Future<Void, Error> {
    return Future { [weak self] promise in
        guard let strongSelf = self else { return }
        strongSelf.service.getItems
            .receive(on: strongSelf.scheduler)
            .sink {
                if case .failure(let error) = $0 {
                    promise(.failure(error))
                }
            } receiveValue: { items in
                 // if items empty make another api call
                strongSelf.saveItems(items)
               
                promise(.success(()))
            }.store(in: &strongSelf.subscriptions)
    }
}

Solution

  • One of the challenges here is the fact that subscribing to a publisher is a synchronous operation. Your Future, as written, is going to execute from top to bottom and end. The subscription you create with sink is going to fall out of scope immediately, and be cancelled.

    One sneaky way to get around this is to capture the subscription in the closure and dispose of it manually:

    func sync(service: Service) -> Future<[Int], Error> {
        Future<[Int],Error> { contination in
            var subscription: AnyCancellable?
            subscription = service.getItems()
                .flatMap { items in
                    if items.isEmpty {
                        return service.generateItems().eraseToAnyPublisher()
                    } else {
                        return Just(items).setFailureType(to: Error.self).eraseToAnyPublisher()
                    }
                }.sink {
                    if case .failure(let error) = $0 {
                        contination(.failure(error))
                    }
                    subscription?.cancel()
                } receiveValue: {
                    contination(.success($0))
                    subscription?.cancel()
                }
    
        }
    }
    

    Here the subscription is captured in the sink's closure as an AnyCancellable? variable. This prevents it from being released (and cancelled) when the future's closure ends. The subscription must be cancelled, in the sink, in every success and failure case.

    I assumed that your API calls, like service.getItems(), return a Future. The flatMap converts the results that call into a new publisher. If the getItems() call returns an empty array, then the publisher created by flatMap() is the one returned by generateItems() (another API call I invented). If the getItems call is not empty, then I use Just to provide a publisher that simply returns the items (with some decoration of operators to make the data types line up).

    For this example, I made up a service that looks like this:

    class Service {
        var shouldReturnItems: Bool;
    
        init(shouldReturnItems: Bool) {
            self.shouldReturnItems = shouldReturnItems
        }
    
        func getItems() ->Future<[Int], Error> {
            Future<[Int], Error> { continuation in
                DispatchQueue.main.asyncAfter(deadline: .now().advanced(by: .seconds(1))) {
                    if self.shouldReturnItems {
                        continuation(.success([1, 2, 3, 4]))
                    } else {
                        continuation(.success([]))
                    }
                }
            }
        }
    
        func generateItems() -> Future<[Int], Error> {
            Future<[Int], Error> { continuation in
                DispatchQueue.main.asyncAfter(deadline: .now().advanced(by: .seconds(1))) {
                    continuation(.success([5, 6, 7, 8]))
                }
            }
        }
    }
    

    You can also solve this problem using Swift Structured Concurrency. You don't have to "convert" any of the calls as Combine does that already. You can still preserve the Future based interface for callers that don't want to use async/await:

    func sync(service: Service) -> Future<[Int], Error> {
        Future<[Int],Error> { contination in
            Task {
                do {
                    var items = try await service.getItems().value
                    if items.isEmpty {
                        items = try await service.generateItems().value
                    }
    
                    contination(.success(items))
                } catch (let error) {
                    contination(.failure(error))
                }
            }
        }
    }
    

    I find this much more readable than the version using the subscription in the closure. To the outside world, sync uses the same Futures that are used in the service's API.

    I did all this in a Playground and tested it with this code:

    
    Task {
        do {
            let items = try await sync(service: Service(shouldReturnItems: true)).value
            print("service 1 returned \(items)")
        } catch {
            print("service 1 returned error")
        }
    }
    
    Task {
        do {
            let items = try await sync(service: Service(shouldReturnItems: false)).value
            print("service 2 returned \(items)")
        } catch {
            print("service 2 returned error")
        }
    }
    

    For iOS 14 where the async value property of a future is not available you might be able to use a variation on the themes above:

    
    extension Future {
        func myValue() async throws -> Output  {
            return try await withUnsafeThrowingContinuation({ continuation in
                var subscription: AnyCancellable?
    
                subscription = self.sink {
                    subscription?.cancel()
                    subscription = nil;
                    if case .failure(let error) = $0 {
                        continuation.resume(throwing: error)
                    }
                } receiveValue: {
                    subscription?.cancel()
                    subscription = nil;
                    continuation.resume(returning: $0)
                }
            })
        }
    }
    

    and at the call site:

    if #unavailable(iOS 15) {
        await someFuture.myValue()
    } else {
        await someFuture.value
    }
    

    (perhaps giving myValue a nicer name)