Building an iOS Networking SDK — Part 1

Alberto Dominguez
9 min readJul 19, 2024

--

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.

  1. Define NetworkingError
  2. Setup some HTTP helpers
  3. Adding encoders
  4. Building a network request
  5. Decoding Data
  6. Validating Data
  7. 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: NetworkingErrormust 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.

--

--

No responses yet