6

I am attempting to perform concurrent API calls using the Combine framework. The API calls are set up like so:

  1. First, call an API to get a list of Posts
  2. 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.

Koh
  • 2,687
  • 1
  • 22
  • 62
  • Is there a CombineLatest like there is in rxjs? – Dan Chase Jul 10 '21 at 05:23
  • @DanChase There is a CombineLatest, but I imagine that this method would listen for 2 publishers. Any idea how do I apply it in the above use case? – Koh Jul 10 '21 at 05:25
  • https://stackoverflow.com/questions/61841254/combine-framework-how-to-process-each-element-of-array-asynchronously-before-pr – matt Jul 10 '21 at 05:48
  • @Koh Sorry I misread the use-case. Not an actual answer, but In the past I have solved this by creating a combined structure in the back-end, and just having the one call on the HTTP side. On the back-end the API surface can call the multiple business layer functions and create a structure to return. In a current project I'm working on, it had grown to 8 HTTP Get's, and I started having problems with some returning before others and causing confusion for the user, as well as issues with stalling the browser. HTTP 1.1 I believe has a limit of 6.. I hope this helps more than my prev. comment. – Dan Chase Jul 10 '21 at 05:48
  • @Koh thinking more about combineLatest, I think my thought process was about using combineLatest with 2 observables, one for master, and one for detail, for each loop. But the more I thought about it the worse the idea seemed, which led to my comment above. – Dan Chase Jul 10 '21 at 05:51
  • All good, but now that we have swift-5.5, "Swift has built-in support for writing asynchronous and parallel code in a structured way." I would definitely look at that and especially using actors. https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html There are some good tutorials on the net on this. – workingdog support Ukraine Jul 10 '21 at 07:48

1 Answers1

1

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.

Koh
  • 2,687
  • 1
  • 22
  • 62