Creating a Robust Network Layer in Swift: Part 2

Rohit Saini
7 min readJun 18, 2024

In modern iOS development, having a well-structured network layer is essential for handling API calls and data fetching efficiently and securely. In this follow-up article, we will guide you through the enhancements and refactoring’s made to our network layer, adhering to SOLID principles and transitioning from a class-based to a struct-based approach. This will ensure a more maintainable and scalable codebase.

Overview

Our upgraded network layer includes the following components:

  1. NetworkError: A comprehensive enum to handle various network errors.
  2. NetworkRequest: A protocol defining the necessary properties and methods for a network request.
  3. HTTPResponseHandler: A protocol to handle and decode HTTP responses.
  4. NetworkEngine: A struct responsible for performing network requests, logging, and handling responses.

We’ll also demonstrate how to use these components with example API calls.

Step 1: Define Network Errors

We start by defining the NetworkError enum, which helps handle various network-related errors in a clean and organized manner.

import Foundation

enum NetworkError: Error {
case badURL
case requestFailed(Error)
case invalidResponse
case dataNotFound
case decodingFailed(Error)
case encodingFailed(Error)
case notFound
case timeout
case internalServerError
case unknownError(statusCode: Int)
}

struct DecodingError: Error {
let message: String
}

Explanation:

  • NetworkError: This enum encapsulates various types of errors that can occur during network operations. Each case represents a specific error scenario:
  • badURL: Indicates that the URL is invalid.
  • requestFailed: Represents a failure in making the request, encapsulating the underlying error.
  • invalidResponse: Indicates that the response is not valid.
  • dataNotFound: Signals that no data was found in the response.
  • decodingFailed: Represents a failure in decoding the response data, encapsulating the decoding error.
  • encodingFailed: Represents a failure in encoding the request parameters, encapsulating the encoding error.
  • notFound: Indicates that the requested resource was not found (HTTP 404).
  • timeout: Represents a timeout error.
  • internalServerError: Indicates a server error (HTTP 500).
  • unknownError: Represents any other HTTP status code error, encapsulating the status code.

DecodingError: A custom error struct to provide additional error information during decoding.

Step 2: Create the NetworkRequest Protocol

The NetworkRequest protocol defines the essential properties for a network request, including the URL, HTTP method, headers, parameters, and a timeout interval.

import Foundation

enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case delete = "DELETE"
}

enum HTTPHeader: String {
case contentType = "Content-Type"
case authorization = "Authorization"
}

enum ContentType: String {
case json = "application/json"
case xml = "application/xml"
case formUrlEncoded = "application/x-www-form-urlencoded"
}

protocol NetworkRequest {
var url: URL? { get }
var method: HTTPMethod { get }
var headers: [HTTPHeader: String]? { get }
var parameters: Encodable? { get }
var timeoutInterval: TimeInterval { get }
}

extension NetworkRequest {
var timeoutInterval: TimeInterval {
return 30
}

func urlRequest() throws -> URLRequest {
guard let url = url else {
throw NetworkError.badURL
}

var request = URLRequest(url: url)
request.httpMethod = method.rawValue

if let headers = headers {
for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key.rawValue)
}
}

if let parameters = parameters {
if method == .get {
var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)
let parameterData = try JSONEncoder().encode(parameters)
let parameterDictionary = try JSONSerialization.jsonObject(with: parameterData, options: []) as? [String: Any]
urlComponents?.queryItems = parameterDictionary?.map { URLQueryItem(name: $0.key, value: "\($0.value)") }
request.url = urlComponents?.url
} else {
do {
let jsonData = try JSONEncoder().encode(parameters)
request.httpBody = jsonData
} catch {
throw NetworkError.encodingFailed(error)
}
}
}

return request
}
}

Explanation:

  • HTTPMethod: An enum representing the HTTP methods we will use (GET, POST, PUT, DELETE).
  • HTTPHeader: An enum for common HTTP headers.
  • ContentType: An enum for common content types used in HTTP headers.
  • NetworkRequest: A protocol that defines the properties required for a network request:
  • url: The URL for the request.
  • method: The HTTP method for the request.
  • headers: Optional headers for the request.
  • parameters: Optional parameters for the request, conforming to Encodable.
  • timeoutInterval: The timeout interval for the request.
  • The protocol extension provides a default implementation for timeoutInterval and a method urlRequest() to create a URLRequest from the properties:
  • If the method is GET, parameters are added as query items to the URL.
  • For other methods, parameters are encoded as JSON and added to the request body.
  • Headers are added to the request.

Step 3: Implement HTTPResponseHandler

The HTTPResponseHandler protocol defines methods for handling and decoding HTTP responses.

import Foundation

public protocol HTTPResponseHandler {
func handleStatusCode(response: URLResponse?) throws
func decode<T: Decodable>(data: Data, to type: T.Type) throws -> T
func extractETag(from response: URLResponse?) -> String?
}

extension HTTPResponseHandler {
public func decode<T: Decodable>(data: Data, to type: T.Type) throws -> T {
do {
let decodedObject = try JSONDecoder().decode(T.self, from: data)
return decodedObject
} catch let decodingError {
throw NetworkError.decodingFailed(decodingError)
}
}

public func handleStatusCode(response: URLResponse?) throws {
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}

switch httpResponse.statusCode {
case 200...299:
return
case 404:
throw NetworkError.notFound
case 500:
throw NetworkError.internalServerError
default:
throw NetworkError.unknownError(statusCode: httpResponse.statusCode)
}
}

public func extractETag(from response: URLResponse?) -> String? {
guard let httpResponse = response as? HTTPURLResponse else {
return nil
}
return httpResponse.allHeaderFields["ETag"] as? String
}
}

public struct DefaultHTTPResponseHandler: HTTPResponseHandler {
public init() {}
}

Explanation:

  • HTTPResponseHandler: A protocol defining methods for handling and decoding HTTP responses:
  • handleStatusCode(response:): Checks the HTTP status code and throws appropriate errors.
  • decode(data:to:): Decodes data to a specified type conforming to Decodable.
  • extractETag(from:): Extracts the ETag from the response headers.

The extension provides default implementations:

  • decode(data:to:) uses JSONDecoder to decode the data and throws NetworkError.decodingFailed on failure.
  • handleStatusCode(response:) checks the HTTP status code and throws specific NetworkError cases for common errors (404, 500) and an unknown error for other status codes.
  • extractETag(from:) retrieves the ETag from the response headers.
  • DefaultHTTPResponseHandler: A concrete implementation of HTTPResponseHandler.

Step 4: Implement NetworkEngine

The NetworkEngine struct is responsible for making network requests and handling responses.

import Foundation

struct NetworkRequestContext<T: Decodable> {
let request: NetworkRequest
let type: T.Type
let completion: (Result<T, NetworkError>) -> Void
let requestInvokeTime: Date
}

protocol NetworkEngineAdapter {
func invokeEngine<T: Decodable>(_ request: NetworkRequest, decodeTo type: T.Type, completion: @escaping (Result<T, NetworkError>) -> Void)
}

public struct NetworkEngine {
private let urlSession: URLSession
private let logger: Logger
private let responseHandler: HTTPResponseHandler

public init(urlSession: URLSession = .shared,
logger: Logger = DefaultLogger(),
responseHandler: HTTPResponseHandler = DefaultHTTPResponseHandler()) {
self.urlSession = urlSession
self.logger = logger
self.responseHandler = responseHandler
}
}

extension NetworkEngine: NetworkEngineAdapter {
func invokeEngine<T>(_ request: NetworkRequest, decodeTo type: T.Type, completion: @escaping (Result<T, NetworkError>) -> Void) where T: Decodable {
let requestInvokeTime = Date()
let context = NetworkRequestContext(request: request, type: type, completion: completion, requestInvokeTime: requestInvokeTime)
fetch(context)
}

private func fetch<T>(_ context: NetworkRequestContext<T>) where T: Decodable {
do {
var urlRequest = try context.request.urlRequest()
urlRequest.timeoutInterval = context.request.timeoutInterval

urlSession.dataTask(with: urlRequest) { data, response, error in
let requestFinishTime = Date()
let duration = requestFinishTime.timeIntervalSince(context.requestInvokeTime)

logger.logMetrics(startTime: context.requestInvokeTime, endTime: requestFinishTime, duration: duration, request: urlRequest)

if let error = error {
context.completion(.failure(.requestFailed(error)))
return
}

guard let data = data else {
context.completion(.failure(.dataNotFound))
return
}

do {
try responseHandler.handleStatusCode(response: response)
let decodedObject = try responseHandler.decode(data: data, to: context.type)
context.completion(.success(decodedObject))
} catch let error as NetworkError {
context.completion(.failure(error))
} catch {
context.completion(.failure(.unknownError(statusCode: (response as? HTTPURLResponse)?.statusCode ?? -1)))
}
}.resume()
} catch {
context.completion(.failure(.requestFailed(error)))
}
}
}

Explanation:

  • NetworkRequestContext: A struct encapsulating the context of a network request, including the request itself, the type to decode to, the completion handler, and the time the request was invoked.
  • NetworkEngineAdapter: A protocol defining a method to invoke the network engine with a request, specifying the type to decode to, and providing a completion handler.
  • NetworkEngine: The main struct for performing network operations:
  • urlSession: The URLSession instance used for making requests.
  • logger: A logger for recording metrics and errors.
  • responseHandler: An instance of HTTPResponseHandler for handling responses.
  • init: Initializes the NetworkEngine with optional parameters for urlSession, logger, and responseHandler.
  • The invokeEngine method creates a NetworkRequestContext and calls the fetch method.

The fetch method:

  • Tries to create a URLRequest from the NetworkRequest.
  • Sets the timeout interval.
  • Makes the network request using urlSession.dataTask.
  • Logs metrics for the request.
  • Handles errors, missing data, and status codes.
  • Decodes the data and calls the completion handler with the result.

Step 5: Using the Network Layer

This example demonstrates how to use the NetworkEngine in a iOS.

Define the Network Request

First, we define the PostService enum, which conforms to the NetworkRequest protocol. This enum encapsulates the details of our network request:

import Foundation

enum PostService {
case fetchPosts
}

extension PostService: NetworkRequest {
var url: URL? {
switch self {
case .fetchPosts:
return URL(string: "https://2e84f9d6-0dcb-4b93-9238-8b272604b4c1.mock.pstmn.io/v1/posts")
}
}

var method: HTTPMethod {
switch self {
case .fetchPosts:
return .get
}
}

var headers: [HTTPHeader : String]? {
return [.contentType: ContentType.json.rawValue]
}

var parameters: (any Encodable)? {
switch self {
case .fetchPosts:
return nil
}
}
}

Here, PostService has a single case fetchPosts, which defines the URL, HTTP method, headers, and parameters for the request. you can add more acc. to your requirements.

Create the Repository

Next, we create a repository class that will use the NetworkEngine to execute the request. This repository will handle fetching the posts:

protocol PostsListRepositoryProtocol {
func fetchPostsList<T: Decodable>(with request: NetworkRequest, responseType: T.Type, completion: @escaping (Result<T, NetworkError>) -> Void)
}

final class DefaultPostsListRepository: PostsListRepositoryProtocol {
private let engine: NetworkEngine

init(engine: NetworkEngine = NetworkEngine()) {
self.engine = engine
}

func fetchPostsList<T>(with request: NetworkRequest, responseType: T.Type, completion: @escaping (Result<T, NetworkError>) -> Void) where T : Decodable {
engine.invokeEngine(request, decodeTo: responseType, completion: completion)
}
}

In this repository, the fetchPostsList method takes a NetworkRequest and a responseType to decode the response. It uses the NetworkEngine to execute the request and calls the completion handler with the result.
you can use this repository directly into your ViewModel or maybe in view UseCase .

we can inject different types of Logger and ResponseHandler here.

Conclusion

The network layer design in this codebase is well-suited for scalable applications due to its modularity, extensibility, robust error handling, emphasis on testability and performance monitoring. These attributes ensure that the app can grow in functionality and complexity without compromising maintainability and reliability.

Note: i am working on few more features to add in this layer.

Till then happy coding.

For more insights and updates, follow me on LinkedIn.

--

--

Rohit Saini

कर्म मुझे बांधता नहीं, क्योंकि मुझे कर्म के प्रतिफल की कोई इच्छा नहीं |