Because the application will interface with a number of endpoints of the mock API, we need to make sure the API client is easy to extend. The more we can reduce code duplication, the easier it is to extend and maintain the API client. In this episode, I show you how to use generics to make the API client extensible and easy to maintain.
Defining an API Endpoint
Before we start, we add a bit of structure to the Networking group. Add a group with name Types to the Networking group and move APIError.swift into the Types group. The plan for this episode is to create a type that defines an API endpoint. Add a Swift file to the Types group and name it APIEndpoint.swift. We define an enum with name APIEndpoint
.
import Foundation
enum APIEndpoint {
}
Each case of the APIEndpoint
enum defines an endpoint of the mock API. Let's add a case with name episodes
for the endpoint that returns the list of episodes.
import Foundation
enum APIEndpoint {
// MARK: - Cases
case episodes
}
The APIEndpoint
enum exposes a computed property, request
, of type URLRequest
. The idea is for the APIEndpoint
enum to encapsulate the details that are needed to create and configure the request the API client sends to the mock API.
import Foundation
enum APIEndpoint {
// MARK: - Cases
case episodes
// MARK: - Properties
var request: URLRequest {
}
}
To create and configure the URLRequest
object, the APIEndpoint
enum defines a number of private, computed properties. We start with url
of type URL
. The URL of the request consists of the base URL of the API and the path of the API endpoint.
private var url: URL {
Environment.apiBaseURL.appendingPathComponent(path)
}
The next step is implementing the computed path
property. It is declared privately and of type String
. We use a switch
statement to return the path for each API endpoint. A switch
statement may seem unnecessary, but remember that we are making the networking layer easy to extend and maintain.
private var path: String {
switch self {
case .episodes:
return "episodes"
}
}
We also define a computed property that returns the headers for the request. Before we implement the computed property, we define a type alias to make it easier to work with headers. Add a Swift file to the Configuration group and name it TypeAliases.swift. We define a type alias, Headers
, for a dictionary with keys of type String
and values of type String
. Type aliases are helpful to keep the code you write readable and easy to understand.
import Foundation
typealias Headers = [String:String]
Revisit APIEndpoint.swift and declare a computed property, headers
, of type Headers
. The computed property returns two headers for now. The first header defines the content type of the request. The value of the header is application/json
. The second header provides the API token. We discussed this earlier in this series.
private var headers: Headers {
[
"Content-Type": "application/json",
"X-API-TOKEN": Environment.apiToken
]
}
To add headers to a request, we implement a convenience method. At the bottom of APIEndpoint.swift, add a fileprivate
extension for URLRequest
. The extension defines a mutating method, addHeaders(_:)
, that accepts a Headers
object as its only argument. In the body of the addHeaders(_:)
method, the request iterates over the headers, adding each header by invoking the addValue(_:forHTTPHeaderField:)
method.
extension URLRequest {
mutating func addHeaders(_ headers: Headers) {
headers.forEach { header, value in
addValue(value, forHTTPHeaderField: header)
}
}
}
Before we implement the computed request
property, we define a computed property for the HTTP method of the request. We first create an enum that defines the HTTP method of a request. The type of the httpMethod
property of URLRequest
is String
and that means it is too easy to introduce a bug by making a typo. This is easy to avoid by using an enum.
Add a Swift file to the Types group and name it HTTPMethod.swift. Define an enum, HTTPMethod
, with four cases, get
, put
, post
, and delete
. The HTTP specification defines more HTTP methods, but this is fine for now. Notice that the raw value of HTTPMethod
is string.
import Foundation
enum HTTPMethod: String {
case get
case put
case post
case delete
}
Head back to APIEndpoint.swift and define a computed property with name, httpMethod
, of type HTTPMethod
. We can copy the body of the computed path
property. Instead of returning the path of the episodes
endpoint, we return the HTTP method, get
.
private var httpMethod: HTTPMethod {
switch self {
case .episodes:
return .get
}
}
It is time to implement the computed request
property. We first create a mutable URLRequest
object by passing the value of the computed url
property to the initializer.
var request: URLRequest {
var request = URLRequest(url: url)
}
To add the headers to the request, we invoke the addHeaders(_:)
method on the request, passing in the value of the computed headers
property. Setting the HTTP method is easy. We assign the raw value of the computed httpMethod
property to the httpMethod
property of the request.
var request: URLRequest {
var request = URLRequest(url: url)
request.addHeaders(headers)
request.httpMethod = httpMethod.rawValue
return request
}
Adding Generics to the Mix
The APIEndpoint
enum is only half the solution I have in mind. Open APIClient.swift. We define a private method, request(_:)
, that accepts an APIEndpoint
object as its only argument and returns a publisher.
private func request(_ endpoint: APIEndpoint) -> AnyPublisher {
}
We move the implementation of the episodes()
method to the request(_:)
method. This drastically simplifies the implementation of the episodes()
method. We invoke the request(_:)
method, passing in an APIEndpoint
object.
func episodes() -> AnyPublisher<[Episode], APIError> {
request(.episodes)
}
Before we can test the changes, we need to tie up a few loose ends. We need to define the Output
and Failure
types of the publisher the request(_:)
method returns. The Failure
type is APIError
, but what about the Output
type. The Output
type is defined at the call site and that allows us to make use of generics. Let me show you how that works.
We define the Output
type of the publisher as T
, a generic type. This means that the closure of the tryMap
operator returns an object of type T
. We have one problem, though. The JSON decoder requires that the type of the value to decode conforms to the Decodable
protocol. This isn't a problem because Swift allows us to define requirements for the generic type. We do this in a pair of angle brackets following the method name. This is similar to conforming a type declaration to a protocol. This little change gets rid of the last compiler error.
private func request<T: Decodable>(_ endpoint: APIEndpoint) -> AnyPublisher<T, APIError> {
var request = URLRequest(url: Environment.apiBaseURL.appendingPathComponent("episodes"))
request.addValue(Environment.apiToken, forHTTPHeaderField: "X-API-TOKEN")
return URLSession.shared.dataTaskPublisher(for: request)
.tryMap { data, response -> T in
guard
let response = response as? HTTPURLResponse,
(200..<300).contains(response.statusCode)
else {
throw APIError.failedRequest
}
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
print("Unable to Decode Response \(error)")
throw APIError.invalidResponse
}
}
.mapError { error -> APIError in
switch error {
case let apiError as APIError:
return apiError
case URLError.notConnectedToInternet:
return APIError.unreachable
default:
return APIError.failedRequest
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
Build and run the application to make sure we didn't break anything.
What's Next?
Thanks to generics and the APIEndpoint
enum, adding support for additional endpoints of the mock API is straightforward. In the next episode, we add the ability for the user to sign in with their email and password.