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)
}
}
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)