A Swift API Client with Associated Types
While there are many frameworks out there aimed at making life easier when dealing with your networking layer, it has become less cumbersome to go without them as Swift continues to mature. Let’s take a look at a simple approach to an API client without using any third party frameworks.
Associated Types?
Associated types are a powerful feature in Swift, yet unknown or often overlooked. To put it simply, they allow you to achieve generic type constraints with protocols. Below follows an excerpt from the official documentation regarding them.
When defining a protocol, it’s sometimes useful to declare one or more associated types as part of the protocol’s definition. An associated type gives a placeholder name to a type that’s used as part of the protocol. The actual type to use for that associated type isn’t specified until the protocol is adopted. Associated types are specified with the associatedtype keyword.
On the surface it sounds straight forward, but the consequences of defining protocols with associated types are profound. Before we jump into using associated types in the context of an API client, going forward you should know that protocols with associated types can only be used as generic constraints. That is to say they cannot be used for type definition, as you would normally use a protocol.
API Resource
Here’s where we leverage Swift’s associatedtype
keyword, to associate our expected response object from the http request with our request object. I’ve chosen to name it Response
accordingly. Adopters of this protocol will be required to provide a concrete typealias
named Response
. By ensuring Response
conforms to Codable
we’ll be able to make use of the new JSON decoding introduced in Swift 4.
public protocol APIResource {
/// The associated response type to use when decoding the response from the API.
associatedtype Response: Codable
/// A dictionary of additional headers to send with the request.
var additionalHeaders: [String: String]? { get }
/// The data sent as the message body of the request.
var httpBody: Data? { get }
/// The HTTP request method.
var httpRequestMethod: HTTPRequestMethod { get }
/// The path subcomponent appended to the base URL.
var path: String { get }
/// An array of query items for the URL, in the order in which they appear.
var queryItems: [URLQueryItem]? { get }
}
extension APIResource {
func buildRequest(withBaseUrl baseUrl: URL?) -> URLRequest? {
guard let url = buildUrl(withBaseUrl: baseUrl) else {
NSLog("failed to build request for resource: \(String(describing: self))")
return nil
}
var request = URLRequest(url: url)
request.httpMethod = httpRequestMethod.rawValue
request.httpBody = httpBody
additionalHeaders?.forEach { key, value in
request.setValue(value, forHTTPHeaderField: key)
}
return request
}
func buildUrl(withBaseUrl baseUrl: URL?) -> URL? {
guard
let baseUrl = baseUrl,
var components = URLComponents(url: baseUrl, resolvingAgainstBaseURL: true)
else {
NSLog("failed to build url for resource: \(String(describing: self))")
return nil
}
components.path = baseUrl.path.appending(path)
components.queryItems = queryItems
return components.url
}
}
API Result
public enum APIResult<T: APIResource> {
case failure(error: APIError)
case success(result: T.Response)
}
HTTP Request Method
public enum HTTPRequestMethod: String {
case delete, get, patch, post, put
}
HTTP Status Code
public enum HTTPStatusCode: Int {
case informational
case success
case redirection
case clientError
case serverError
public init?(rawValue: Int) {
switch rawValue {
case 100...199: self = .informational
case 200...299: self = .success
case 300...399: self = .redirection
case 400...499: self = .clientError
case 500...599: self = .serverError
default: return nil
}
}
var ok: Bool {
rawValue == 200
}
}
API Error
public enum APIError: Error {
/// For client side errors, such as failing to build a request to the server.
case client
/// For decoding errors, such as failing to decode a response from the server.
case decoding(reason: [String: Any]?)
/// For network errors, such as 404 not found etc.
case network(httpStatusCode: HTTPStatusCode)
/// For when the client receives an unexpected response from the server.
case unrecognizedFormat
/// For when the network is unreachable.
case unreachable
}
API Client
The client should be able to handle any arbitrary request, agnostic to it’s concrete type. This will allow us to do most of the heavy lifting using generics inside an extension, rather than cluttering up our concrete client implementations later on. Reachability is also being leveraged here to determine whether the network is reachable.
public protocol APIClient {
/// The base URLComponents.
var baseUrlComponents: URLComponents { get }
/// The decoder to use for JSON decoding.
var decoder: JSONDecoder { get }
/// The session to be used when creating URLSessionDataTasks.
var session: URLSession { get }
/// Cancels all URLSessionTasks for the current session.
func cancelAllTasks()
/// Starts a URLSessionDataTask using the request for the corresponding APIResource.
///
/// - Parameters:
/// - resource: APIResource defining some remote resource.
/// - completion: Completion handler.
/// - Returns: URLSessionDataTask for the corresponding resource, or nil.
func sendRequest<T: APIResource>(for resource: T, completion: @escaping (APIResult<T>) -> Void) -> URLSessionDataTask?
}
public extension APIClient {
func cancelAllTasks() {
session.getAllTasks { tasks in
tasks.forEach { $0.cancel() }
}
}
func sendRequest<T: APIResource>(for resource: T, completion: @escaping (APIResult<T>) -> Void) -> URLSessionDataTask? {
if let reachability = Reachability(), reachability.connection == .none {
completion(.failure(error: APIError.unreachable))
return nil
}
guard
let request = resource.buildRequest(withBaseUrl: baseUrlComponents.url),
let url = request.url?.absoluteString
else {
completion(.failure(error: APIError.client))
return nil
}
let task = session.dataTask(with: request) { data, response, error in
guard
let httpResponse = response as? HTTPURLResponse,
let httpStatusCode = HTTPStatusCode(rawValue: httpResponse.statusCode)
else {
completion(.failure(error: APIError.unrecognizedFormat))
return
}
guard httpStatusCode.ok else {
completion(.failure(error: APIError.network(httpStatusCode: httpStatusCode)))
return
}
do {
completion(.success(result: try self.decoder.decode(T.Response.self, from: data!)))
} catch let error as NSError {
completion(.failure(error: APIError.decoding(reason: error.userInfo)))
return
}
}
defer {
NSLog("starting task with request URL \(url)")
task.resume()
}
return task
}
}
Piecing It Together
Considering everything thus far as been agnostic to it’s concrete type, we can keep our concrete client thin and extensible. It simply manages an operation queue, while facilitating requests in a consumer friendly way, providing handles to operations so the caller can cancel them if required. Let’s hit an endpoint from Reqres for some mock data.
/*
{
"data": {
"id": 2,
"first_name": "Janet",
"last_name": "Weaver",
"avatar": "https://s3.amazonaws.com/uifaces/faces/twitter/josephstein/128.jpg"
}
}
*/
struct User: Codable {
let id: Double
let first_name: String
let last_name: String
let avatar: String
}
struct UserData: Codable {
let data: User
}
struct GetUser: APIResource {
// MARK: - APIResource
typealias Response = UserData
var additionalHeaders: [String: String]?
var httpBody: Data?
var httpRequestMethod: HTTPRequestMethod = .get
var path: String {
return "/api/users/\(id)"
}
var queryItems: [URLQueryItem]?
// MARK: - Parameters
let id: String
// MARK: - Init
init(id: String) {
self.id = id
}
}
class ReqresAPIClient: APIClient {
static let shared = ReqresAPIClient()
// MARK: - APIClient
var baseUrlComponents: URLComponents = {
var components = URLComponents()
components.host = "reqres.in/api/"
components.scheme = "https"
return components
}()
var decoder = JSONDecoder()
var session = URLSession.shared
// MARK: - Init
private init() {}
}
class MyViewController: UIViewController {
// MARK: - Life cycle
override func viewDidLoad() {
super.viewDidLoad()
let resource = GetUser(id: "2")
_ = ReqresAPIClient.shared.sendRequest(for: resource) { result in
switch result {
case .failure(let error):
print(error)
case .success(let response):
print(response)
}
}
}
}