Learn how to make HTTP requests and parse responses by using the new Combine framework with foundation networking.
API & data structure
First of all we will need some kind of API to connect, I will use the JSONPlaceholder service with the following data models:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | enum HTTPError: LocalizedError { case statusCode case post } struct Post: Codable { let id: Int let title: String let body: String let userId: Int } struct Todo: Codable { let id: Int let title: String let completed: Bool let userId: Int } |
Nothing special, just some basic codable elements, and a simple error, we want to show some errors if something fails. ❌
The traditional way
Making an HTTP request in Swift is quite easy, you can use the built-in URLSession shared with a simple data task, and have your response. Of course you might want to check for a valid status code, and if all goes well, you can parse your JSON response using the JSONDecoder objects from the Foundation.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | //somewhere in viewDidLoad let url = URL(string: "https://jsonplaceholder.typicode.com/posts")! let task = URLSession.shared.dataTask(with: url) { data, response, error in if let error = error { fatalError("Error: (error.localizedDescription)") } guard let response = response as? HTTPURLResponse, response.statusCode == 200 else { fatalError("Error: invalid HTTP response code") } guard let data = data else { fatalError("Error: missing response data") } do { let decoder = JSONDecoder() let posts = try decoder.decode([Post].self, from: data) print(posts.map { $0.title }) } catch { print("Error: (error.localizedDescription)") } } task.resume() |
Data tasks and the Combine framework
Now as you will see traditional “block-based” this approach is fine, but can we do something perhaps better here? You know, like describing the whole thing as a series, like we used to do this with Promises? Starting with iOS13 with the help of Combine frameworks you can really go further! ?
My favorite part of Combine is memory management & cancellation.
Data task with Combine
So the most common examples are usually:
1 2 3 4 5 6 7 8 9 10 11 12 13 | private var cancellable: AnyCancellable? //... self.cancellable = URLSession.shared.dataTaskPublisher(for: url) .map { $0.data } .decode(type: [Post].self, decoder: JSONDecoder()) .replaceError(with: []) .eraseToAnyPublisher() .sink(receiveValue: { posts in print(posts.count) }) //... self.cancellable?.cancel() |
- First we create a cancellable for your Publisher
- Then, we create a brand new data task publisher object
- Map responseg, we only care about the data (ignore the error).
- Decode the content of data with JSONDecoder
- If anything goes wrong, just go with an empty array
- Eliminate potential complexity to a simple AnyPublisher
- Use the sink to display some information about the final value
- Optional: you can cancel your network request at any time
Error handling
Please introduce some troubleshooting, because I don’t like the idea of hiding errors. That’s a lot better to present a warning with actual error messages, right? ?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | enum HTTPError: LocalizedError { case statusCode } self.cancellable = URLSession.shared.dataTaskPublisher(for: url) .tryMap { output in guard let response = output.response as? HTTPURLResponse, response.statusCode == 200 else { throw HTTPError.statusCode } return output.data } .decode(type: [Post].self, decoder: JSONDecoder()) .eraseToAnyPublisher() .sink(receiveCompletion: { completion in switch completion { case .finished: break case .failure(let error): fatalError(error.localizedDescription) } }, receiveValue: { posts in print(posts.count) }) |
In a nutshell, this time we test the response code and if they get stuck we throw an error. Now, since the publisher can lead to an error state, the sink has a variation where you can check the results of the entire operation, so you can make your own thingy error there, as shown. display a warning. ?
Assign result to property
A common model is to store responses in an internal variable somewhere in the view controller. You can only do this using the assign function.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | class ViewController: UIViewController { private var cancellable: AnyCancellable? private var posts: [Post] = [] { didSet { print("posts --> (self.posts.count)") } } override func viewDidLoad() { super.viewDidLoad() let url = URL(string: "https://jsonplaceholder.typicode.com/posts")! self.cancellable = URLSession.shared.dataTaskPublisher(for: url) .map { $0.data } .decode(type: [Post].self, decoder: JSONDecoder()) .replaceError(with: []) .eraseToAnyPublisher() .assign(to: .posts, on: self) } } |
Very easily, you can also use the didSet to receive notifications about changes.
Group multiple requests
Sending multiple requests is one thing that used to be difficult. Now we have Compose and this task is just ridiculously easy with Publishers.Zip . You can literally combine multiple claimors and wait until both are finished. ?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | let url1 = URL(string: "https://jsonplaceholder.typicode.com/posts")! let url2 = URL(string: "https://jsonplaceholder.typicode.com/todos")! let publisher1 = URLSession.shared.dataTaskPublisher(for: url1) .map { $0.data } .decode(type: [Post].self, decoder: JSONDecoder()) let publisher2 = URLSession.shared.dataTaskPublisher(for: url2) .map { $0.data } .decode(type: [Todo].self, decoder: JSONDecoder()) self.cancellable = Publishers.Zip(publisher1, publisher2) .eraseToAnyPublisher() .catch { _ in Just(([], [])) } .sink(receiveValue: { posts, todos in print(posts.count) print(todos.count) }) |
Request dependency
Sometimes you have to load a resource from a certain URL, and then use that to extend the object with something else. I’m talking about dependency requests, which is pretty much a problem without Combine, but now you can chain two HTTP calls to each other with just a few lines of the following Swift code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | override func viewDidLoad() { super.viewDidLoad() let url1 = URL(string: "https://jsonplaceholder.typicode.com/posts")! self.cancellable = URLSession.shared.dataTaskPublisher(for: url1) .map { $0.data } .decode(type: [Post].self, decoder: JSONDecoder()) .tryMap { posts in guard let id = posts.first?.id else { throw HTTPError.post } return id } .flatMap { id in return self.details(for: id) } .sink(receiveCompletion: { completion in }) { post in print(post.title) } } func details(for id: Int) -> AnyPublisher<Post, Error> { let url = URL(string: "https://jsonplaceholder.typicode.com/posts/(id)")! return URLSession.shared.dataTaskPublisher(for: url) .mapError { $0 as Error } .map { $0.data } .decode(type: Post.self, decoder: JSONDecoder()) .eraseToAnyPublisher() } |
The trick is that you can flatMap a publisher into another.
Conclusion
Combine is a great framework, it can do a lot, but it certainly has some learning curve. Sadly, you can only use it if you are targeting iOS13 or higher so think before applying this new technology.
You should also note that currently (b6) does not have upload / downloadTaskPublisher, perhaps in a later beta seed we will see something like that. ?
Thank you for your interest in this article, which has been translated according to the article of the same name by Tibor Bödecs .