Insert comment into Swift Combine pipeline - swift

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

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.

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)

Chained Network Request RXSwift

Hey I learned on an HackingWithSwift Tutorial How to make a chained network request with Combine (see code below). Now I will build the same logic with RXSwift but I don't know how can I get/subscribe like in Combine to get the end result.
Combine:
//Combine code
func fetch<T: Decodable>(_ url: URL, defaultValue: T) -> AnyPublisher<T, Never> {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return URLSession.shared.dataTaskPublisher(for: url)
.retry(1)
.map(\.data)
.decode(type: T.self, decoder: decoder)
.replaceError(with: defaultValue)
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
//call fetch method and get the end result
fetch(url, defaultValue: [URL]())
.flatMap { urls in
urls.publisher.flatMap { url in
fetch(url, defaultValue: [NewsItem]())
}
}
.collect()
.sink { values in
let allItems = values.joined()
items = allItems.sorted { $0.id > $1.id }
}
.store(in: &requests)
//RXSwift code
func fetchWithRX<T: Decodable>(_ url: URL, defaultValue: T) -> Observable<T> {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let request = URLRequest(url: url)
return URLSession.shared.rx.response(request: request)
.retry(1)
.map(\.data)
.decode(type: T.self, decoder: decoder)
.debug()
.catchAndReturn(defaultValue)
.observe(on: MainScheduler.instance)
}
//call fetch2 method
Now I want to subscribe to the values like in the first fetch method with flatMap..collect..sink etc.
fetchWithRX(url, defaultValue: [URL]())
I would write the analog like this:
fetchWithRX(url, defaultValue: [URL]())
.flatMap { urls in
Observable.zip(urls.map { fetchWithRX($0, defaultValue: [NewsItem]()) })
}
.map { $0.flatMap { $0 }.sorted { $0.id > $1.id } }
.subscribe(onNext: { values in
items = values
})
.disposed(by: requests)
This way, I'm moving all the logic into a map closure which could be moved into a function for testability. Minimize the amount of code in a flatMap or subscribe in order to increase testability of your code.
Or you could write it like this:
fetchWithRX(url, defaultValue: [URL]())
.flatMap { urls in
Observable.zip(urls.map { fetchWithRX($0, defaultValue: [NewsItem]()) })
}
.subscribe(onNext: { values in
let allItems = values.joined()
items = allItems.sorted { $0.id > $1.id }
})
.disposed(by: requests)
You can learn more about combining observables in this article: Recipes for Combining Observables in RxSwift
URLSession also has an operator data(request:) which will just emit the data so you don't have to map to dump the result object. Like this:
func fetchWithRX<T: Decodable>(_ url: URL, defaultValue: T) -> Observable<T> {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return URLSession.shared.rx.data(request: URLRequest(url: url))
.retry(1)
.decode(type: T.self, decoder: decoder)
.catchAndReturn(defaultValue)
.observe(on: MainScheduler.instance)
}
It occurs to me that you might be looking for a direct corollary to the original Combine code... The above samples will have the same eventual output but are subtly different in how they work...
Here is a direct translation:
fetchWithRX(url, defaultValue: [URL]())
.flatMap { urls in
// Observable.from(urls) works like urls.publisher
Observable.from(urls).flatMap { url in
fetchWithRX(url, defaultValue: [NewsItem]())
}
}
.toArray() // works like collect(). However, toArray() returns a Single rather than a generic Observable.
.subscribe(onSuccess: { values in
let allItems = values.joined()
items = allItems.sorted { $0.id > $1.id }
})
.disposed(by: requests)
The difference is that the other examples preserve the order of the news items while this doesn't. Since you are collecting and sorting anyway, the eventual output is the same. You would only see a difference if you weren't using collect()/toArray() before observing the output.

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

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