Reputation: 2897
I am attempting to perform concurrent API calls using the Combine framework. The API calls are set up like so:
Posts
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.
Upvotes: 6
Views: 5034
Reputation: 2897
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.
Upvotes: 1