Writing a modern iOS Networking Library with Swift Concurrency
In the ever-changing nature of software development, we often find ourselves in the need of rewriting some of our code and libraries. Getir is no exception, although it was working fine, we wanted to rewrite the networking library of our iOS Project.
Let’s take a look at the benefits of a library rewrite:
- 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
In addition to the benefits mentioned above, we also wanted to rewrite the Networking library in particular for the following reasons:
- 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
In this article, we are going to talk about our adventure in this rewriting process, our approaches, the challenges faced, and the lessons learned along the way. Let’s get started! 🚀
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.
Thus, we’ve created the following protocol:
public protocol NetworkRequestable {
var baseURL: String { get }
var method: HTTPMethod { get }
var path: String: { get }
var parameters: Encodable? { get }
var headers: [String: String]? { get }
}
Additionally, we provided the following extension, since not all requests need parameters or headers:
extension NetworkRequestable {
public var parameters: Encodable? {
nil
}
public var headers: [String: String]? {
nil
}
}
An example request struct would now look like this:
struct SampleRequest: NetworkRequestable {
var method: HTTPMethod = .post
var baseURL = “https://my-base-url.com”
var path = “my/login/path”
var parameters: Encodable?
}
HTTPMethod
is a basic enum describing the method to use for the request, later on, implementation details are omitted for brevity.
The model used for parameters is now a simple Encodable
that looks like the following:
struct SampleRequestParameters: Encodable {
let testProperty: String
let secondTestProperty: String
}
Finally the initialization of the request:
let parameters = SampleRequestParameters(testProperty: “test”, secondTestProperty: “test2”)
let request = SampleRequest(parameters: parameters)
That’s it. The request is ready to be fired with the networking. But now, we have another problem, the base URL of a service doesn’t change often, but here we are providing it in the request, which means we are going to provide it for another request as well, it is repetitive and unnecessary. Also would be very hard to change all over the place if needed.
As a solution, we introduced a new protocol to define the baseURL
, which acts as a middleman between requests and NetworkRequestable
. Now all requests that need to connect to the same baseURL
can conform to it.
protocol MyDomainSpecificRequest: NetworkRequestable {
var baseURL = “https://my-base-url.com”
}
Now the SampleRequest
can simply conform to MyDomainSpecificRequest
and leave the baseURL
out:
struct SampleRequest: MyDomainSpecificRequest {
var method: HTTPMethod = .post
var path = “my/login/path”
var parameters: Encodable?
}
You might be wondering, why not just inject the baseURL
in the Networking API? Because we wanted a single instance of a Networking
to communicate with different services, and also wanted to keep it as lean as possible. Additionally, with this approach, we can keep our base URLs dynamic, outside of the Networking
package. We could provide the baseURL
conditionally in MyDomainSpecificRequest
for example:
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",
...
}
}
So we needed to implement a way to provide the expected response and also the additional metadata attached to it. Previously this was done with subclassing, every response type would subclass the base response where the metadata
properties were declared. But with the new implementation, we wanted to keep our response models as value types and implemented a struct with a generic associated response type to achieve this
public struct SuccessResponseWrapper<T: Decodable>: Decodable {
public let metadata: ResponseMetadata
public let expectedResponse: T
}
So whenever a request succeeds the callers will receive a wrapper, which contains the expected response, and the metadata. The only requirement of the actual response type is that it should be a Decodable
.
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.
Now that we talked about the request and response model structures, we can move forward with Networking implementation. While constructing the Networking, we wanted to create different layers, adhere to the single responsibility principle, and also quickly write tests for certain interactions.
To keep things concise, we will only discuss the four primary entities, even though the actual implementation has additional dependencies.
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
It does the following in the given order:
- Constructs the URL by combining the
baseURL
and thepath
- If there are parameters, encode them to
Data
withJSONEncoder
- Depending on the
HTTPMethod
apply correct encoding (JSON or URL encoding) - Finally add the headers if they exist, and return the
URLRequest
Encoding
For the JSON encoding, it was trivial, we could just encode the Encodable
parameters as Data
and set it as the httpBody
of the URLRequest
. But when it comes to URL encoding, things were a bit more tricky.
The query parameters were created by using JSONSerialization
(Force unwrapping used for example purposes):
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))
}
Although it looked fine at the first sight, soon we realized a problem with this approach, it was sending query values for booleans as 1
and 0
, instead of true
and false
. It was happening because JSONSerialization
converts Bool
to CFBoolean
, which acts as an Int
when directly used with String(describing:)
.
To address the issue, we needed to check if the value was of type NSNumber
when it is initialized with a boolean value. Additionally, we also needed to verify if the value could be converted to a Bool
to ensure that we only convert actual boolean values and not mistakenly convert integer values to true
or false
.
Now the queryItems
initialization looks like the following:
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)
}
}
If the execution succeeds, it provides a ExecutionSuccessModel
which consists of Data
and URLResponse
, and if it throws an error, it is returned back to the Networking
.
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.
All the adapters must conform to RequestAdaptation
protocol, which only has one requirement:
public protocol RequestAdaptation {
func adapt(request: URLRequest) -> URLRequest
}
So an adapter can take a request, do something with it and provide it back.
Next we will take a look at an adapter that is used for providing the default headers for every ongoing request:
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
}
}
The DefaultHTTPHeaderAdapter
is initialized with a headerProvidingClosure
, which is a closure that takes nothing and provides headers as key-value pairs when needed. This can, later on, be injected into the Networking
so every ongoing request can have the default headers applied.
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
}
}
Here we realized another problem, the consumers of the library don’t need to know about all these internal entities. But to make the init
public
so it can be initialized outside, and we can continue using dependency injection, we have to make all our internal entity protocols public. Any consumer can implement these protocols and provide their own implementations, which is not what we want.
Convenience init to the rescue
To solve this issue, we created a public
convenience
initialiser and kept the original one internal
, resulting in two separate init methods:
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
}
We made only the RequestAdaptation
public
, keeping all other entities and protocols internal
as planned. This allowed unit tests to use the internal
init
method with dependency injection. More details on this approach can be found here.
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
}
We could keep the method and Networking
lean by delegating all the work to sub-entities as shown above.
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.
We were initially skeptical about adding the @MainActor
annotation to the method since it only ensures that the method itself runs on the main actor, not the caller. However, we were surprised to find that the callers of the function continued to run on the main actor within their Task
block, so we didn’t need to specify those tasks to run on the main actor, or so we thought.
It turns out, before Swift 5.7, the behavior was non-deterministic, and after building our project with Xcode 14.0, we experienced local crashes due to UI updates on a background thread. We then removed the @MainActor
annotation and updated networking interactions on the call site.
This change allowed consumers to switch to the main actor only when necessary, and to continue doing background work after retrieving the result from networking.
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.
Although we initially found no apparent cause for a memory leak, we later realized that the system could cache the URLSession
for future usage, retaining its delegate in the process. To prevent this behavior, we ensured proper deallocation by invalidating the URLSession
in the deinit
method.:
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) {
}
Swift concurrency makes requesting and processing results straightforward and readable, especially when compared to closure-based concurrency APIs.
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.
Thanks for reading this article — we hope you found it helpful! We’d love to hear your thoughts, so please join the discussion in the comments section. Until next time, take care! 👋