Welcome 👋 to part one of the Swift Network Layer Series where we will be laying the foundation for our network layer.
Pre-requisites
- Xcode 11
- Basic understanding of Swift & Networking
API Design
Things I like to keep in mind when designing an API are syntax and style. Syntax defines whether the API is declarative (which defines the computational logic i.e the "what") or imperative (defines state changes i.e the "how") whereas style defines whether the design paradigm is concrete or abstracted with generics and protocols.
The goal of this network layer is to design an API that is easy to use whilst being powerful and easily customizable. To achieve this we need to set some requirements for the framework:
- Generic
- Typesafe
- Declarative
- Single source of truth
- Abstracts implementation details
With the requirements set out what I usually do is dive straight in Xcode and try to imagine what using this framework would look like.
To start we’ll create a new Xcode project and add the following line to ViewController.swift
.
let publisher = client.request([User].self, from: .users)
With the line above we have set a clear goal for what we aim to achieve. One thing I love the most about Swift is the language "readability". When designing an API I aim to have it read like a normal English sentence.
Reading the above line of code: "Client request list of User from users”.
Not exactly everyday language but it’s close enough to give an idea of what that line of code does without any prior knowledge of the implementation.
Let’s break down our initial line of code even further and look at each component
- publisher: hints to the use some publisher-subscriber pattern
- client: is able to perform requests
- User: is some model representing a user
- .users: is some enum encapsulating a resource from which Users can be requested
The client is requesting a list of type User from users and the response will be published to the publisher.
Right now Xcode should be throwing a couple of errors. Xcode is unable to resolve what the client and User are. The next task is to get rid of the errors we currently have and make Xcode happy. This approach to development lends itself well to TDD (Test Driven Development). Whilst we won’t be writing unit tests in this section we will be following a similar pattern of RED, GREEN, REFACTOR found in TDD. I like to call this approach something more sinister like “Error Driven Development” 😅.
Code
Folder Structure
User
For part one, we will be making use of the JSON Placeholder API. If you go to jsonplaceholder.typicode.com/users in your browser you will see a JSON response containing an array of users. Our User model will be a representation of this response. Add the code below to a file of your liking Network.swift
or ViewController.swift
.
- One error down, on to the next one 👉
struct User: Decodable {
let id: Int
let name: String
let username: String
let email: String
}
Client
The next error we have to address is the unresolved identifier client
. In Client.swift
add the following code
final class Client { }
Now where you declared your publisher above go ahead and create a new instance of Client.
let client = Client()
At this point, you should have the following error in Xcode:
In understandable English, the first error thrown here is telling us that Xcode is unable to infer what we mean by .users
.
The second error is easier to understand, there is no method named request on the client.
Let’s address the easier of the two errors by declaring our request method on the client.
ClientType
In Client.swift
add the following protocol and implementation:
import Combine
import Foundation
protocol HTTPClient {
associatedtype Route: RouteProvider
func request<T: Decodable>(_ model: T.Type,
from route: Route,
urlSession: URLSession) -> AnyPublisher<HTTPResponse<T>, HTTPError>
}
final class Client<Route: RouteProvider>: HTTPClient {
func request<T>(_ model: T.Type,
from route: Route,
urlSession: URLSession = .shared) -> AnyPublisher<HTTPResponse<T>, HTTPError> {
fatalError("Not implemented yet")
}
}
We declare a HTTPClient protocol which will serve as the blueprint for our client. First, we provide the protocol with an associatedtype of RouteProvider.
Declare the request function which takes in a model parameter that will represent the response object we want to decode from the request. The route parameter will represent the HTTP route to fetch the expected response object. The last argument is the URLSession which will allow us to pass in a URLSession to use with the request. The function returns a publisher with a generic HTTPResponse on our model object and an HTTPError.
Create the Client class which will implement the HTTPClient protocol. For now, we will leave the implementation empty. I’ve added a fatalError message to ensure that we get back to it. In the code above we made RouteProvider, HTTPResponse, and HTTPError which are types we will declare next.
RouteProvider
This is what I would call the star of the show. RouteProvider defines a protocol for which we can build requests. By using a protocol we can give flexibility to how requests are formed. We can decide to implement the RouteProvider with a class, struct, or enum. I’ve decided to go with the enum approach. This provides users with a single source of truth. We know every time that a request is made to a specific endpoint it will be built using the same parameters.
protocol RouteProvider {
var baseURL: URL { get }
var path: String { get }
var method: HTTPMethod { get }
var headers: [String: String] { get }
}
PlaceHolderRoute
Declare a Route which will implement the RouteProvider protocol. I’ve made use of the JSONPlaceholder API and added a single case to get users to demonstrate.
enum PlaceHolderRoute {
case users
}
extension PlaceHolderRoute: RouteProvider {
var baseURL: URL {
guard let url = URL(string: "https://jsonplaceholder.typicode.com") else {
fatalError("Base URL could not be configured.")
}
return url
}
var path: String {
switch self {
case .users: return "/users"
}
}
var method: HTTPMethod {
switch self {
case .users: return .get(nil)
}
}
var headers: [String : String] {
return [:]
}
}
HTTPMethod
The HTTPMethod enum will be used to state which HTTP method to use for the request. We will only be supporting the most common HTTP method types in our framework. The HTTP spec does not limit the number of HTTP method types, in fact, there are other spec implementations that use methods like “COPY” and “LOCK” but you don’t come across such often.
enum HTTPMethod {
case get([String: String]? = nil)
case put(HTTPContentType)
case post(HTTPContentType)
case patch(HTTPContentType)
case delete
public var rawValue: String {
switch self {
case .get: return "GET"
case .put: return "PUT"
case .post: return "POST"
case .patch: return "PATCH"
case .delete: return "DELETE"
}
}
}
We once again make use of associated values in our enums to enable us to pass in values to the HTTPMethod.
The GET case will enable us to pass in URL query parameters with our request. We can construct use .get(["page": 2])
to construct a URL like: http://api.test.com/content?page=2
For PUT, POST and, PATCH we make use of HTTPContentType. This type will enable us to post encoded JSON or even an encoded dictionary in the body of the request.
HTTPContentType
enum HTTPContentType {
case json(Encodable?)
case urlEncoded(EncodableDictionary)
var headerValue: String {
switch self {
case .json: return "application/json"
case .urlEncoded: return "application/x-www-form-urlencoded"
}
}
}
By setting the headerValue here we can tightly couple the expected header to the type being sent. This will ensure that we always send the right headers.
EncodableDictionary
This protocol URL encodes Dictionary keys and values and returns them as Data.
Input: [“Name”: “Malcolm K”, “Emoji”: “🍩”]
Output: Name=Malcolm%20K&Emoji=%F0%9F%8D%A9
(string representation of data)
protocol EncodableDictionary {
var asURLEncodedString: String? { get }
var asURLEncodedData: Data? { get }
}
extension Dictionary: EncodableDictionary {
var asURLEncodedString: String? {
var pairs: [String] = []
for (key, value) in self {
pairs.append("\(key)=\(value)")
}
return pairs
.joined(separator: "&")
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
}
var asURLEncodedData: Data? { asURLEncodedString?.data(using: .utf8) }
}
HTTPError
When communicating with a server via internet errors are expected. Errors can happen due to poor internet connectivity or the server is down. We need a way to handle these errors gracefully. Create and HTTPError struct with a nested Code enum with all the errors you wish to handle.
HTTPURLResponse provides us with an error message given a status code. We will leverage this functionality to display an appropriate message.
print(HTTPURLResponse.localizedString(forStatusCode: 400)) // prints “bad request”
We can decide to either make use of the system provided message or use the Code enum to provide a custom error message as seen below.
struct HTTPError: Error {
private enum Code: Int {
case unknown = -1
case networkUnreachable = 0
case unableToParseResponse = 1
case badRequest = 400
case internalServerError = 500
}
let route: RouteProvider?
let response: HTTPURLResponse?
let error: Error?
var message: String {
switch Code(rawValue: response?.statusCode ?? -1) {
case .unknown:
return "Something went wrong"
case .networkUnreachable:
return "Please check your internet connectivity"
default:
return systemMessage
}
}
private var systemMessage: String {
HTTPURLResponse.localizedString(forStatusCode: response?.statusCode ?? 0)
}
}
Here we store a few properties: code, route, response and, error. This gives us flexibility as to how to handle the error. We can decide to show a toast with a message in the response. If needed we can retry the request as we have the route. We can also log an error to a logger or analytics service.
HTTPResponse
Finally, we have HTTPResponse which is a generic type constrained to Decodable. We constrain to Decodable as we expect JSON back from the server which we will decode to a model object.
struct HTTPResponse<T: Decodable> {
let route: RouteProvider
let response: HTTPURLResponse?
let data: Data?
var decoded: T? {
guard let data = data else { return nil }
return try? JSONDecoder().decode(T.self, from: data)
}
}
Conclusion
That brings us to the end of part one! Our goal was to design a generic, typesafe and, declarative API for our networking library. We started with this line of code giving us some errors.
let publisher = client.request([User].self, from: .users)
No more errors. 👍 Well done on getting this far! 🥳 But, wait when we build and run nothing is happening yet? 🤔
See you in part two where we will start building our URLRequests using Combine and handle server responses!
Thanks to Mpendulo Ndlovu for his editorial work on this post.
Resources
- Swift Enum Documentation
- HTTP in Swift by Dave DeLong
- URLs in Swift by Antoine van der Lee
- Why Swift enums with associated values can’t have a rawValue by Mischa Hildebrand