Building a Robust Network Layer in iOS Using Swift

Rohit Saini
8 min readJun 8, 2024

In modern iOS development, having a well-structured network layer is essential for handling API calls and data fetching efficiently and securely. This article will guide you through creating a robust network layer in Swift, using the provided code as a foundation.

Overview

Our network layer will consist of several key components:

  1. NetworkError: A comprehensive enum to handle different types of network errors.
  2. NetworkRequest: A protocol defining the necessary properties and methods for a network request.
  3. NetworkManager: A singleton class responsible for performing network requests, decoding responses, and handling file downloads.

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

Step 1: Define Network Errors

Start by defining the NetworkError enum, which will help in handling 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 internalServerError
case unknownError(statusCode: Int)
}

struct DecodingError: Error {
let message: String
}

Explanation:

NetworkError Enum: This enum lists possible network-related errors that might occur, making error handling more manageable.

  • badURL: Indicates an invalid URL.
  • requestFailed: Indicates a failure in the network request, storing the original error.
  • invalidResponse: Indicates that the response received is not valid.
  • dataNotFound: Indicates that the data expected from the response was not found.
  • decodingFailed: Indicates failure in decoding the response data into the expected type.
  • encodingFailed: Indicates failure in encoding the request parameters.
  • notFound: Indicates a 404 error.
  • internalServerError: Indicates a 500 error.
  • unknownError: Indicates an unknown error with the associated status code.

DecodingError Struct: A custom error struct to provide specific error messages during decoding failures.

Step 2: Create the NetworkRequest Protocol

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

import Foundation

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

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"
}

Explanation:

NetworkRequest Protocol: Defines the structure that all network requests must follow.

  • url: The endpoint URL.
  • method: The HTTP method (GET, POST, etc.).
  • headers: Any headers required for the request.
  • parameters: The request parameters, conforming to the Encodable protocol.

HTTPMethod Enum: Represents the different HTTP methods used in requests.

HTTPHeader Enum: Represents common HTTP header fields.

ContentType Enum: Represents common content types for HTTP headers.

Step 3: Extend NetworkRequest for URLRequest Creation

We extend the NetworkRequest protocol to include a method for creating a URLRequest object. This extension handles the setting of HTTP headers and parameters.

extension NetworkRequest {
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:

URLRequest Creation Method: Converts a NetworkRequest into a URLRequest object.

  • Checks if the URL is valid, otherwise throws a badURL error.
  • Sets the HTTP method.
  • Sets any provided headers.
  • Encodes and sets parameters:
  • For GET requests, adds parameters as query items.
  • For other methods, encodes parameters to JSON and sets them as the request body.
  • Throws an encodingFailed error if encoding fails.

Step 4: Implement NetworkManager

NetworkManager is a singleton class responsible for making network requests and handling responses. It supports both async/await and completion handlers for backwards compatibility.

Async/Await Implementation

import Foundation
import UIKit

class NetworkManager {
static let shared = NetworkManager()
private let urlSession = URLSession.shared

private init() {}

func perform<T: Decodable>(_ request: NetworkRequest, decodeTo type: T.Type) async throws -> T {
if #available(iOS 15.0, *) {
let urlRequest = try request.urlRequest()
let (data, response) = try await urlSession.data(for: urlRequest)
try processResponse(response: response)
return try decodeData(data: data, type: T.self)
} else {
return try await withCheckedThrowingContinuation { continuation in
perform(request, decodeTo: type) { result in
switch result {
case .success(let data):
continuation.resume(returning: data)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
}

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

private func processResponse(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)
}
}

func downloadFile(from url: URL) async throws -> URL {
if #available(iOS 15.0, *) {
let (localURL, response) = try await urlSession.download(from: url)
try processResponse(response: response)
return localURL
} else {
return try await withCheckedThrowingContinuation { continuation in
downloadFile(from: url) { result in
switch result {
case .success(let localURL):
continuation.resume(returning: localURL)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
}
}

Explanation:

NetworkManager Singleton: Provides a single instance for making network requests.

  • shared: The singleton instance.
  • urlSession: The shared URLSession instance for network tasks.
  • init(): A private initializer to prevent multiple instances.

perform Method:

  • If iOS 15.0 or later:
  • Creates a URLRequest from the NetworkRequest.
  • Performs the request asynchronously.
  • Processes the response.
  • Decodes the response data into the specified type.
  • For earlier iOS versions:
  • Uses a continuation to handle the request with completion handlers.

decodeData Method: Decodes data into the specified type and throws a decodingFailed error if decoding fails.

processResponse Method: Validates the HTTP response and throws appropriate errors based on the status code.

downloadFile Method: Downloads a file from the given URL, supporting both async/await and completion handlers for backwards compatibility.

Completion Handlers Implementation

For older iOS versions, we implement network requests using completion handlers.

extension NetworkManager {
private func perform<T: Decodable>(_ request: NetworkRequest, decodeTo type: T.Type, completion: @escaping (Result<T, NetworkError>) -> Void) {
do {
let urlRequest = try request.urlRequest()
urlSession.dataTask(with: urlRequest) { data, response, error in
if let error = error {
completion(.failure(.requestFailed(error)))
return
}

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

do {
try self.processResponse(response: response)
let decodedObject = try self.decodeData(data: data, type: T.self)
completion(.success(decodedObject))
} catch {
completion(.failure(error as? NetworkError ?? .invalidResponse))
}
}.resume()
} catch {
completion(.failure(error as? NetworkError ?? .invalidResponse))
}
}

private func downloadFile(from url: URL, completion: @escaping (Result<URL, NetworkError>) -> Void) {
urlSession.downloadTask(with: url) { localURL, response, error in
if let error = error {
completion(.failure(.requestFailed(error)))
return
}

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

do {
try self.processResponse(response: response)
completion(.success(localURL))
} catch {
completion(.failure(error as? NetworkError ?? .invalidResponse))
}
}.resume()
}
}

Explanation:

perform Method with Completion Handler:

  • Creates a URLRequest and performs the request using URLSession.dataTask.
  • Handles errors and response validation.
  • Decodes the response data and calls the completion handler with the result.

downloadFile Method with Completion Handler:

  • Downloads a file using URLSession.downloadTask.
  • Handles errors and response validation.
  • Calls the completion handler with the local URL of the downloaded file.

Step 5: Image Downloading and Caching

We also extend NetworkManager to handle image downloading with optional caching.

extension NetworkManager {
func downloadImage(from url: URL, cacheEnabled: Bool = true) async -> Result<UIImage, NetworkError> {
do {
if cacheEnabled, let cachedImage = try getCachedImage(for: url) {
return .success(cachedImage)
}

let localURL = try await NetworkManager.shared.downloadFile(from: url)
let imageData = try Data(contentsOf: localURL)
if let image = UIImage(data: imageData) {
if cacheEnabled {
cacheImage(imageData, for: url)
}
return .success(image)
} else {
return .failure(.decodingFailed(DecodingError(message: "Failed to decode image data")))
}
} catch {
return .failure(error as? NetworkError ?? .invalidResponse)
}
}

private func cacheImage(_ imageData: Data, for url: URL) {
let cachedResponse = CachedURLResponse(response: HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, data: imageData)
URLCache.shared.storeCachedResponse(cachedResponse, for: URLRequest(url: url))
checkAndClearCache()
}

private func checkAndClearCache() {
let cacheSize = URLCache.shared.currentDiskUsage
let cacheLimit: Int = 100 * 1024 * 1024 // 100 MB
if cacheSize > cacheLimit {
URLCache.shared.removeAllCachedResponses()
}
}

private func getCachedImage(for url: URL) throws -> UIImage? {
if let cachedResponse = URLCache.shared.cachedResponse(for: URLRequest(url: url)),
let image = UIImage(data: cachedResponse.data) {
return image
}
return nil
}
}

Explanation:

downloadImage Method: Downloads an image from a URL with optional caching.

  • Checks the cache first if caching is enabled.
  • Performs the download using async/await or completion handlers for earlier iOS versions.
  • Processes the response and decodes the image data.
  • Saves the image to cache if caching is enabled.

cacheImage Method: Saves an image to the cache.

loadImageFromCache Method: Loads an image from the cache.

Example Usage

API Call Request

struct ExampleAPIRequest: NetworkRequest {
var url: URL? {
return URL(string: "https://api.example.com/data")
}
var method: HTTPMethod {
return .get
}
var headers: [HTTPHeader: String]? {
return [.contentType: ContentType.json.rawValue]
}
var parameters: Encodable? {
return ExampleParameters(param1: "value1", param2: "value2")
}
}

struct ExampleParameters: Encodable {
let param1: String
let param2: String
}

struct ExampleData: Decodable {
let id: Int
let name: String
}

func fetchExampleData() async {
let request = ExampleAPIRequest()

if #available(iOS 15.0, *) {
do {
let data: ExampleData = try await NetworkManager.shared.perform(request, decodeTo: ExampleData.self)
print("Fetched data: \(data)")
} catch {
print("Failed to fetch data: \(error)")
}
} else {
NetworkManager.shared.perform(request, decodeTo: ExampleData.self) { result in
switch result {
case .success(let data):
print("Fetched data: \(data)")
case .failure(let error):
print("Failed to fetch data: \(error)")
}
}
}
}

Explanation:

ExampleAPIRequest Struct: Defines a network request conforming to NetworkRequest protocol.

  • Specifies the URL, HTTP method, headers, and parameters.

ExampleParameters Struct: Represents the parameters for the request, conforming to Encodable.

ExampleData Struct: Represents the response data, conforming to Decodable.

fetchExampleData Function: Demonstrates how to perform the request using async/await and handle the response.

Download Image

import SwiftUI

struct HomeView: View {
@State private var image: UIImage? = nil

var body: some View {
VStack {
if let image = image {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 200, height: 200)
} else {
ProgressView()
}
}
.onAppear {
let imageURL = URL(string: "https://picsum.photos/200/200")!
Task {
let result = await NetworkManager.shared.downloadImage(from: imageURL, cacheEnabled: false)
switch result {
case .success(let success):
self.image = success
case .failure(_):
self.image = nil
}
}
}
}
}

struct HomeView_Previews: PreviewProvider {
static var previews: some View {
HomeView()
}
}

Explanation:

HomeView Struct: A SwiftUI view displaying an image downloaded from a URL.

  • Uses @State to manage the image state.
  • Displays a ProgressView while the image is loading.
  • Downloads the image on appear using async/await.
  • Handles the result of the download.

Download Files

import Foundation

func downloadExampleFile() async {
let fileURL = URL(string: "https://example.com/file.zip")!

if #available(iOS 15.0, *) {
do {
let localURL = try await NetworkManager.shared.downloadFile(from: fileURL)
print("Downloaded file to: \(localURL)")
} catch {
print("Failed to download file: \(error)")
}
} else {
NetworkManager.shared.downloadFile(from: fileURL) { result in
switch result {
case .success(let localURL):
print("Downloaded file to: \(localURL)")
case .failure(let error):
print("Failed to download file: \(error)")
}
}
}
}

Explanation:

downloadExampleFile Function: Demonstrates how to download a file using async/await and handle the response.

  • Supports both async/await and completion handlers for earlier iOS versions.

Note: if you need the entire codebase drop the comment below thanks

Conclusion

With this network layer in place, your iOS application will be better equipped to handle API requests, process responses, and manage file downloads. This structured approach not only makes your codebase cleaner but also more maintainable and scalable.

--

--

Rohit Saini

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