We successfully fetched a list of episodes from the Cocoacasts API and converted the data into model objects. The code we wrote works, but it is far from finished. In this episode, we create a dedicated object that manages the communication with the Cocoacasts API.
Creating an API Client
Open the Project Navigator on the right and create a file in the Sources group. Name the file APIClient.swift. Define a public class with name APIClient. We mark the class as final because it shouldn't be subclassed.
import Foundation
public final class APIClient {
}
We start by defining a private, constant property, baseUrl, of type URL. As the name implies, the baseUrl property defines the base URL of the server the API client talks to.
import Foundation
public final class APIClient {
// MARK: - Properties
private let baseUrl: URL
}
The baseUrl property is set during initialization. We create a public initializer that defines a single parameter, baseUrl, of type URL. We set the baseUrl property in the body of the initializer.
import Foundation
public final class APIClient {
// MARK: - Properties
private let baseUrl: URL
// MARK: - Initialization
public init(baseUrl: URL) {
// Set Properties
self.baseUrl = baseUrl
}
}
Fetching Episodes
The next step is implementing a method to fetch the list of episodes. We define a public method with name fetchEpisodes(_:). The method accepts a completion handler, a closure, as its only argument. The completion handler is executed when the API request completes, successfully or unsuccessfully. The closure accepts an optional array of Episode objects and an optional Error object, and it returns Void.
// MARK: - Public API
public func fetchEpisodes(_ completion: @escaping ([Episode]?, Error?) -> Void) {
}
Before we continue, I would like to make one improvement by defining a custom error type for the APIClient class. We define a public enum with name APIClientError that conforms to the Error protocol.
import Foundation
public final class APIClient {
// MARK: - Types
public enum APIClientError: Error {
}
...
}
We replace Error with APIClientError in the fetchEpisodes(_:) method.
// MARK: - Public API
public func fetchEpisodes(_ completion: @escaping ([Episode]?, APIClientError?) -> Void) {
}
In the body of the fetchEpisodes(_:) method, we create the URL for the API request by appending episodes to the base URL. We copy the rest of the implementation from the playground.
// MARK: - Public API
public func fetchEpisodes(_ completion: @escaping ([Episode]?, APIClientError?) -> Void) {
// Create URL
let url = baseUrl.appendingPathComponent("episodes")
// Create Request
var request = URLRequest(url: url)
// Add X-API-Key Header Field
request.addValue("9e8d3f9fd2ce713bb1ca8e60021e09d0dc6fb654", forHTTPHeaderField: "X-API-Key")
// Create and Initiate Data Task
URLSession.shared.dataTask(with: request) { (data, response, error) in
if let data = data {
do {
// Initialize JSON Decoder
let decoder = JSONDecoder()
// Configure JSON Decoder
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
// Decode JSON Response
let episodes = try decoder.decode([Episode].self, from: data)
print(episodes.count)
} catch {
print("Unable to Decode Response \(error)")
}
} else {
if let error = error {
print("Unable to Fetch Episodes \(error)")
} else {
print("Unable to Fetch Episodes")
}
}
}.resume()
}
Instead of printing the result of the API request to the console, we pass the array of Episode objects to the completion handler in the do clause of the do-catch statement.
The body of the catch clause is executed if the JSONDecoder instance throws an error. In that scenario, we pass an error to the completion handler. We add a case, invalidResponse, to the APIClientError enum. The invalidResponse error is passed to the completion handler in the catch clause of the do-catch statement.
We also need to define an error for the scenario in which the API request fails for some reason. We add another case, requestFailed, to the APIClientError enum. The requestFailed error is passed to the completion hander in the else clause of the if-else statement.
// MARK: - Types
public enum APIClientError: Error {
case requestFailed
case invalidResponse
}
// MARK: - Public API
public func fetchEpisodes(_ completion: @escaping ([Episode]?, APIClientError?) -> Void) {
// Create URL
let url = baseUrl.appendingPathComponent("episodes")
// Create Request
var request = URLRequest(url: url)
// Add X-API-Key Header Field
request.addValue("9e8d3f9fd2ce713bb1ca8e60021e09d0dc6fb654", forHTTPHeaderField: "X-API-Key")
// Create and Initiate Data Task
URLSession.shared.dataTask(with: request) { (data, response, error) in
if let data = data {
do {
// Initialize JSON Decoder
let decoder = JSONDecoder()
// Configure JSON Decoder
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
// Decode JSON Response
let episodes = try decoder.decode([Episode].self, from: data)
// Invoke Handler
completion(episodes, nil)
} catch {
// Invoke Handler
completion(nil, .invalidResponse)
}
} else {
// Invoke Handler
completion(nil, .requestFailed)
if let error = error {
print("Unable to Fetch Episodes \(error)")
} else {
print("Unable to Fetch Episodes")
}
}
}.resume()
}
Let's take the APIClient class for a spin. Revisit the playground. Rename url to baseUrl and remove episodes from the string that is passed to the initializer.
import Foundation
// Create Base URL
let baseUrl = URL(string: "http://0.0.0.0:3000/api/")!
We pass baseUrl to the initializer of the APIClient class to create an instance. To fetch the list of episodes, we invoke the fetchEpisodes(_:) method. In the completion handler, we print the number of episodes returned by the Cocoacasts API.
import Foundation
// Create Base URL
let baseUrl = URL(string: "http://0.0.0.0:3000/api/")!
// Fetch Episodes
APIClient(baseUrl: baseUrl).fetchEpisodes { (episodes, error) in
print(episodes?.count)
}
Let's execute the contents of the playground to make sure we didn't break anything.
Optional(20)
Injecting the API Key
There are several ways to improve the implementation of the APIClient class. We start by injecting the API key into the APIClient instance, similar to how the base URL of the server is injected. We define a private property, apiKey, of type String.
import Foundation
public final class APIClient {
// MARK: - Types
public enum APIClientError: Error {
case requestFailed
case invalidResponse
}
// MARK: - Properties
private let apiKey: String
// MARK: -
private let baseUrl: URL
...
}
We also need to update the initializer of the APIClient class. We define a parameter, apiKey, of type String and set the apiKey property in the body of the initializer.
// MARK: - Initialization
public init(apiKey: String, baseUrl: URL) {
// Set Properties
self.apiKey = apiKey
self.baseUrl = baseUrl
}
We no longer need to hard code the API key in the APIClient class. We can use the value stored in the apiKey property in the fetchEpisodes(_:) method.
// MARK: - Public API
public func fetchEpisodes(_ completion: @escaping ([Episode]?, APIClientError?) -> Void) {
// Create URL
let url = baseUrl.appendingPathComponent("episodes")
// Create Request
var request = URLRequest(url: url)
// Add X-API-Key Header Field
request.addValue(apiKey, forHTTPHeaderField: "X-API-Key")
...
}
Revisit the playground and define a constant with name apiKey. We assign the API key to the the apiKey constant and pass it to the initializer of the APIClient class.
import Foundation
// Define API Key
let apiKey = "9e8d3f9fd2ce713bb1ca8e60021e09d0dc6fb654"
// Create Base URL
let baseUrl = URL(string: "http://0.0.0.0:3000/api/")!
// Fetch Episodes
APIClient(apiKey: apiKey, baseUrl: baseUrl).fetchEpisodes { (episodes, error) in
print(episodes?.count)
}
Improving the Result
Performing an API request succeeds or fails. Success and failure are the only options. This implies that the completion handler of the fetchEpisodes(_:) method accepts an array of Episode objects or an Error object. There is no scenario in which both arguments of the completion handler are nil.
This pattern is common in Swift and many other programming languages and that is why the Result type was introduced in Swift 5. I have used custom result types in the past to work around this issue, but that is no longer necessary. Let's refactor the APIClient class by leveraging the Result enum.
The idea is simple. The Result enum defines a success case and a failure case. The success case accepts an associated value of a type we define and the failure case accepts an associated value of type Error. We define the result type in the method signature of the fetchEpisodes(_:) method. The associated value of the success case is of type [Episode]. The associated value of the failure case is of type APIClientError.
// MARK: - Public API
public func fetchEpisodes(_ completion: @escaping (Result<[Episode], APIClientError>) -> Void) {
...
}
The changes we need to make to the body of the fetchEpisodes(_:) method are small. We only need to change the arguments that we pass to the completion handler. We pass success to the completion handler if the API request is successful and we pass failure to the completion handler if anything goes wrong.
// MARK: - Public API
public func fetchEpisodes(_ completion: @escaping (Result<[Episode], APIClientError>) -> Void) {
// Create URL
let url = baseUrl.appendingPathComponent("episodes")
// Create Request
var request = URLRequest(url: url)
// Add X-API-Key Header Field
request.addValue(apiKey, forHTTPHeaderField: "X-API-Key")
// Create and Initiate Data Task
URLSession.shared.dataTask(with: request) { (data, response, error) in
if let data = data {
do {
// Initialize JSON Decoder
let decoder = JSONDecoder()
// Configure JSON Decoder
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
// Decode JSON Response
let episodes = try decoder.decode([Episode].self, from: data)
// Invoke Handler
completion(.success(episodes))
} catch {
// Invoke Handler
completion(.failure(.invalidResponse))
}
} else {
// Invoke Handler
completion(.failure(.requestFailed))
if let error = error {
print("Unable to Fetch Episodes \(error)")
} else {
print("Unable to Fetch Episodes")
}
}
}.resume()
}
There are several important benefits to this approach. The API clearly communicates the possible outcomes of the API request. The API request succeeds or fails. There is no third option.
Another advantage becomes clear at the call site. Revisit the playground. We use a switch statement to handle the result of the API request and we no longer need to deal with optionals. We have access to an array of Episode objects if the API request succeeds. If the API request fails, then we have access to an APIClientError object.
import Foundation
// Define API Key
let apiKey = "9e8d3f9fd2ce713bb1ca8e60021e09d0dc6fb654"
// Create Base URL
let baseUrl = URL(string: "http://0.0.0.0:3000/api/")!
// Fetch Episodes
APIClient(apiKey: apiKey, baseUrl: baseUrl).fetchEpisodes { (result) in
switch result {
case .success(let episodes):
print(episodes.count)
case .failure(let error):
print(error)
}
}
Let's execute the contents of the playground one more time to see the result.
20
Preparing for the Future
There is one last improvement I want to make before we integrate the APIClient class into the project. The Cocoacasts client will need more than a list of episodes and we might as well prepare for the future. I would like to make the APIClient class more flexible.
Let's revisit APIClient.swift and define a private enum with name Endpoint. The raw values of the Endpoint enum are of type String. The idea is simple. As the name implies, an Endpoint object defines an endpoint of the Cocoacasts API. We start with one case for the episodes endpoint and we appropriately name it episodes.
private enum Endpoint: String {
// MARK: - Cases
case episodes
}
We define a computed property, path, of type String. The path computed property returns the raw value. This isn't strictly necessary, but it improves the readability of the code we write.
private enum Endpoint: String {
// MARK: - Cases
case episodes
// MARK: - Properties
var path: String {
return rawValue
}
}
The next step is implementing a private helper method, request(for:). This method accepts an Endpoint object and returns a URLRequest object.
// MARK: - Helper Methods
private func request(for endpoint: Endpoint) -> URLRequest {
}
The body of the method isn't complex. We create the URL for the API request by appending the path of the endpoint to the base URL. The URL object is used to create the URLRequest object. The X-API-Key header field is defined and we also set the Content-Type header field to application/json.
// MARK: - Helper Methods
private func request(for endpoint: Endpoint) -> URLRequest {
// Create URL
let url = baseUrl.appendingPathComponent(endpoint.path)
// Create Request
var request = URLRequest(url: url)
// Configure Request
request.addValue(apiKey, forHTTPHeaderField: "X-API-Key")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
return request
}
With the request(for:) method in place, we can update the fetchEpisodes(_:) method. We no longer need to create the URLRequest object in the fetchEpisodes(_:) method. We invoke the request(for:) method with episodes as the argument and pass the result to the dataTask(with:completionHandler:) method of the URLSession class. It's a small but welcome improvement.
// MARK: - Public API
public func fetchEpisodes(_ completion: @escaping (Result<[Episode], APIClientError>) -> Void) {
// Create and Initiate Data Task
URLSession.shared.dataTask(with: request(for: .episodes)) { (data, response, error) in
...
}.resume()
}
What's Next?
The APIClient class manages the communication with the Cocoacasts API and exposes an elegant and easy to use API. In the next episode, we integrate the APIClient class into the project and populate the feed view controller's collection view with data.