Swift Combine chaining .mapError() - swift

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

Related

Insert comment into Swift Combine pipeline

Here is a snippet from my Publisher:
let cancellable = URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.decode([Object].self, decoder: JSONDecoder()
.sink(...)
If I want to know what's happening when, I could do this:
let cancellable = URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.map { print("Before decoding"); return $0 }
.decode([Object].self, decoder: JSONDecoder()
.map { print("After decoding"); return $0 }
.sink(...)
Is there a better way than this (ab)use of map or similar?
As mentioned in the comments, the obvious answer is the .print() operator. If you only want to see print statements for a particular kind of event, then use handleEvents instead.
let cancellable = URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.handleEvents(receiveOutput: { _ in print("before decoding") })
.decode(type: [Object].self, decoder: JSONDecoder())
.handleEvents(receiveOutput: { _ in print("after decoding") })
.sink(receiveCompletion: { _ in }, receiveValue: { _ in })

Reusable publishers (subscriptions?) in Combine

I've got a case where I'm using a dataTaskPublisher and then chaining the output, as shown below. Now I'm implementing a background download, using URLSession's downloadTask(with:completionHandler) and I need to perform the exact same operations.
So everything in the code, below, from the decode(type:decoder) onwards is common between both situations. Is there some way I can take a Data object and let it be passed through that same set of steps without duplicating the code?
anyCancellable = session.dataTaskPublisher(for: url)
.map { $0.data }
.decode(type: TideLowWaterHeightPredictions.self, decoder: Self.decoder)
.map { $0.predictions }
.eraseToAnyPublisher()
.sink {
...
} receiveValue: { predictions in
...
}
You can wrap it up in an extension:
extension Publisher where Output == Data {
func gargoyle() -> AnyCancellable {
return self
.decode(type: TideLowWaterHeightPredictions.self, decoder: Self.decoder)
.map { $0.predictions }
.sink {
...
} receiveValue: { predictions in
...
}
}
}
And use it like this:
session
.dataTaskPublisher(for: url)
.map { $0.data }
.gargoyle()
.store(in: &tickets)
Or like this if you already have a Data:
Just(data)
.gargoyle()
.store(in: &tickets)

Reduce an array to a single value with Combine

How would I use swift combine to get the key of the first TrailVideo object who's site is "YouTube". I feel like I need a flatMap somewhere but I'm not entirely sure.
struct TrailerVideoResult: Codable {
let results : [TrailerVideo]
}
struct TrailerVideo: Codable {
let key: String
let site: String
}
class Testing{
//Should output the key of the first TrailVideo object who's site is "YouTube"
func getYoutubeKey()-> AnyPublisher<String, Error>{
return URLSession.shared.dataTaskPublisher(for: URL(string: "")!)
.map(\.data)
.decode(type: TrailerVideoResult.self, decoder: JSONDecoder())
.receive(on: RunLoop.main)
.map(\.results)
.map(\.sites)
.eraseToAnyPublisher()
}
}
You may use compactMap if you aren't concerned with errors (ie. if filtered results variable is empty):
class Testing {
func getYoutubeKey() -> AnyPublisher<String, Error> {
return URLSession.shared.dataTaskPublisher(for: URL(string: "")!)
.map(\.data)
.decode(type: TrailerVideoResult.self, decoder: JSONDecoder())
.map(\.results)
.compactMap { $0.first { $0.site == "YouTube" }?.key }
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
}
Or if you want to provide a default value you can replace:
.compactMap { $0.first { $0.site == "YouTube" }?.key }
with:
.map { $0.first { $0.site == "YouTube" }?.key ?? "default" }
I ended up having to map over results to get the sites then grab the first where site == "YouTube". Thanks to #DonnyWals on Twitter for the help.
class Testing{
//Should output the key of the first TrailVideo object who's site is "YouTube"
func getYoutubeKey()-> AnyPublisher<String, Error>{
return URLSession.shared.dataTaskPublisher(for: URL(string: "")!)
.map(\.data)
.decode(type: TrailerVideoResult.self, decoder: JSONDecoder())
.receive(on: RunLoop.main)
.compactMap{
$0.results.first(where: {$0.site == "YouTube"}).map(\.key)
}
.eraseToAnyPublisher()
}
}

handling HTTP status code with URLSession and Combine

I'm trying to handle the responses that arrive from a DataTaskPublisher reading its response status code.
When status code is greater than 299, I'd like to return a ServiceError type as Failure. In every examples that I've seen I've used .mapError and .catch... in this specific case, from a .flatMap, I really don't know how to handle the publisher response to return the Error instead of the TResponse...
return URLSession.DataTaskPublisher(request: urlRequest, session: .shared)
.mapError{error in return ServiceError.request}
.flatMap{ data, response -> AnyPublisher<TResponse, ServiceError> in
if let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode){
return Just(data)
.decode(type: TResponse.self, decoder: JSONDecoder())
.mapError{error in return ServiceError.decode}
.eraseToAnyPublisher()
}else{
//???? HOW TO HANDLE THE ERROR?
}
}
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
enum ServiceErrors: Error {
case internalError(_ statusCode: Int)
case serverError(_ statusCode: Int)
}
return URLSession.shared.dataTaskPublisher(for: urlRequest)
.tryMap { data, response in
guard let httpResponse = response as? HTTPURLResponse,
200..<300 ~= httpResponse.statusCode else {
switch (response as! HTTPURLResponse).statusCode {
case (400...499):
throw ServiceErrors.internalError((response as! HTTPURLResponse).statusCode)
default:
throw ServiceErrors.serverError((response as! HTTPURLResponse).statusCode)
}
}
return data
}
.mapError { $0 as! ServiceErrors }
.decode(type: T.self, decoder: JSONDecoder())
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
NOTE: I relied on this link to make my error handler.
If I correctly understood your goal, you need something like
}else{
return Fail(error: ServiceError.badServiceReply)
.eraseToAnyPublisher()
}
Simple example:
URLSession.shared
.dataTaskPublisher(for: URL(string: "https://www.google.com")!)
.receive(on: DispatchQueue.main)
.flatMap { _ in
Fail(error: URLError(URLError.unsupportedURL)).eraseToAnyPublisher()
} //for the sake of the demo
.replaceError(with: "An error occurred") //this sets Failure to Never
.assign(to: \.stringValue, on: self)
.store(in: &cancellableBag)
would always assign string "An error occurred" due to remap to Fail publisher
You can use tryMap here:
URLSession.shared.dataTaskPublisher(for: urlRequest)
.mapError { error in return ServiceError.request }
.tryMap { (data, response) throws -> TResponse in
guard let httpResponse = response as? HTTPURLResponse,
200...299 ~= httpResponse.statusCode else {
throw ServiceError.invalidStatusCode((response as? HTTPURLResponse)?.statusCode ?? 0)
}
return try JSONDecoder().decode(TResponse.self, from: data)
}
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
This will also simplify your code as you don't need do create an extra publisher.
I also made a few other improvements to the original code:
used URLSession.shared.dataTaskPublisher(for: urlRequest) instead of URLSession.DataTaskPublisher(request: urlRequest, session: .shared)
used the ~= operator to check the status code
used a guard instead of a let as it's more idiomatic

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.