Reporting progress on list of Publishers in Combine

Viewed 882

I am writing a Hacker News iOS application using SwiftUI/Combine. They have an API call for getting the ids of top posts and then you are supposed to request each story by itself. For this I have created storyIds(:) -> AnyPublisher<[Int], Error> and story(for:) -> AnyPublisher<Post, Error> for those calls.

Now I want to combine them into one function, getStories() which first download the identifiers and then goes through them fetching the stories one by one. I suppose you can use MergeMany or something else in the API for achieving this but I am not sure

The last thing I need is a function that combine these into stories() -> AnyPublisher<[Post], Error>. I found another question doing almost that. What I miss however, is a way to report progress of the task. I would like to update a counter for each fetched story to show the user how much is left of the download. How can I do this?

struct Agent {
    struct Response<T> {
        let value: T
        let response: URLResponse
    }
    
    func run<T: Decodable>(_ request: URLRequest, _ decoder: JSONDecoder = JSONDecoder()) -> AnyPublisher<Response<T>, Error> {
        return URLSession.shared
            .dataTaskPublisher(for: request)
            .tryMap { result -> Response<T> in
                let value = try decoder.decode(T.self, from: result.data)
                return Response(value: value, response: result.response)
            }
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }
}

enum HackerNewsAPI {
    static let agent = Agent()
    static let base = URL(string: "https://hacker-news.firebaseio.com/v0/")!
}

extension HackerNewsAPI {
    static func storyIds() -> AnyPublisher<[Int], Error> {
        let request = URLRequest(url: base.appendingPathComponent("topstories.json"))
        return agent.run(request)
            .print()
            .map(\.value)
            .eraseToAnyPublisher()
    }
}

extension HackerNewsAPI {
    static func story(for id: Int) -> AnyPublisher<Post, Error> {
        let request = URLRequest(url: base.appendingPathComponent("item/\(id).json"))
        return agent.run(request)
            .map(\.value)
            .eraseToAnyPublisher()
    }
}

extension HackerNewsAPI {
    static func stories() -> AnyPublisher<[Post], Error> {
        HackerNewsAPI.storyIds()
            .flatMap { storyIDs -> AnyPublisher<[Post], Error> in
                let stories = storyIDs.map { story(for: $0) }
                return Publishers.MergeMany(stories)
                    .collect()
                    .eraseToAnyPublisher()
            }
            .eraseToAnyPublisher()
    }
}

1 Answers

I expect something like the below code would work.

You end up with a count publisher that emits the number of articles that have been downloaded so far and a array publisher that emits the entire array of Posts after they have all been downloaded.

func getStories() -> (count: AnyPublisher<Int, Error>, stories: AnyPublisher<[Post], Error>) {
    let posts = HackerNewsAPI.storyIds()
        .map { $0.map { HackerNewsAPI.story(for: $0) } }
        .flatMap { Publishers.Sequence(sequence: $0) }
        .flatMap { $0 }
        .share()

    let count = posts
        .scan(0) { count, _ in
            return count + 1
        }

    let array = posts
        .reduce([Post]()) { current, post in
            current + [post]
        }

    return (count.eraseToAnyPublisher(), array.eraseToAnyPublisher())
}
Related