Getir
Published in

Getir

Writing a modern iOS Networking Library with Swift Concurrency

Photo by Conny Schneider on Unsplash
  • Support the modernization of the codebase
  • Provide easy-to-use APIs
  • Leverage newer system APIs for better performance and reliability
  • Transfer the ownership of code to the new developers
  • Codable support
  • Async-await support for the new Swift concurrency
  • Mock support for UI tests
  • Flexible API for different needs of different parts of the project
  • Increasing the unit test coverage of our Networking stack

Model Structure

In terms of the models used for the request and response types with the new networking, we wanted to use Decodable and Encodable, which provides us with easy-to-use decoding and encoding APIs.

Request Structure

We wanted to provide an easy way to describe the details of a request, such as an endpoint path, parameters, HTTP method, and any request-specific headers.

public protocol NetworkRequestable {
var baseURL: String { get }
var method: HTTPMethod { get }
var path: String: { get }
var parameters: Encodable? { get }
var headers: [String: String]? { get }
}
extension NetworkRequestable {
public var parameters: Encodable? {
nil
}

public var headers: [String: String]? {
nil
}
}
struct SampleRequest: NetworkRequestable {
var method: HTTPMethod = .post
var baseURL = “https://my-base-url.com”
var path = “my/login/path”
var parameters: Encodable?
}
struct SampleRequestParameters: Encodable {
let testProperty: String
let secondTestProperty: String
}
let parameters = SampleRequestParameters(testProperty: “test”, secondTestProperty: “test2”)
let request = SampleRequest(parameters: parameters)
protocol MyDomainSpecificRequest: NetworkRequestable {
var baseURL = “https://my-base-url.com”
}
struct SampleRequest: MyDomainSpecificRequest {
var method: HTTPMethod = .post
var path = “my/login/path”
var parameters: Encodable?
}
protocol MyDomainSpecificRequest: NetworkRequestable {

var baseURL: String {
switch ExampleState.environment {
case .development:
return “https://my-base-url-development.com”
case .production:
return “https://my-base-url.com”
}
}
}

Response Structure

When we make a network request, we often expect a response, but additionally, at Getir, we provide certain metadata that is available with every response, let’s have a look at the response structure

{
"expectedResponse": { // Request specific response
"testField": "testValue",
"testFieldTwo": 2,
...
},
"metadata" : { // Metadata sent with every response
"additionalField": "Test",
...
}
}
public struct SuccessResponseWrapper<T: Decodable>: Decodable {
public let metadata: ResponseMetadata
public let expectedResponse: T
}

Constructing the Networking

Moving on from the request and response models, while constructing the Networking, we aimed to follow the single responsibility principle and enable effective testing by dividing the Networking into distinct layers.

  • RequestFactory
  • RequestExecutor
  • RequestAdapters
  • ResponseParser

RequestFactory

The request factory is responsible for translating a NetworkRequestable into a URLRequest for the request execution. It has only one internal method and looks like the following:

func makeURLRequest<T: NetworkRequestable>(with request: T) throws -> URLRequest
  • Constructs the URL by combining the baseURL and the path
  • If there are parameters, encode them to Data with JSONEncoder
  • Depending on the HTTPMethod apply correct encoding (JSON or URL encoding)
  • Finally add the headers if they exist, and return the URLRequest
let queryParameters = try! JSONSerialization.jsonObject(
with: parameters,
options: .fragmentsAllowed
) as! [String: Any]

var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)!

urlComponents.queryItems = queryParameters.map {
URLQueryItem(name: $0.key, value: String(describing: $0.value))
}
urlComponents.queryItems = queryParameters.map {
if type(of: $0.value) == type(of: NSNumber(value: true)),
let value = $0.value as? Bool {
return URLQueryItem(name: $0.key, value: "\(value)"
}
return URLQueryItem(name: $0.key, value: String(describing: $0.value))
}

RequestExecutor

The request executor is responsible for executing the requests with the injected URLSession. It has one internal method:

func execute(_ request: URLRequest) async -> Result<ExecutionSuccessModel, Error> {
do {
let (data, response) = try await session.data(for: request)
return .success(ExecutionSuccessModel(data: data, response: response))
} catch let error {
return .failure(error)
}
}

RequestAdapters

Request adapters allow for final modifications of requests before they are executed. They are injected into Networking and applied sequentially after the URLRequest is created by the request factory.

public protocol RequestAdaptation {
func adapt(request: URLRequest) -> URLRequest
}
public final class DefaultHTTPHeaderAdapter: RequestAdaptation {
private var headerProvidingClosure: () -> ([String: String])

public init(headerProvidingClosure: @escaping () -> ([String: String]) {
self.headerProvidingClosure = headerProvidingClosure
}

public func adapt(request: URLRequest) -> URLRequest {
var mutableRequest = request
let headers = headerProvidingClosure()
headers.forEach {
mutableRequest.setValue($0.value, forHTTPHeaderField: $0.key)
}
return mutableRequest
}
}

ResponseParser

The response parser converts the retrieved data into the expected type + metadata for a request call. It returns the previously mentioned success model or a network error in case of any issues.

Networking

Now that we talked about all the important pieces, we can construct the Networking. In addition to the entities we talked about, the Networking also has its own URLSession. We are going to inject all our entities in the init method so that we can write unit tests easily later on:

final public class Networking {
private let requestMaker: RequestMaking
private let requestExecutor: RequestExecuting
private let requestAdapters: [RequestAdaptation]
private let responseParser: ResponseParsing
private let session: URLSession

public init(requestMaker: /* all entities are injected */) {
// properties are initialised
}
}
public convenience init(requestAdapters: [RequestAdaptation] = []) {
self.init(requestMaker: RequestFactory(),
requestExecutor: RequestExecutor(),
requestAdapters: requestAdapters,
responseParser: ResponseParser())
}

init(requestMaker: RequestMaking, requestExecutor: RequestExecuting, requestAdapters: [RequestAdaptation], responseParser: ResponseParsing) {
// properties initialised
}

Implementing the request method

Networking implements the NetworkRequestProviding protocol and provides an async method to make network requests.

public func executeRequest<T: Decodable, V: NetworkRequestable>(
request: V,
responseType: T.Type
) async -> Result<SuccessResponseWrapper<T>, NetworkingError> {
// some syntactic details are omitted for simplicity
// make the URLRequest
let urlRequest = requestFactory.makeURLRequest(with: request)
// adapt request
var adaptedRequest = urlRequest
requestAdapters.forEach {
adaptedRequest = $0.adapt(request: adaptedRequest)
}
// execute request
let result = await requestExecutor.execute(adaptedRequest)
// parse the result and return
let parsedResult = responseParser.parseResult(result)
return parsedResult
}

Swift concurrency pitfalls

We tried returning the result in the main actor to prevent consumers from having to switch to the main actor to update their UI, similar to using a completion handler on the main queue in GCD.

URLSession Invalidation

The Networking was functioning as intended at this stage, but we noticed the instance was still present in the memory after its intended scope. Something was wrong.

deinit {
session.finishTasksAndInvalidate()
}

Usage

Now that we are done with the Networking implementation, let’s take a look at the complete usage, from request creation to result retrieval.


let networking: NetworkRequestProviding

func makeRequest() {
Task {
let parameters = SampleRequestParameters(testProperty: "test", secondTestProperty: "test2")
let request = SampleRequest(parameters: parameters)

let result = await networking.makeRequest(
request: request,
responseType: ExampleType.self // A decodable
)

switch result {
case .success(let successWrapper):
await updateUI(successWrapper.expectedResponse)
case .failure(let error):
await showError(error)
}
}
}

@MainActor
func updateUI(with model: ExampleType.self) {
// update UI safely on main actor
}

@MainActor
func showError(_ error: NetworkingError) {
}

Mock networking for UI tests

We also made a very cool Mock Networking feature with the ability to load mock JSON data, but that’s a story for another time — stay tuned for our next article! :)

Final words

We’re happy to have created a modern version of our Networking stack! We’ve built a highly scalable and easy-to-use library that’s been rigorously unit tested and leverages the new Swift concurrency. Along the way, we’ve learned a ton by overcoming various issues and challenges.

--

--

As the pioneers of super-fast delivery, we’d like to share our thoughts regarding what to do and the values we have as a growing people organization.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Emre Havan

iOS Software Engineer — Interested in Compilers, ML and recommender systems — https://github.com/emrepun