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

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.

Related

Swift: How to perform concurrent API calls using Combine

I am attempting to perform concurrent API calls using the Combine framework. The API calls are set up like so:
First, call an API to get a list of Posts
For each post, call another API to get Comments
I would like to use Combine to chain these two calls together and concurrently so that it returns an array of Post objects with each post containing the comments array.
My attempt:
struct Post: Decodable {
let userId: Int
let id: Int
let title: String
let body: String
var comments: [Comment]?
}
struct Comment: Decodable {
let postId: Int
let id: Int
let name: String
let email: String
let body: String
}
class APIClient: ObservableObject {
#Published var posts = [Post]()
var cancellables = Set<AnyCancellable>()
init() {
getPosts()
}
func getPosts() {
let urlString = "https://jsonplaceholder.typicode.com/posts"
guard let url = URL(string: urlString) else {return}
URLSession.shared.dataTaskPublisher(for: url)
.receive(on: DispatchQueue.main)
.tryMap({ (data, response) -> Data in
guard
let response = response as? HTTPURLResponse,
response.statusCode >= 200 else {
throw URLError(.badServerResponse)
}
return data
})
.decode(type: [Post].self, decoder: JSONDecoder())
.sink { (completion) in
print("Posts completed: \(completion)")
} receiveValue: { (output) in
//Is there a way to chain getComments such that receiveValue would contain Comments??
output.forEach { (post) in
self.getComments(post: post)
}
}
.store(in: &cancellables)
}
func getComments(post: Post) {
let urlString = "https://jsonplaceholder.typicode.com/posts/\(post.id)/comments"
guard let url = URL(string: urlString) else {
return
}
URLSession.shared.dataTaskPublisher(for: url)
.receive(on: DispatchQueue.main)
.tryMap({ (data, response) -> Data in
guard
let response = response as? HTTPURLResponse,
response.statusCode >= 200 else {
throw URLError(.badServerResponse)
}
return data
})
.decode(type: [Comment].self, decoder: JSONDecoder())
.sink { (completion) in
print("Comments completed: \(completion)")
} receiveValue: { (output) in
print("Comment", output)
}
.store(in: &cancellables)
}
}
How do I chain getComments to getPosts so that the output of comments can be received in getPosts? Traditionally using UIKit, I would use DispatchGroup for this task.
Note that I would like to receive just a single Publisher event for posts from the APIClient so that the SwiftUI view is refreshed only once.
Thanks to #matt's post in the comments above, I've adapted the solution in that SO post for my use case above.
Not too sure if it is the best implementation, but it addresses my problem for now.
func getPosts() {
let urlString = "https://jsonplaceholder.typicode.com/posts"
guard let url = URL(string: urlString) else {return}
URLSession.shared.dataTaskPublisher(for: url)
.receive(on: DispatchQueue.main)
.tryMap({ (data, response) -> Data in
guard
let response = response as? HTTPURLResponse,
response.statusCode >= 200 else {
throw URLError(.badServerResponse)
}
return data
})
.decode(type: [Post].self, decoder: JSONDecoder())
.flatMap({ (posts) -> AnyPublisher<Post, Error> in
//Because we return an array of Post in decode(), we need to convert it into an array of publishers but broadcast as 1 publisher
Publishers.Sequence(sequence: posts).eraseToAnyPublisher()
})
.compactMap({ post in
//Loop over each post and map to a Publisher
self.getComments(post: post)
})
.flatMap {$0} //Receives the first element, ie the Post
.collect() //Consolidates into an array of Posts
.sink(receiveCompletion: { (completion) in
print("Completion:", completion)
}, receiveValue: { (posts) in
self.posts = posts
})
.store(in: &cancellables)
}
func getComments(post: Post) -> AnyPublisher<Post, Error>? {
let urlString = "https://jsonplaceholder.typicode.com/posts/\(post.id)/comments"
guard let url = URL(string: urlString) else {
return nil
}
let publisher = URLSession.shared.dataTaskPublisher(for: url)
.receive(on: DispatchQueue.main)
.tryMap({ (data, response) -> Data in
guard
let response = response as? HTTPURLResponse,
response.statusCode >= 200 else {
throw URLError(.badServerResponse)
}
return data
})
.decode(type: [Comment].self, decoder: JSONDecoder())
.tryMap { (comments) -> Post in
var newPost = post
newPost.comments = comments
return newPost
}
.eraseToAnyPublisher()
return publisher
}
Essentially, we will need to return a Publisher from the getComments method so that we can loop over each publisher inside getPosts.

Swift Combine correct use of Future

Say I have a three layered architecture (Data, Domain and View) and I want to access and provide some data. The three layers are part of different targets and are initialised using dependency injection.
In the domain layer I have the following types:
protocol BookListRepository: AnyObject {
func getAll() -> Future<[Book], Error>
}
final class BookService {
private let repository: BookListRepository
init(repository: BookListRepository) {
self.repository = repository
}
func getAll() -> Future<[Book], Error> {
repository.getAll()
}
}
In data I define the following:
class BookApi: BookListRepository {
func getAll() -> Future<[Book], Error> {
.init { promise in
let cancellable = urlSession
.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: [Book]].self, decoder: JSONDecoder())
.sink(receiveCompletion: { completion in
guard case let .failure(error) = completion
promise(.failure(error))
},
receiveValue: { books in
promise(.success(books))
}
}
}
In my view layer I would access this in a similar way to this:
let service: BookService = .init(repository: BookApi())
service
.getAll()
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { print($0) }) { books in
// Displau
}
.store(in: &cancelables)
My question here is the following: Is this in any way a good practice and if not what is the correct/preferred way to achieve what I want.
In Combine (and other similar frameworks), subscribers care about what values and what errors publishers emit, so it's customary to use a AnyPublisher at an API boundary.
protocol BookListRepository: AnyObject {
func getAll() -> AnyPublisher<[Book], Error>
}
Operators .sink and .assign create a subscription to the publisher. You'd want to subscribe only at the final consumption site of the data, and return the publisher in the intermediate steps:
final class BookService {
private let repository: BookListRepository
func getAll() -> AnyPublisher<[Book], Error> {
repository.getAll()
}
}
class BookApi: BookListRepository {
func getAll() -> AnyPublisher<[Book], Error> {
urlSession
.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: [Book].self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
}

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

Swift Combine. How to transform publisher values

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

Swift Combine chaining .mapError()

I'm trying to achieve something similar to scenario presented below (create URL, request to server, decode json, error on every step wrapped in custom NetworkError enum):
enum NetworkError: Error {
case badUrl
case noData
case request(underlyingError: Error)
case unableToDecode(underlyingError: Error)
}
//...
func searchRepos(with query: String, success: #escaping (ReposList) -> Void, failure: #escaping (NetworkError) -> Void) {
guard let url = URL(string: searchUrl + query) else {
failure(.badUrl)
return
}
session.dataTask(with: url) { data, response, error in
guard let data = data else {
failure(.noData)
return
}
if let error = error {
failure(.request(underlyingError: error))
return
}
do {
let repos = try JSONDecoder().decode(ReposList.self, from: data)
DispatchQueue.main.async {
success(repos)
}
} catch {
failure(.unableToDecode(underlyingError: error))
}
}.resume()
}
My solution in Combine works:
func searchRepos(with query: String) -> AnyPublisher<ReposList, NetworkError> {
guard let url = URL(string: searchUrl + query) else {
return Fail(error: .badUrl).eraseToAnyPublisher()
}
return session.dataTaskPublisher(for: url)
.mapError { NetworkError.request(underlyingError: $0) }
.map { $0.data }
.decode(type: ReposList.self, decoder: JSONDecoder())
.mapError { $0 as? NetworkError ?? .unableToDecode(underlyingError: $0) }
.subscribe(on: DispatchQueue.global())
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
but I really don't like this line
.mapError { $0 as? NetworkError ?? .unableToDecode(underlyingError: $0) }
My questions:
Is there better way to map errors (and replace line above) using chaining in Combine?
Is there any way to include first guard let with Fail(error:) in chain?
I agree with iamtimmo that you don't need .subscribe(on:). I also think this method is the wrong place for .receive(on:), because nothing in the method requires the main thread. If you have code elsewhere that subscribes to this publisher and wants results on the main thread, then that is where you should use the receive(on:) operator. I'm going to omit both .subscribe(on:) and .receive(on:) in this answer.
Anyway, let's address your questions.
Is there better way to map errors (and replace line above) using chaining in Combine?
“Better” is subjective. The problem you're trying to solve here is that you only want to apply that mapError to an error produced by the decode(type:decoder:) operator. You can do that using the flatMap operator to create a mini-pipeline inside the full pipeline:
return session.dataTaskPublisher(for: url)
.mapError { NetworkError.request(underlyingError: $0) }
.map { $0.data }
.flatMap {
Just($0)
.decode(type: ReposList.self, decoder: JSONDecoder())
.mapError { .unableToDecode(underlyingError: $0) } }
.eraseToAnyPublisher()
Is this “better”? Meh.
You could extract the mini-pipeline into a new version of decode:
extension Publisher {
func decode<Item, Coder>(type: Item.Type, decoder: Coder, errorTransform: #escaping (Error) -> Failure) -> Publishers.FlatMap<Publishers.MapError<Publishers.Decode<Just<Self.Output>, Item, Coder>, Self.Failure>, Self> where Item : Decodable, Coder : TopLevelDecoder, Self.Output == Coder.Input {
return flatMap {
Just($0)
.decode(type: type, decoder: decoder)
.mapError { errorTransform($0) }
}
}
}
And then use it like this:
return session.dataTaskPublisher(for: url)
.mapError { NetworkError.request(underlyingError: $0) }
.map { $0.data }
.decode(
type: ReposList.self,
decoder: JSONDecoder(),
errorTransform: { .unableToDecode(underlyingError: $0) })
.eraseToAnyPublisher()
Is there any way to include first guard let with Fail(error:) in chain?
Yes, but again it's not clear that doing so is better. In this case, the transformation of query into a URL is not asynchronous, so there's little reason to use Combine. But if you really want to do it, here's a way:
return Just(query)
.setFailureType(to: NetworkError.self)
.map { URL(string: searchUrl + $0).map { Result.success($0) } ?? Result.failure(.badUrl) }
.flatMap { $0.publisher }
.flatMap {
session.dataTaskPublisher(for: $0)
.mapError { .request(underlyingError: $0) } }
.map { $0.data }
.decode(
type: ReposList.self,
decoder: JSONDecoder(),
errorTransform: { .unableToDecode(underlyingError: $0) })
.eraseToAnyPublisher()
This is convoluted because Combine doesn't have any operators that can turn a normal output or completion into a typed failure. It has tryMap and similar, but those all produce a Failure type of Error instead of anything more specific.
We can write an operator that turns an empty stream into a specific error:
extension Publisher where Failure == Never {
func replaceEmpty<NewFailure: Error>(withFailure failure: NewFailure) -> Publishers.FlatMap<Result<Self.Output, NewFailure>.Publisher, Publishers.ReplaceEmpty<Publishers.Map<Publishers.SetFailureType<Self, NewFailure>, Result<Self.Output, NewFailure>>>> {
return self
.setFailureType(to: NewFailure.self)
.map { Result<Output, NewFailure>.success($0) }
.replaceEmpty(with: Result<Output, NewFailure>.failure(failure))
.flatMap { $0.publisher }
}
}
Now we can use compactMap instead of map to turn query into a URL, producing an empty stream if we can't create a URL, and use our new operator to replace the empty stream with the .badUrl error:
return Just(query)
.compactMap { URL(string: searchUrl + $0) }
.replaceEmpty(withFailure: .badUrl)
.flatMap {
session.dataTaskPublisher(for: $0)
.mapError { .request(underlyingError: $0) } }
.map { $0.data }
.decode(
type: ReposList.self,
decoder: JSONDecoder(),
errorTransform: { .unableToDecode(underlyingError: $0) })
.eraseToAnyPublisher()
I don't think your approach is unreasonable. A benefit of the first mapError() (at // 1) is that you don't need to know much about the possible errors from the request.
return session.dataTaskPublisher(for: url)
.mapError { NetworkError.request(underlyingError: $0) } // 1
.map { $0.data }
.decode(type: ReposList.self, decoder: JSONDecoder())
.mapError { $0 as? NetworkError ?? .unableToDecode(underlyingError: $0) }
.subscribe(on: DispatchQueue.global()) // 2 - not needed
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
I don't think you need the subscribe(on:) at // 2, since URLSession.DataTaskPublisher starts on a background thread already. The subsequent receive(on:) is required.
An alternative approach would be to run through the "happy path" first and map all of the errors later, as in the following. You'll need to understand which errors come from which publishers/operators to correctly map to your NetworkError enum.
return session.dataTaskPublisher(for: url)
.map { $0.data }
.decode(type: ReposList.self, decoder: JSONDecoder())
.mapError({ error -> NetworkError in
// map all the errors here
})
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
To handle your second question, you can use the tryMap() and flatMap() to map your query into a URL and then into a URLSession.DataTaskPublisher instance. I haven't tested this particular code, but a solution would be along these lines.
Just(query)
.tryMap({ query in
guard let url = URL(string: searchUrl + query) else { throw NetworkError.badUrl }
return url
})
.flatMap({ url in
URLSession.shared.dataTaskPublisher(for: url)
.mapError { $0 as Error }
})
.map { $0.data }
//
// ... operators from the previous examples
//
.eraseToPublisher()