Posted 1/23/2018.
Almost every app we write needs to retrieve and serialize data from an API. Adding networking and serialization to an app can require a lot of setup, which is why open-source frameworks like Alamofire, Moya, and SwiftyJSON have been written to streamline this process. However, using URLSession and the new Codable protocol introduced in Swift 4, we'll see how easy it can be to interact with an API using only what's available to us in Foundation. We'll also see how we can use generics to maintain separation between our networking and model layers.
In this tutorial we'll use the GitHub API to retrieve a user's profile and list of repositories. The finished app will look like this:
You can download the starter project here. It contains the basic UI and five classes we'll be working with:
APIService
- Uses URLSession to retrieve data from a URL.ProfileViewController
- Our app's main view controller, which will coordinate requesting and displaying the data.ProfileHeaderView
- Displays a GitHub user's name, blog URL, and profile image.Profile
- The model for a GitHub user.Repository
- The model for a GitHub repository.URLSession
"URLSession" refers both to a class and a set of related APIs used to download and upload content. We'll use it here to make HTTP requests to the GitHub API, but the URLSession APIs are rich in functionality, allowing us to manage authentication, credentials, caching, and cookie storage and to support other protocols (e.g. FTP).
A URLSession uses "tasks" to download or upload content from / to a URL. The base class for a task is URLSessionTask
. We'll be using a URLSessionTask subclass, URLSessionDataTask
, which returns a Data
value that we can serialize into a model or image and display to the user.
For more on URLSession, see the URLSession Programming Guide
Codable
Codable is a new protocol introduced in Swift 4. As Apple describes in the documentation, a Codable type "can convert itself into and out of an external representation". Codable is actually a typealias for two other protocols, Encodable
and Decodable
, which describe the requirements for converting the type to and from this representation, respectively. Apple has so far provided encoders and decoders for working with JSON (which we'll use here) and property lists, but the protocols could be used to support any data representation.
For more on Codable, see Encoding and Decoding Custom Types.
The first class we'll fill in is the APIService. There's a lot to unpack in the outline of this file. Let's start with the two enums declared at the top, APIError
and Result
.
enum APIError: Error {
case missingData
}
enum Result<T> {
case success(T)
case failure(Error)
}
The requests we make will either return a Data
value or an Error
. If the API doesn't return an error but we also don't have a valid data response, we'll still want to return an error to the caller. We'll use this APIError
to do that.
After the request has completed, we'll want to tell the caller whether it succeeded or failed, and pass back either the serialized response value or error. Result
allows us to do that. The "success" case has a generic associated value which can contain any successful response value, and the "failure" case has an associated value that can be anything conforming to the Error
protocol, such as our APIError
above.
Next let's look at the setup at the top of the APIService
class:
static let shared = APIService()
let defaultSession = URLSession(configuration: .default)
typealias SerializationFunction<T> = (Data?, URLResponse?, Error?) -> Result<T>
The "shared" property simply makes APIService a singleton. The "defaultSession" property declares the URLSession we will use to make requests with the default configuration (see URLSessionConfiguration in the documentation for other possible options).
Finally, we declare a typealias for a "SerializationFunction". The reason we do this is because we will be using a URLSessionDataTask to retrieve both JSON data (e.g. GitHub profile, list of repositories) and image data (the user's profile image). We can share most of the code for retrieving this data using URLSession, but the JSON data and image data will be serialized differently. Using this typealias, we can pass the data, response, and error values we get back from URLSession into any serliazation function we want to write. The generic type "T" will represent the serialized value, which we wrap in the Result
enum we declared above to model success or failure.
Now we're ready to actually use URLSession to retrieve the data. Fill in the first function in this class as follows:
@discardableResult
private func request<T>(_ url: URL, serializationFunction: @escaping SerializationFunction<T>,
completion: @escaping (Result<T>) -> Void) -> URLSessionDataTask {
let dataTask = defaultSession.dataTask(with: url) { data, response, error in
let result: Result<T> = serializationFunction(data, response, error)
DispatchQueue.main.async {
completion(result)
}
}
dataTask.resume()
return dataTask
}
This function accepts a URL, serialization function, and a completion block, which we'll use to pass the result of the request back to the caller. We start by using the URLSession to get a data task with the URL that was passed in. URLSession calls a completion block with the data, response, and error values for the request.
In the completion block, we call the serialization function that was passed in to generate a result. Since URLSession by default returns data on a background queue, we first dispatch to the main queue and then pass the result into our function's completion block.
We just created a data task, but URLSession tasks by default start in a suspended state. We need to call resume()
on the task so that URLSession will actually make the request. Finally, we return the data task so that the caller can cancel it if needed.
Next, let's use this function to request and serialize JSON. Replace the next function with the following:
@discardableResult
func request<T: Decodable>(_ url: URL, completion: @escaping (Result<T>) -> Void) -> URLSessionDataTask {
return request(url, serializationFunction: serializeJSON, completion: completion)
}
Here we specify that the serialized value must be Decodable
using a generic constraint. We also pass in a function called "serializeJSON", which will be responsible for decoding the JSON data and serializing it into a model value that we can pass back to the caller. Let's write that now:
private func serializeJSON<T: Decodable>(with data: Data?, response: URLResponse?, error: Error?) -> Result<T> {
if let error = error { return .failure(error) }
guard let data = data else { return .failure(APIError.missingData) }
do {
let serializedValue = try JSONDecoder().decode(T.self, from: data)
return .success(serializedValue)
} catch let error {
return .failure(error)
}
}
If we got an error, we immediately wrap it in a "failure" Result
value and return. Similarly, if we don't have an error but our Data
value is nil
, we also return a failure. Otherwise, we use a JSONDecoder to decode the data into our generic model value (of type "T") and return the appropriate Result for success or failure.
Now we can use the same pattern for retrieving images:
@discardableResult
func requestImage(withURL url: URL, completion: @escaping (Result<UIImage>) -> Void) -> URLSessionDataTask {
return request(url, serializationFunction: serializeImage, completion: completion)
}
private func serializeImage(with data: Data?, response: URLResponse?, error: Error?) -> Result<UIImage> {
if let error = error { return .failure(error) }
guard let data = data, let image = UIImage(data: data) else { return .failure(APIError.missingData) }
return .success(image)
}
This is where our generic setup really shines. We're able to reuse all of the URLSession code and only need to differentiate the serialization steps. Also notice what's not in this file. Our APIService has no concept of GitHub, profiles, repositories, or any model types. However, we still get a fully serialized value passed back in the request function's completion block.
Now that we have our networking layer, it's time to use it! We'll be loading a GitHub user's profile and list of repos, so let's check out the models for Profile
and Repository
.
final class Profile: Codable {
let id: Int
let name: String
let blog: String
let avatarURL: String
private enum CodingKeys: String, CodingKey {
case id
case name
case blog
case avatarURL = "avatar_url"
}
}
final class Repository: Codable {
let id: Int
let name: String
let description: String?
let starCount: Int
private enum CodingKeys: String, CodingKey {
case id
case name
case description
case starCount = "stargazers_count"
}
}
This is standard Codable
setup. Again, for more on this see Encoding and Decoding Custom Types.
To finish the app, we have three functions in ProfileViewController
that need to be filled in. In the app, we will load the data for a GitHub username when the user enters a search term. Find the searchBarSearchButtonClicked
delegated method at the bottom of the file and replace it with the following:
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
guard let query = searchBar.text, query.count > 0 else { return }
tasks.forEach { $0.cancel() }
loadData(withUsername: query)
searchBar.resignFirstResponder()
}
First we check that we have a valid query. Then we cancel any data tasks that are currently in-flight (we'll keep track of these above). Finally, we call loadData
with the search query, which we assume to be a GitHub username, and dismiss the keyboard. The loadData
function calls loadProfile
and loadRepositories
. Let's write those now. Find the declarations above and replace them with the following:
func loadProfile(withUsername username: String) {
guard let url = URL(string: baseURL + username) else { return }
let task = APIService.shared.request(url) { [weak self] (result: Result<Profile>) in
switch result {
case .success(let profile):
self?.headerView.configure(with: profile)
case .failure(let error):
print(error.localizedDescription)
}
}
tasks.append(task)
}
func loadRepositories(withUsername username: String) {
guard let url = URL(string: baseURL + username + "/repos") else { return }
let task = APIService.shared.request(url) { [weak self] (result: Result<[Repository]>) in
switch result {
case .success(let repositories):
self?.repositories = repositories.sorted(by: { $0.starCount > $1.starCount })
self?.tableView.reloadData()
case .failure(let error):
print(error.localizedDescription)
}
}
tasks.append(task)
}
Here we follow the same pattern to load the profile and list of repositories concurrently. The notable piece here is that we need to declare what type the Result
will hold so that the APIService knows what it will be serializing. This is what allows our networking and model layers to be separate. At the bottom of each function, we append the task returned by APIService to an array so that any in-flight requests can be cancelled on each new search.
Build and run the app. You should be able to enter any GitHub username and see its profile and list of repos.
We've seen how the combination of URLSession, Codable, and generics allows us to write a powerful and reusable networking layer. For simplicity, we've focused on GET requests, but we could easily expand our APIService to support a full REST service, as well as other tasks like uploading and downloading files.