Swift Combine. How to transform publisher values - swift

I need to download files using provided links from the backend. To download files the asynchronous API is used that returns Progress() object. The problem is that FlatMap cannot map from Publisher<[Link], Error> to Publisher<[File], Error>. Another problem I want to solve is getting rid of cancellable and somehow transform Progress info filePath once the Progress.fractionCompleted is equal to 1.0.
For now I have tried to use map function. See the code form the Playground:
import UIKit
import Combine
var progress = Progress()
extension ProgressUserInfoKey {
public static var destinationURL = ProgressUserInfoKey("destinationURL")
}
func download(from urlRequest: URLRequest, to destinationURL: URL) -> AnyPublisher<Progress, Error> {
return Future<Progress, Error> { promise in
progress = Progress(totalUnitCount: 1)
progress.setUserInfoObject(destinationURL.absoluteString,
forKey: ProgressUserInfoKey.destinationURL)
promise(.success(progress))
// Simulate async API
DispatchQueue.main.async {
progress.completedUnitCount = 1
}
}.eraseToAnyPublisher()
}
struct Link: Decodable {
let url: String
}
func getLinks() -> AnyPublisher<[Link], Error> {
return URLSession.shared.dataTaskPublisher(for: URL(string: "https://backend.com")!)
.map { $0.data }
.decode(type: [Link].self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
struct File {
let url: URL
let size: UInt32
}
private func destinationUrl(_ fromUrl: String?) -> URL {
guard let path = fromUrl else {
return URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
}
return URL(fileURLWithPath: path)
}
/// 2) How to get rid of this state and transoft Progress into filePath directly (using matp(transform: ? )
var cancellableSet = Set<AnyCancellable>()
func getFiles() -> AnyPublisher<[File], Error> {
getLinks()
.flatMap { (links) -> AnyPublisher<[File], Error> in
let sequence = Sequence<[AnyPublisher<File, Error>], Error>(sequence: links.map {
download(from: URLRequest(url: $0), to: destinationUrl(UUID().uuidString))
.sink { progress in
progress.publisher(for: \.fractionCompleted).sink { progressValue in
if progressValue == 1.0 {
let filePath: String = progress.userInfo[ProgressUserInfoKey.destinationURL] as? String ?? ""
/// 1) How to return publisher here
///return Publisher(File(url: URL(string: filePath)!, size: 0))
}
}
.store(in: &cancellableSet)
}
.store(in: &cancellableSet)
} )
return sequence.flatMap { $0 }.collect().eraseToAnyPublisher()
}
}
I am expecting that the code compiles successfully and the function getFiles returns AnyPublisher<[File], Error>.
Currently the error code am getting is the following:
Cannot convert return expression of type 'Publishers.FlatMap<AnyPublisher<[File], Error>, AnyPublisher<[Link], Error>>' to return type 'AnyPublisher<[File], Error>'

Problem has been solved by using Publishers.Sequence() extension and avoiding to call a function sink(). Also I used the flatMap() to transform the Output value. The code that demonstrate this way might be found here: Combine publishers together

Related

Repeat Network Request Multiple Times With Swift Combine

I'm trying to have a combine pipeline that fetches five random photo urls using the Unsplash API, then downloads a photo from each url. The issue I’m running into is that the images are mostly the same. Sometimes (and I don't know why) one of the images will be different than the other four.
Any help would be appreciated. I've included all the necessary code besides the UnSplash Api key.
static func fetchRandomPhotos() -> AnyPublisher<UIImage, Error>{
let string = createURL(path: "/photos/random")
return [1,2,3,4,5]
.publisher
.flatMap{ i -> AnyPublisher<UnsplashImageResults, Error> in
return self.downloadAndDecode(string, type: UnsplashImageResults.self)
}
.flatMap{ result -> AnyPublisher<UIImage, Error> in
print(result.urls.thumb)
let url = URL(string: result.urls.thumb)!
return URLSession.shared.dataTaskPublisher(for: url)
.map { UIImage(data: $0.data)! }
.mapError{_ in NetworkError.invalidURL}
.eraseToAnyPublisher()
}
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
struct UnsplashURLs: Decodable{
let full: String
let regular: String
let small: String
let thumb: String
}
struct UnsplashImageResults: Decodable{
let urls: UnsplashURLs
}
enum NetworkError: LocalizedError{
case invalidURL
var errorDescription: String? {
switch self {
case .invalidURL:
return "The url is invalid"
}
}
}
static private func createURL(path: String)-> String{
var components = URLComponents()
components.scheme = "https"
components.host = "api.unsplash.com"
components.path = path
components.queryItems = [
URLQueryItem(name: "client_id", value: "YOUR API KEY HERE")
]
return components.string!
}
static private func downloadAndDecode<T:Decodable>(_ urlString: String, type: T.Type) -> AnyPublisher<T, Error>{
guard let url = URL(string: urlString) else{
return Fail(error: NetworkError.invalidURL).eraseToAnyPublisher()
}
return URLSession.shared.dataTaskPublisher(for: url)
.tryMap() { element -> Data in
guard let httpResponse = element.response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw URLError(.badServerResponse)
}
return element.data
}
.decode(type: T.self, decoder: JSONDecoder())
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
class UnsplashImagesViewModel: ObservableObject{
var subscriptions = Set<AnyCancellable>()
#Published var images = [UIImage]()
init(){
UnsplashAPI.fetchRandomPhotos()
.sink { (_) in
} receiveValue: { image in
self.images.append(image)
}.store(in: &subscriptions)
}
}
struct UnsplashImagesGrid: View {
#StateObject private var model = UnsplashImagesViewModel()
var body: some View {
List(model.images, id: \.self){ image in
Image(uiImage: image)
}
}
}
Unsplash returns the same image, if you send multiple requests at the same time. You can test this behaviour by introducing a delay between your requests.
Anyway, you should be using the count param of Unsplash random API to request a particular number of images. I have made some changes to your code, to receive an array of UIImage. I hope this gives you an idea about, how it can improved further.
static func fetchRandomPhotos() -> AnyPublisher<[UIImage], Error>{
let string = createURL(path: "/photos/random")
return Just(())
.print()
.flatMap{ i -> AnyPublisher<[UnsplashImageResults], Error> in
return self.downloadAndDecode(string, type: UnsplashImageResults.self)
}
.flatMap{ results -> AnyPublisher<[UIImage], Error> in
let images = results.map { result -> AnyPublisher<UIImage, Error> in
let url = URL(string: result.urls.thumb)!
return URLSession.shared.dataTaskPublisher(for: url)
.map { UIImage(data: $0.data)! }
.mapError{_ in NetworkError.invalidURL}
.eraseToAnyPublisher()
}
return Publishers.MergeMany(images)
.collect()
.eraseToAnyPublisher()
}
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
static private func createURL(path: String)-> String{
...
components.queryItems = [
URLQueryItem(name: "client_id", value: "ID"),
URLQueryItem(name: "count", value: "5")
]
...
}
static private func downloadAndDecode<T:Decodable>(_ urlString: String, type: T.Type) -> AnyPublisher<[T], Error>{
...
.decode(type: [T].self, decoder: JSONDecoder())
...
}

Combine SwiftUI Remote Fetch Data - ObjectBinding doesn't update view

I am trying to learn Combine and it is a PITA for me. I never learned RX Swift, so this is all new to me. I am sure I am missing something simple with this one, but hoping for some help.
I am trying to fetch some JSON from an API and load it in a List view. I have a view model that conforms to ObservableObject and updates a #Published property which is an array. I use that VM to load my list, but it looks like the view loads way before this API returns (List showing up blank). I was hoping these property wrappers would do what I thought they were supposed to and re-render the view whenever the object changed.
Like I said, I am sure I am missing something simple. If you can spot it, I would love the help. Thanks!
class PhotosViewModel: ObservableObject {
var cancellable: AnyCancellable?
#Published var photos = Photos()
func load(user collection: String) {
guard let url = URL(string: "https://api.unsplash.com/users/\(collection)/collections?client_id=\(Keys.unsplashAPIKey)") else {
return
}
cancellable = URLSession.shared.dataTaskPublisher(for: url)
.map { $0.data }
.decode(type: Photos.self, decoder: JSONDecoder())
.replaceError(with: defaultPhotosObject)
.receive(on: RunLoop.main)
.assign(to: \.photos, on: self)
}
}
struct PhotoListView: View {
#EnvironmentObject var photosViewModel: PhotosViewModel
var body: some View {
NavigationView {
List(photosViewModel.photos) { photo in
NavigationLink(destination: PhotoDetailView(photo)) {
PhotoRow(photo)
}
}.navigationBarTitle("Photos")
}
}
}
struct PhotoRow: View {
var photo: Photo
init(_ photo: Photo) {
self.photo = photo
}
var body: some View {
HStack {
ThumbnailImageLoadingView(photo.coverPhoto.urls.thumb)
VStack(alignment: .leading) {
Text(photo.title)
.font(.headline)
Text(photo.user.firstName)
.font(.body)
}
.padding(.leading, 5)
}
.padding(5)
}
}
Based on your updated solution, here are some improvement suggestions (that wont fit in a comment).
PhotosViewModel improvement suggestions
Might I just make the recommendation of changing your load function from returning Void (i.e. returning nothing), to be returning AnyPublisher<Photos, Never> and skipping the last step .assign(to:on:).
One advantage of this is that your code takes one step toward being testable.
Instead of replaceError with some default value you can use catch together with Empty(completeImmediately: <TRUE/FALSE>). Because is it always possible to come up with any relevant default value? Maybe in this case? Maybe "empty photos"? If so then you can either make Photos conform to ExpressibleByArrayLiteral and use replaceError(with: []) or you can create a static var named empty, allowing for replaceError(with: .empty).
To sum up my suggestions in a code block:
public class PhotosViewModel: ObservableObject {
#Published var photos = Photos()
// var cancellable: AnyCancellable? -> change to Set<AnyCancellable>
private var cancellables = Set<AnyCancellable>()
private let urlSession: URLSession
public init(urlSession: URLSession = .init()) {
self.urlSession = urlSession
}
}
private extension PhotosViewModel {}
func populatePhotoCollection(named nameOfPhotoCollection: String) {
fetchPhotoCollection(named: nameOfPhotoCollection)
.assign(to: \.photos, on: self)
.store(in: &cancellables)
}
func fetchPhotoCollection(named nameOfPhotoCollection: String) -> AnyPublisher<Photos, Never> {
func emptyPublisher(completeImmediately: Bool = true) -> AnyPublisher<Photos, Never> {
Empty<Photos, Never>(completeImmediately: completeImmediately).eraseToAnyPublisher()
}
// This really ought to be moved to some APIClient
guard let url = URL(string: "https://api.unsplash.com/users/\(collection)/collections?client_id=\(Keys.unsplashAPIKey)") else {
return emptyPublisher()
}
return urlSession.dataTaskPublisher(for: url)
.map { $0.data }
.decode(type: Photos.self, decoder: JSONDecoder())
.catch { error -> AnyPublisher<Photos, Never> in
print("☣️ error decoding: \(error)")
return emptyPublisher()
}
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
}
*Client suggestion
You might want to write some kind of HTTPClient/APIClient/RESTClient and take a look at HTTP Status codes.
This is a highly modular (and one might argue - overly engineered) solution using a DataFetcher and a DefaultHTTPClient conforming to a HTTPClient protocol:
DataFetcher
public final class DataFetcher {
private let dataFromRequest: (URLRequest) -> AnyPublisher<Data, HTTPError.NetworkingError>
public init(dataFromRequest: #escaping (URLRequest) -> AnyPublisher<Data, HTTPError.NetworkingError>) {
self.dataFromRequest = dataFromRequest
}
}
public extension DataFetcher {
func fetchData(request: URLRequest) -> AnyPublisher<Data, HTTPError.NetworkingError> {
dataFromRequest(request)
}
}
// MARK: Convenience init
public extension DataFetcher {
static func urlResponse(
errorMessageFromDataMapper: ErrorMessageFromDataMapper,
headerInterceptor: (([AnyHashable: Any]) -> Void)?,
badStatusCodeInterceptor: ((UInt) -> Void)?,
_ dataAndUrlResponsePublisher: #escaping (URLRequest) -> AnyPublisher<(data: Data, response: URLResponse), URLError>
) -> DataFetcher {
DataFetcher { request in
dataAndUrlResponsePublisher(request)
.mapError { HTTPError.NetworkingError.urlError($0) }
.tryMap { data, response -> Data in
guard let httpResponse = response as? HTTPURLResponse else {
throw HTTPError.NetworkingError.invalidServerResponse(response)
}
headerInterceptor?(httpResponse.allHeaderFields)
guard case 200...299 = httpResponse.statusCode else {
badStatusCodeInterceptor?(UInt(httpResponse.statusCode))
let dataAsErrorMessage = errorMessageFromDataMapper.errorMessage(from: data) ?? "Failed to decode error from data"
print("⚠️ bad status code, error message: <\(dataAsErrorMessage)>, httpResponse: `\(httpResponse.debugDescription)`")
throw HTTPError.NetworkingError.invalidServerStatusCode(httpResponse.statusCode)
}
return data
}
.mapError { castOrKill(instance: $0, toType: HTTPError.NetworkingError.self) }
.eraseToAnyPublisher()
}
}
// MARK: From URLSession
static func usingURLSession(
errorMessageFromDataMapper: ErrorMessageFromDataMapper,
headerInterceptor: (([AnyHashable: Any]) -> Void)?,
badStatusCodeInterceptor: ((UInt) -> Void)?,
urlSession: URLSession = .shared
) -> DataFetcher {
.urlResponse(
errorMessageFromDataMapper: errorMessageFromDataMapper,
headerInterceptor: headerInterceptor,
badStatusCodeInterceptor: badStatusCodeInterceptor
) { urlSession.dataTaskPublisher(for: $0).eraseToAnyPublisher() }
}
}
HTTPClient
public final class DefaultHTTPClient {
public typealias Error = HTTPError
public let baseUrl: URL
private let jsonDecoder: JSONDecoder
private let dataFetcher: DataFetcher
private var cancellables = Set<AnyCancellable>()
public init(
baseURL: URL,
dataFetcher: DataFetcher,
jsonDecoder: JSONDecoder = .init()
) {
self.baseUrl = baseURL
self.dataFetcher = dataFetcher
self.jsonDecoder = jsonDecoder
}
}
// MARK: HTTPClient
public extension DefaultHTTPClient {
func perform(absoluteUrlRequest urlRequest: URLRequest) -> AnyPublisher<Data, HTTPError.NetworkingError> {
return Combine.Deferred {
return Future<Data, HTTPError.NetworkingError> { [weak self] promise in
guard let self = self else {
promise(.failure(.clientWasDeinitialized))
return
}
self.dataFetcher.fetchData(request: urlRequest)
.sink(
receiveCompletion: { completion in
guard case .failure(let error) = completion else { return }
promise(.failure(error))
},
receiveValue: { data in
promise(.success(data))
}
).store(in: &self.cancellables)
}
}.eraseToAnyPublisher()
}
func performRequest(pathRelativeToBase path: String) -> AnyPublisher<Data, HTTPError.NetworkingError> {
let url = URL(string: path, relativeTo: baseUrl)!
let urlRequest = URLRequest(url: url)
return perform(absoluteUrlRequest: urlRequest)
}
func fetch<D>(urlRequest: URLRequest, decodeAs: D.Type) -> AnyPublisher<D, HTTPError> where D: Decodable {
return perform(absoluteUrlRequest: urlRequest)
.mapError { print("☢️ got networking error: \($0)"); return castOrKill(instance: $0, toType: HTTPError.NetworkingError.self) }
.mapError { HTTPError.networkingError($0) }
.decode(type: D.self, decoder: self.jsonDecoder)
.mapError { print("☢️ 🚨 got decoding error: \($0)"); return castOrKill(instance: $0, toType: DecodingError.self) }
.mapError { Error.serializationError(.decodingError($0)) }
.eraseToAnyPublisher()
}
}
Helpers
public protocol ErrorMessageFromDataMapper {
func errorMessage(from data: Data) -> String?
}
public enum HTTPError: Swift.Error {
case failedToCreateRequest(String)
case networkingError(NetworkingError)
case serializationError(SerializationError)
}
public extension HTTPError {
enum NetworkingError: Swift.Error {
case urlError(URLError)
case invalidServerResponse(URLResponse)
case invalidServerStatusCode(Int)
case clientWasDeinitialized
}
enum SerializationError: Swift.Error {
case decodingError(DecodingError)
case inputDataNilOrZeroLength
case stringSerializationFailed(encoding: String.Encoding)
}
}
internal func castOrKill<T>(
instance anyInstance: Any,
toType expectedType: T.Type,
_ file: String = #file,
_ line: Int = #line
) -> T {
guard let instance = anyInstance as? T else {
let incorrectTypeString = String(describing: Mirror(reflecting: anyInstance).subjectType)
fatalError("Expected variable '\(anyInstance)' (type: '\(incorrectTypeString)') to be of type `\(expectedType)`, file: \(file), line:\(line)")
}
return instance
}
This ended up being an issue with my Codable struct not being set up properly. Once I added a default object in the .replaceError method instead of a blank array (Thanks #Asperi), I was able to see the decoding error and fix it. Works like a charm now!
Original:
func load(user collection: String) {
guard let url = URL(string: "https://api.unsplash.com/users/\(collection)/collections?client_id=\(Keys.unsplashAPIKey)") else {
return
}
cancellable = URLSession.shared.dataTaskPublisher(for: url)
.map { $0.data }
.decode(type: Photos.self, decoder: JSONDecoder())
.replaceError(with: [])
.receive(on: RunLoop.main)
.assign(to: \.photos, on: self)
}
Updated:
func load(user collection: String) {
guard let url = URL(string: "https://api.unsplash.com/users/\(collection)/collections?client_id=\(Keys.unsplashAPIKey)") else {
return
}
cancellable = URLSession.shared.dataTaskPublisher(for: url)
.map { $0.data }
.decode(type: Photos.self, decoder: JSONDecoder())
.replaceError(with: defaultPhotosObject)
.receive(on: RunLoop.main)
.assign(to: \.photos, on: self)
}

Translating async method into Combine

I'm trying to wrap my head around Combine.
Here's a method I want to translate into Combine, so that it would return AnyPublisher.
func getToken(completion: #escaping (Result<String, Error>) -> Void) {
dispatchQueue.async {
do {
if let localEncryptedToken = try self.readTokenFromKeychain() {
let decryptedToken = try self.tokenCryptoHelper.decrypt(encryptedToken: localEncryptedToken)
DispatchQueue.main.async {
completion(.success(decryptedToken))
}
} else {
self.fetchToken(completion: completion)
}
} catch {
DispatchQueue.main.async {
completion(.failure(error))
}
}
}
}
The whole thing executes on a separate dispatch queue because reading from Keychain and decryption can be slow.
My first attempt to embrace Combine
func getToken() -> AnyPublisher<String, Error> {
do {
if let localEncryptedToken = try readTokenFromKeychain() {
let decryptedToken = try tokenCryptoHelper.decrypt(encryptedToken: localEncryptedToken)
return Result.success(decryptedToken).publisher.eraseToAnyPublisher()
} else {
return fetchToken() // also rewritten to return AnyPublisher<String, Error>
}
} catch {
return Result.failure(error).publisher.eraseToAnyPublisher()
}
}
But how would I move reading from Keychain and decryption onto separate queue? It probably should look something like
func getToken() -> AnyPublisher<String, Error> {
return Future<String, Error> { promise in
self.dispatchQueue.async {
do {
if let localEncryptedToken = try self.readTokenFromKeychain() {
let decryptedToken = try self.tokenCryptoHelper.decrypt(encryptedToken: localEncryptedToken)
promise(.success(decryptedToken))
} else {
// should I fetchToken().sink here?
}
} catch {
promise(.failure(error))
}
}
}.eraseToAnyPublisher()
}
How would I return a publisher from my private method call? (see comment in code)
Are there any prettier solutions?
Assuming you’ve refactored readTokenFromKeyChain, decrypt, and fetchToken to return AnyPublisher<String, Error> themselves, you can then do:
func getToken() -> AnyPublisher<String, Error> {
readTokenFromKeyChain()
.flatMap { self.tokenCryptoHelper.decrypt(encryptedToken: $0) }
.catch { _ in self.fetchToken() }
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
That will read the keychain, if it succeeded, decrypt it, and if it didn’t succeed, it will call fetchToken. And having done all of that, it will make sure the final result is delivered on the main queue.
I think that’s the right general pattern. Now, let's talk about that dispatchQueue: Frankly, I’m not sure I’m seeing anything here that warrants running on a background thread, but let’s imagine you wanted to kick this off in a background queue, then, you readTokenFromKeyChain might dispatch that to a background queue:
func readTokenFromKeyChain() -> AnyPublisher<String, Error> {
dispatchQueue.publisher { promise in
let query: [CFString: Any] = [
kSecReturnData: true,
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: "token",
kSecAttrService: Bundle.main.bundleIdentifier!]
var extractedData: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &extractedData)
if
status == errSecSuccess,
let retrievedData = extractedData as? Data,
let string = String(data: retrievedData, encoding: .utf8)
{
promise(.success(string))
} else {
promise(.failure(TokenError.failure))
}
}
}
By the way, that’s using a simple little method, publisher that I added to DispatchQueue:
extension DispatchQueue {
/// Dispatch block asynchronously
/// - Parameter block: Block
func publisher<Output, Failure: Error>(_ block: #escaping (Future<Output, Failure>.Promise) -> Void) -> AnyPublisher<Output, Failure> {
Future<Output, Failure> { promise in
self.async { block(promise) }
}.eraseToAnyPublisher()
}
}
For the sake of completeness, this is a sample fetchToken implementation:
func fetchToken() -> AnyPublisher<String, Error> {
let request = ...
return URLSession.shared
.dataTaskPublisher(for: request)
.map { $0.data }
.decode(type: ResponseObject.self, decoder: JSONDecoder())
.map { $0.payload.token }
.eraseToAnyPublisher()
}
I think I could find a solution
private func readTokenFromKeychain() -> AnyPublisher<String?, Error> {
...
}
func getToken() -> AnyPublisher<String, Error> {
return readTokenFromKeychain()
.flatMap { localEncryptedToken -> AnyPublisher<String, Error> in
if let localEncryptedToken = localEncryptedToken {
return Result.success(localEncryptedToken).publisher.eraseToAnyPublisher()
} else {
return self.fetchToken()
}
}
.flatMap {
return self.tokenCryptoHelper.decrypt(encryptedToken: $0)
}
.subscribe(on: dispatchQueue)
.eraseToAnyPublisher()
}
But I had to make functions I call within getToken() return publishers too to Combine them well.
There probably should be error handling somewhere but this is the next thing for me to learn.

How can I loop over the output of a publisher with Combine?

I'm working on rewriting my Hacker News reader to use Combine more heavily. I'm have two functions which both return an AnyPublisher, one of them get's the ids of a bunch of HN stories from the server and the other one fetches a story by it's id. I'm not sure how I could loop over the results of fetchStoryIds, run fetchStory with the id and end up with an array of Story objects with Combine.
import Combine
import Foundation
struct HackerNewsService {
private var session = URLSession(configuration: .default)
static private var baseURL = "https://hacker-news.firebaseio.com/v0"
private func fetchStoryIds(feed: FeedType) -> AnyPublisher<[Int], Error> {
let url = URL(string: "\(HackerNewsService.baseURL)/\(feed.rawValue.lowercased())stories.json")!
return session.dataTaskPublisher(for: url)
.retry(1)
.map { $0.data }
.decode(type: [Int].self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
private func fetchStory(id: Int) -> AnyPublisher<Story, Error> {
let url = URL(string: "\(HackerNewsService.baseURL)/item/\(id).json")!
return session.dataTaskPublisher(for: url)
.map { $0.data }
.decode(type: Story.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
}
Before I started the rewrite, I used this code to loop over the ids and get the stories.
func fetchStories(feed: FeedType, completionHandler: #escaping ([Story]?, Error?) -> Void) {
fetchStoryIds(feed: feed) { (ids, error) in
guard error == nil else {
completionHandler(nil, error)
return
}
guard let ids = ids else {
completionHandler(nil, error)
return
}
let dispatchGroup = DispatchGroup()
var stories = [Story]()
for id in ids {
dispatchGroup.enter()
self.fetchStory(id: id) { (story, error) in
guard error == nil else {
dispatchGroup.leave()
return
}
guard let story = story else {
dispatchGroup.leave()
return
}
stories.append(story)
dispatchGroup.leave()
}
}
dispatchGroup.notify(queue: .main) {
completionHandler(stories, nil)
}
}
}
}
Hmm.. It doesn't look like there is a Publishers.ZipMany that accepts a collection of publishers, so instead I merged the stories and collected them instead. Ideally this would collect them in the correct order but I haven't tested that and the documentation is still somewhat sparse across Combine.
func fetchStories(feed: FeedType) -> AnyPublisher<[Story], Error> {
fetchStoryIds(feed: feed)
.flatMap { ids -> AnyPublisher<[Story], Error> in
let stories = ids.map { self.fetchStory(id: $0) }
return Publishers.MergeMany(stories)
.collect()
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
If you are open to external code this is a gist implementation of ZipMany that will preserve the order:
https://gist.github.com/mwahlig/725fe5e78e385093ba53e6f89028a41c
Although I would think that such a thing would exist in the framework.

do not know how to get the result of completion

I am having trouble to use the result of a completion handler.
I am getting this error "Cannot convert value of type '()' to expected argument type"
struct SearchCollectionViewModel {
let name: String
let previewURL: String?
var image:UIImage?
let dataController = DataController()
}
extension SearchCollectionViewModel {
init(with result: Result) {
self.name = result.trackName
self.previewURL = result.previewURL
if let url = result.previewURL {
let imgData = preview(with: url, completion: { data -> Data? in
guard let data = data as? Data else { return nil }
return data
})
self.image = UIImage(data: imgData)
}
}
private func preview(with url: String, completion: #escaping (Data) -> Data?) {
dataController.download(with: url) { data, error in
if error == nil {
guard let imageData = data else { return }
DispatchQueue.main.async {
_ = completion(imageData)
}
}
}
}
}
A couple of observations:
You cannot “return” a value that is retrieved asynchronously via escaping closure.
The closure definition (Data) -> Data? says that the closure not only will be passed the Data retrieved for the image, but that the closure will, itself, return something back to preview. But it’s obviously not doing that (hence the need for _, as in _ = completion(...)). I’d suggest you change that to (Data?) -> Void (or use the Result<T, U> pattern).
I’d suggest renaming your Result type as there’s a well-known generic called Result<Success, Failure> for returning .success(Success) or .failure(Failure). This is a pattern that we’ve used for a while, but is formally introduced in Swift 5, too. See SE-0235.
Your codebase can have its own Result type, but it’s an invitation for confusion later down the road if and when you start adopting this Result<T, U> convention.
You really shouldn’t be initiating asynchronous process from init, but instead invoke a method to do that.
Personally, I’d move the conversion to UIImage into the DataController, e.g.
extension DataController {
func downloadImage(with url: URL, completion: #escaping (UIImage?, Error?) -> Void) {
let task = URLSession.shared.dataTask(with: url) { data, _, error in
let image = data.flatMap { UIImage(data: $0) }
completion(image, error)
}
task.resume()
}
}
So, I might suggest you end up with something like:
class SearchCollectionViewModel {
let name: String
let previewURL: String?
let dataController = DataController()
var image: UIImage?
init(with result: Result) {
self.name = result.trackName
self.previewURL = result.previewURL
}
}
extension SearchCollectionViewModel {
func preview(with url: String, completion: #escaping (UIImage?) -> Void) {
guard let urlString = previewURL, let url = URL(string: urlString) else {
completion(nil)
return
}
dataController.downloadImage(with: url) { [weak self] image, error in
DispatchQueue.main.async {
self?.image = image
completion(image)
}
}
}
}