Building an iOS Networking SDK — Part 1
As an iOS developer, you will be writing a lot of network calls. Pretty much every app, in one way or another, makes an API call, and it’s our job to make and handle those requests. Each time I start a new project, I often find that the first thing I do is build the networking layer as I look at each API endpoint I think I will need for my project.
There are a lot of 3rd party libraries out there, such as AlamoFire, but I often find that they don’t fit my unique programming style, so I sought to create my very own robust Networking SDK that I can utilize in all my projects.
This is the first of many parts of my journey to create my iOS Networking Swift Package.
Overview
A strong networking layer has several layers. Below are the steps I took while implementing the first phase of this networking SDK.
- Define NetworkingError
- Setup some HTTP helpers
- Adding encoders
- Building a network request
- Decoding Data
- Validating Data
- Performing network requests
Step 1: Define NetworkingError
While making a network request, there are a plethora of potential errors that can occur. One simple place to start was creating an enum called NetworkingError
to represent every possible error that may occur, making error handling easier.
Note: NetworkingError
must conform to Equatable
public enum NetworkingError: Error {
case unknown
case noURL
case couldNotParse
case invalidError
case noData
case noResponse
case requestFailed(Error)
case noRequest
case noHTTPURLResponse
// Successful Responses (200-299)
case ok
// Redirection Messages (300-399)
case multipleChoices, movedPermanently, found, seeOther, notModified, useProxy, temporaryRedirect, permanentRedirect
// Client Errors (400-499)
case badRequest, unauthorized, paymentRequired, forbidden, notFound, methodNotAllowed, notAcceptable, proxyAuthenticationRequired, requestTimeout, conflict, gone, lengthRequired, preconditionFailed, payloadTooLarge, uriTooLong, unsupportedMediaType, rangeNotSatisfiable, expectationFailed, imATeapot, misdirectedRequest, unprocessableEntity, locked, failedDependency, tooEarly, upgradeRequired, preconditionRequired, tooManyRequests, requestHeaderFieldsTooLarge, unavailableForLegalReasons
// Server Errors (500-599)
case internalServerError, notImplemented, badGateway, serviceUnavailable, gatewayTimeout, httpVersionNotSupported, variantAlsoNegotiates, insufficientStorage, loopDetected, notExtended, networkAuthenticationRequired
}
Explanation:
The first several cases, such as couldNotParse
or noURL
are general errors that may occur while encoding or decoding or unwrapping within the different utility structs.
The cases below are HTTP URL Response status code errors (yes I made a case for EACH one). I’ve separated them based on their general kind. the 300s, 400s, and 500s. The 200s (ok
) is techniquely the only one that’s not a real error
Step 2: Setup some HTTP helpers
HTTPMethod
Every API call requires an HTTP Method. This can easily be represented with a simple enum
public enum HTTPMethod: String {
case GET, POST, PUT, DELETE
}
HTTP Query Parameters
Every api endpoint you are trying to call will most likely contain parameters that you will need to inject. I
public struct HTTPParameter {
let key: String
let value: String
}
HTTP Headers
Each API call will also need you to provide a dictionary of headers. Since 99% of API calls use the same keys for the headers, I’ve created an enum for the most used
public enum HTTPHeader {
case accept(MediaType)
case acceptCharset(String)
case acceptEncoding(String)
case acceptLanguage(String)
case authorization(AuthorizationType)
case cacheControl(String)
case contentLength(String)
case contentType(MediaType)
case cookie(String)
case host(String)
case ifModifiedSince(String)
case ifNoneMatch(String)
case origin(String)
case referer(String)
case userAgent(String)
case custom(key: String, value: String)
var key: String {
switch self {
case .accept: return "Accept"
case .acceptCharset: return "Accept-Charset"
case .acceptEncoding: return "Accept-Encoding"
case .acceptLanguage: return "Accept-Language"
case .authorization: return "Authorization"
case .cacheControl: return "Cache-Control"
case .contentLength: return "Content-Length"
case .contentType: return "Content-Type"
case .cookie: return "Cookie"
case .host: return "Host"
case .ifModifiedSince: return "If-Modified-Since"
case .ifNoneMatch: return "If-None-Match"
case .origin: return "Origin"
case .referer: return "Referer"
case .userAgent: return "User-Agent"
case .custom(let key, _): return key
}
}
var value: String {
switch self {
case .accept(let accept): return accept.value
case .acceptCharset(let value): return value
case .acceptEncoding(let value): return value
case .acceptLanguage(let value): return value
case .authorization(let authentication): return authentication.value
case .cacheControl(let value): return value
case .contentLength(let value): return value
case .contentType(let contentType): return contentType.value
case .cookie(let value): return value
case .host(let value): return value
case .ifModifiedSince(let value): return value
case .ifNoneMatch(let value): return value
case .origin(let value): return value
case .referer(let value): return value
case .userAgent(let value): return value
case .custom(_, let value): return value
}
}
}
public enum MediaType {
case json
case xml
case formUrlEncoded
case custon(String)
var value: String {
switch self {
case .json: return "application/json"
case .xml: return "application/xml"
case .formUrlEncoded: return "application/x-www-form-urlencoded"
case .custon(let value): return value
}
}
}
public enum AuthorizationType {
case bearer(String)
case custom(String)
var value: String {
switch self {
case .bearer(let value): return "Bearer \(value)"
case .custom(let value): return value
}
}
}
Step 3: Adding Encoders
Encoding your HTTP Headers
HTTPHeaderEncoder
allows us to pass in a list of HTTPHeader
that you want to add to your URLRequest
.
public protocol HTTPHeaderEncoder {
func encodeHeaders(for urlRequest: inout URLRequest, with headers: [HTTPHeader])
}
public struct HTTPHeaderEncoderImpl: HTTPHeaderEncoder {
public init() {}
public func encodeHeaders(for urlRequest: inout URLRequest, with headers: [HTTPHeader]) {
for header in headers {
urlRequest.setValue(header.value, forHTTPHeaderField: header.key)
}
}
}
Encoding your Query Parameters
HTTPParameterEncoder
allows us to pass in a list of HTTPParameter
that you want to add to your URLRequest
.
public protocol HTTPParameterEncoder {
func encodeParameters(for urlRequest: inout URLRequest, with parameters: [HTTPParameter]) throws
}
public struct HTTPParameterEncoderImpl: HTTPParameterEncoder {
public init() {}
public func encodeParameters(for urlRequest: inout URLRequest, with parameters: [HTTPParameter]) throws {
guard let url = urlRequest.url else {
throw NetworkingError.noURL
}
if var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), !parameters.isEmpty {
urlComponents.queryItems = [URLQueryItem]()
for param in parameters {
let queryItem = URLQueryItem(name: param.key, value: "\(param.value)")
urlComponents.queryItems?.append(queryItem)
}
urlRequest.url = urlComponents.url
}
}
}
Step 4: Building a Network Request
Now we can’t make any network calls without having a URLRequest. The creation process can be lengthy, so I built the ReqeustBuildable
protocol to extract the creation logic for us.
public protocol RequestBuildable {
func build(httpMethod: HTTPMethod,
urlString: String,
parameters: [HTTPParameter]?,
headers: [HTTPHeader]?,
body: Data?,
timeoutInterval: TimeInterval) -> URLRequest?
}
public class RequestBuilder: RequestBuildable {
private let headerEncoder: HTTPHeaderEncoder
private let paramEncoder: HTTPParameterEncoder
public init(headerEncoder: HTTPHeaderEncoder = HTTPHeaderEncoderImpl(),
paramEncoder: HTTPParameterEncoder = HTTPParameterEncoderImpl()) {
self.headerEncoder = headerEncoder
self.paramEncoder = paramEncoder
}
public func build(httpMethod: HTTPMethod,
urlString: String,
parameters: [HTTPParameter]?,
headers: [HTTPHeader]? = nil,
body: Data? = nil,
timeoutInterval: TimeInterval = 60
) -> URLRequest? {
guard let url = URL(string: urlString) else {
return nil
}
var request = URLRequest(url: url)
request.httpMethod = httpMethod.rawValue
request.httpBody = body
request.timeoutInterval = timeoutInterval
if let parameters = parameters {
try? paramEncoder.encodeParameters(for: &request, with: parameters)
}
if let headers = headers {
headerEncoder.encodeHeaders(for: &request, with: headers)
}
return request
}
}
Step 5: Decoding your data
RequestDecodable
— Responsible for decoding your data into any Decodable object
public protocol RequestDecodable {
func decode<T: Decodable>(_ type: T.Type, from data: Data) throws -> T
}
public struct RequestDecoder: RequestDecodable {
public init() {}
public func decode<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
throw NetworkingError.couldNotParse
}
}
}
Step 6: Validator your response
When making an API call dataTask
we get back optional error, response, and data. I’ve created a helper protocol that can easily handle all of that for us.
public protocol URLResponseValidator {
func validate(data: Data?, urlResponse: URLResponse?, error: Error?) throws -> Data
}
public struct URLResponseValidatorImpl: URLResponseValidator {
public init() {}
public func validate(data: Data?, urlResponse: URLResponse?, error: Error?) throws -> Data {
guard let data else {
throw NetworkingError.noData
}
guard let urlResponse else {
throw NetworkingError.noResponse
}
if let error = error {
throw NetworkingError.requestFailed(error)
}
guard let httpURLResponse = urlResponse as? HTTPURLResponse else {
throw NetworkingError.noHTTPURLResponse
}
let errorResponse = httpURLResponse.networkingError
if case .ok = errorResponse {
return data
} else {
throw errorResponse
}
}
}
Step 7 Performer your network requests
Now that we can easily create reqeusts and easily validate the results, let’s string it all together and create a performer object to perform our network requests.
Async/Await Implementation
AsyncRequestPerformer
will be your primary utility struct for performing your API requests. It’s built using async/await
public protocol AsyncRequestPerformable {
func perform<T: Decodable>(request: URLRequest, decodeTo decodableObject: T.Type) async throws -> T
func perform(request: URLRequest) async throws
}
public struct AsyncRequestPerformer: AsyncRequestPerformable {
private let urlSession: URLSessionTaskProtocol
private let urlResponseValidator: URLResponseValidator
private let requestDecoder: RequestDecodable
public init(urlSession: URLSessionTaskProtocol = URLSession.shared,
urlResponseValidator: URLResponseValidator = URLResponseValidatorImpl(),
requestDecoder: RequestDecodable = RequestDecoder()) {
self.urlSession = urlSession
self.urlResponseValidator = urlResponseValidator
self.requestDecoder = requestDecoder
}
// MARK: perform request with Async Await and return Decodable
public func perform<T: Decodable>(request: URLRequest, decodeTo decodableObject: T.Type) async throws -> T {
do {
let (data, response) = try await urlSession.data(for: request, delegate: nil)
let validData = try urlResponseValidator.validate(data: data, urlResponse: response, error: nil)
let result = try requestDecoder.decode(decodableObject.self, from: validData)
return result
} catch let error as NetworkingError {
throw error
} catch {
throw NetworkingError.unknown
}
}
// MARK: perform request with Async Await
public func perform(request: URLRequest) async throws {
do {
let (data, response) = try await urlSession.data(for: request, delegate: nil)
_ = try urlResponseValidator.validate(data: data, urlResponse: response, error: nil)
} catch let error as NetworkingError {
throw error
} catch {
throw NetworkingError.unknown
}
}
}
Completion Handler Implementation
To support older iOS versions, or if your code just hasn’t migrated from callbacks to async await yet, You can use a Callback basedRequestPerformer
public protocol RequestPerformable {
func perform<T: Decodable>(request: URLRequest, decodeTo decodableObject: T.Type, completion: @escaping((Result<T, NetworkingError>)) -> Void)
func perform(request: URLRequest, completion: @escaping((VoidResult<NetworkingError>) -> Void))
}
public struct RequestPerformer: RequestPerformable {
private let urlSession: URLSessionTaskProtocol
private let urlResponseValidator: URLResponseValidator
private let requestDecoder: RequestDecodable
public init(urlSession: URLSessionTaskProtocol = URLSession.shared,
urlResponseValidator: URLResponseValidator = URLResponseValidatorImpl(),
requestDecoder: RequestDecodable = RequestDecoder()) {
self.urlSession = urlSession
self.urlResponseValidator = urlResponseValidator
self.requestDecoder = requestDecoder
}
// MARK: perform using Completion Handler
public func perform<T: Decodable>(request: URLRequest, decodeTo decodableObject: T.Type, completion: @escaping ((Result<T, NetworkingError>)) -> Void) {
let dataTask = urlSession.dataTask(with: request) { data, urlResponse, error in
do {
let validData = try urlResponseValidator.validate(data: data, urlResponse: urlResponse, error: error)
let decodedObject = try requestDecoder.decode(decodableObject.self, from: validData)
completion(.success(decodedObject))
} catch let httpError as NetworkingError {
completion(.failure(httpError))
return
} catch {
completion(.failure(NetworkingError.unknown))
return
}
}
dataTask.resume()
}
// MARK: perform using Completion Handler without returning Decodable
public func perform(request: URLRequest, completion: @escaping ((VoidResult<NetworkingError>) -> Void)) {
let dataTask = urlSession.dataTask(with: request) { data, urlResponse, error in
do {
_ = try urlResponseValidator.validate(data: data, urlResponse: urlResponse, error: error)
completion(.success)
} catch let httpError as NetworkingError {
completion(.failure(httpError))
return
} catch {
completion(.failure(NetworkingError.unknown))
return
}
}
dataTask.resume()
}
}
Example usage
Now you’re probably wondering, “How do I use all this?” No problem, I’ll break it down. Your first step will be to create a network request. Let’s build a simple example with some parameters, some headers, authorization, and a body.
let request = RequestBuilder().build(
httpMethod: .GET,
urlString: "http://www.example.com",
parameters: [
.init(key: "query_param_key_1", value: "query_param_value_1"),
.init(key: "query_param_key_2", value: "query_param_value_2"),
.init(key: "query_param_key_3", value: "query_param_value_3")
],
headers: [
.accept(.json),
.contentType(.json),
.authorization(.bearer("Your_API_KEY"))
],
body: myData,
timeoutInterval: 30
)
Here’s how to execute this request.
// Perform request with async/await and decoding a response
do {
let person = try await AsyncRequestPerformer().perform(request: request, decodeTo: Person.self)
print(person.age, person.name)
} catch let error as NetworkingError {
print(error)
}
// Perform request with async/await without decoding a response
do {
try await AsyncRequestPerformer().perform(request: request)
print("Did succeed")
} catch let error as NetworkingError {
print(error)
}
// Perform request with callbacks and decoding a response
RequestPerformer().perform(request: request, decodeTo: Person.self) { result in
switch result {
case .success(let person):
print(person.name, person.age)
case .failure(let error):
print(error)
}
}
// Perform request with callbacks without decoding a response
RequestPerformer().perform(request: request) { result in
switch result {
case .success:
print("did succeed")
case .failure(let error):
print(error)
}
}
Conclusions
With this networking layer, you will be able to easily perform a majority of all your network requests and handle responses. This will make your codebase much cleaner and maintainable as you no longer need to do all this low-level handling yourself, this Swift Package can do it for you!
I plan to keep working on this to make the SDK even more robust, so expect a lot of new cool features shortly!
Github: https://github.com/Aldo10012/EZNetworking
Note: Since writing this article, I will have had updated the GitHub repo with more features, so if you look at the source code it may look different from what is presented here.