Creating a Robust Network Layer in Swift: Part 2
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:
- NetworkError: A comprehensive enum to handle various network errors.
- NetworkRequest: A protocol defining the necessary properties and methods for a network request.
- HTTPResponseHandler: A protocol to handle and decode HTTP responses.
- 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 toEncodable
.timeoutInterval
: The timeout interval for the request.- The protocol extension provides a default implementation for
timeoutInterval
and a methodurlRequest()
to create aURLRequest
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 toDecodable
.extractETag(from:)
: Extracts the ETag from the response headers.
The extension provides default implementations:
decode(data:to:)
usesJSONDecoder
to decode the data and throwsNetworkError.decodingFailed
on failure.handleStatusCode(response:)
checks the HTTP status code and throws specificNetworkError
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 ofHTTPResponseHandler
.
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
: TheURLSession
instance used for making requests.logger
: A logger for recording metrics and errors.responseHandler
: An instance ofHTTPResponseHandler
for handling responses.init
: Initializes theNetworkEngine
with optional parameters forurlSession
,logger
, andresponseHandler
.- The
invokeEngine
method creates aNetworkRequestContext
and calls thefetch
method.
The fetch
method:
- Tries to create a
URLRequest
from theNetworkRequest
. - 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.