Network Layer -API Authentication using iOS Combine, async-await

Mayur Kothawade
5 min readMay 31, 2022

Many of us might have given a try or started designing your own network layer using combine and async await but usually we might get stuck in few critical areas like authentication, certificate pinning, reachability etc.

This article will highlight the authentication part in your network layer with retry request mechanism.

Expectation

Before we start, Ultimate API interface for your network layer will look like below

let output : NetworkOutput<ABCModel> = await APINetwork.responseFor(url: urlString, requestType: NetworkHTTPMethod.post, parameters: param)if let model = output.model {
// Do something with response model
}
else if let error = outlet.error {
// Do something with error
}

If you are not familiar with Combine and async await, Please follow below given links to get started

https://developer.apple.com/documentation/combine https://developer.apple.com/documentation/swift/swift_standard_library/concurrency

Lets get started…

Authentication has 2 common parts

  1. First where you have to pass the auth token to each request (Authenticated request)
  2. Second where we need to refresh token if earlier one is expired or has become invalid. (Request retry)

So lets define a protocol for handling these auth events

protocol NetworkAuthHandler {func adapt(_ urlRequest: URLRequest, options: Any?) -> URLRequestfunc retry(urlRequest: URLRequest?, urlResponse: URLResponse?, retryCount: Int?, error: Error, completionBlock: @escaping (Bool) -> Void)}

adapt function has to be called before making any network request to modify/transform it with specified auth token.

retry has to be called whenever we see any response with status code not between 200 to 300

Lets define a generic output model

class NetworkOutput<Output> {
var model: Output?
var data: Data?
var response: HTTPURLResponse?
var error: Error?
}

NetworkOutput is generic model which will have model of type Output, data will contain a data from response, response will be HTTPURLRespons coming from API, error will contain error if API gives any network error

Request Flow

At any point of time we will have a network request of type URLRequest to be processed, so let’s define an internal interface for processing network request using combine.

func networkDataTask<R:Codable>(request: URLRequest) -> AnyPublisher<NetworkOutput<R>?,Error> {/*
Steps:
1. Adapt a request for authentication
2. Create a CurrentValueSubject of type URLRequest, with initial value as adapted request (authSubject)3. Do a flatMap transform of the request to get a dataTaskPublisher4. Use tryMap transform to parse the response, decode it and retry the request if required by sending new request to authSubject
*/
}

adapt protocol will be used to get the authenticated request (by adding auth token in header)

retry mechanism is achieved using CurrentValueSubject of type URLRequest, which can be triggered any number of times by sending new request until we get proper output

Make a note that Network.shared.eventHandler is the one who implement NetworkAuthHandler protocol here.

Lets code above written steps

func networkDataTask<R:Codable>(request: URLRequest) -> AnyPublisher<NetworkOutput<R>?,Error> {// 1. Adapt a request for authentication        guard let refreshedRequest = Network.shared.eventHandler?.adapt(request, options: nil) else {
return Fail<NetworkOutput<R>?, Error>(error: NetworkError.noDataReceived ).eraseToAnyPublisher()
}

// 2. Create a CurrentValueSubject of type URLRequest, with initial value as adapted request (authSubject)
let authenticatedRequestSub = CurrentValueSubject<URLRequest, Error>(refreshedRequest)
var retry : Int = 0
return authenticatedRequestSub
.flatMap( { req in
// 3. Do a flatMap transform of the request to get a dataTaskPublisherreturn URLSession(configuration: URLSessionConfiguration.default, delegate: sessionDelegate, delegateQueue: nil)
.dataTaskPublisher(for:req)
.receive(on: DispatchQueue.main)
.tryMap { result -> NetworkOutput<R>? in
4. Use tryMap transform to parse the response, decode it and retry the request if required by sending new request to authSubjectlet statusCode = (result.response as? HTTPURLResponse)?.statusCode
let combinedOutput = NetworkOutput<R>()
combinedOutput.data = result.data
combinedOutput.response = result.response as? HTTPURLResponse
guard
let code = statusCode,
(200..<300) ~= code
else {
retry = retry + 1
Network.shared.eventHandler?.retry(urlRequest: request, urlResponse: result.response, options: nil, retryCount: retry, error: NetworkError.serverError(statusCode: statusCode), completionBlock: { shouldRetry in
// If request needs to be retried just adapt a new token and send value to authenticatedRequestSub
if shouldRetry {
guard let refreshedRequest = Network.shared.eventHandler?.adapt(request, options: nil) else {
return
}
authenticatedRequestSub.send(refreshedRequest)
}
else {
throw NetworkError.serverError(statusCode: statusCode)
}
})
combinedOutput.error = NetworkError.serverError(statusCode: statusCode)
return nil
}
let value = try JSONDecoder().decode(R.self, from: result.data)
combinedOutput.model = value
return combinedOutput
}
.mapError({ error in
let combinedOutput = NetworkOutput<R>()
combinedOutput.error = error
return error
})
.eraseToAnyPublisher()
}).handleEvents(receiveOutput: { output in// Make sure you call finished only when you receive desired output and ignore the cases where it retries the request.
if output != nil {
authenticatedRequestSub.send(completion: .finished)
}
}).eraseToAnyPublisher()
}

Here networkDataTask is a generic function which return publisher of value of type AnyPublisher<NetworkOutput<R>?,Error>

This will take care of authenticating each request based on requirement (as decision to add auth header is on Network.shared.eventHandler side)

It will retry the request if it fails with status code other that 200 to 300 for e.g 401, whether to retry the request or not is completely decided by Network.shared.eventHandler,

Network.shared.eventHandler can refresh token as well asynchronously and return back using completionBlock to retry the original request

In case of retry we also pass in a retry count which can be consumed by Network.shared.eventHandler to add further restrictions.

Retrying a request will call adapt function again to get a new auth token embedded for a request, and simply pass the new request to authenticatedRequestSub which will repeat the process again.

Process an API

Lets define a async interface for processing API

public static func responseFor<R: Codable>(
url: String,
requestType: NetworkHTTPMethod = .get,
parameters: [String: Any]? = nil,
parameterEncoding: NetworkEncoding = .jsonEncoding,
headers: [String: String]? = nil) async -> NetworkOutput<R> {
/*
Steps
1. Combine new headers with default headers2. Create a URLRequest object3. Handle parameter encoding 4. Sink on networkDataTask to get the data and return it using withCheckedContinuation
*/
}

Straight forward steps?, let's implement it

public static func responseFor<R: Codable>(
url: String,
requestType: NetworkHTTPMethod = .get,
parameters: [String: Any]? = nil,
parameterEncoding: NetworkEncoding = .jsonEncoding,
headers: [String: String]? = nil) async -> NetworkOutput<R> {

// 1. Combine new headers with default headers
let httpHeaders = URLSessionConfiguration.default.httpAdditionalHeaders
var newHttpHeaders = [String:String]()
httpHeaders?.forEach { (httpHeader) in
if let key = httpHeader.key as? String {
newHttpHeaders[key] = httpHeader.value as? String
}
}
headers?.forEach({ (httpHeader) in
newHttpHeaders[httpHeader.key] = httpHeader.value
})
// 2. Create a URLRequest object
var request : URLRequest?
request = URLRequest(url: URL(string: url)!)
request?.httpMethod = requestType.httpMethod().rawValue
request?.allHTTPHeaderFields = headers

// 3. Handle parameter encoding
if let parameters = parameters {
switch requestType {
case .get, .head, .delete :
NetworkEncoding.urlEncoding.encode(urlRequest: &request, parameters: parameters)

default:
NetworkEncoding.jsonEncoding.encode(urlRequest: &request, parameters: parameters)
}

}
// 4. Sink on networkDataTask to get the data and return it using withCheckedContinuation
return await withCheckedContinuation { continuation in
if let request = request {
self.networkDataTask(request: request)
.receive(on: RunLoop.main)
.sink ( receiveCompletion: { completion in
switch completion {
case .finished:
break
case .failure(let error):
let tempOutput = NetworkOutput<R>()
tempOutput.error = error
DispatchQueue.main.async {
continuation.resume(returning: tempOutput)
}
}
}, receiveValue: { ( output: NetworkOutput<R>?) in
if let output = output {
continuation.resume(returning:output)
}
}).store(in: &cancellables)
}
}


}
private static var cancellables: Set<AnyCancellable> = []

Note that we can resume continuation only once, that's why we are resuming it only when output is available

Here is how you access it

let output : NetworkOutput<ABCModel> = await Network.responseFor(url: urlString, requestType: NetworkHTTPMethod.post, parameters: param)if let model = output.model {
// Do something with response model
}
else if let error = outlet.error {
// Do something with error
}

That’s it, keeping an article simple and easy to understand. Let’s design your own network layer with combine and async await.

Thank you so much for your claps and share :)

--

--

Mayur Kothawade

Building Mobile App that challenges creativity and innovation.