Creating a Robust Network Layer in Swift + API Cache Feature : Part 3

Rohit Saini
7 min read4 days ago

--

In the previous parts of this series, we established a solid foundation for a robust network layer using Swift. We covered comprehensive error handling, protocol-oriented design, response validation, and decoding. Now, in Part 3, we’ll focus on adding an API cache feature to further enhance the efficiency and user experience of your iOS applications.

Important:: Before going into coding implementation let’s understand

What is ETag?

ETag (Entity Tag) is a mechanism used in HTTP (Hypertext Transfer Protocol) for web cache validation and conditional requests. It’s a string identifier assigned by the server to a specific version of a resource (like a web page, image, or data). The ETag represents the state of the resource at the time it was generated or last modified.

How ETag Works:

  1. Server-Side Generation: When a client requests a resource from a server, the server includes an ETag header in the response. This ETag uniquely identifies the current version of the resource.
  2. Client-Side Caching: The client (e.g., a web browser or a mobile app) can store this ETag along with the resource locally.
  3. Conditional Requests: When the client wants to fetch the resource again, it sends a request to the server. This request includes the ETag in the If-None-Match header.
  4. Server-Side Validation: The server compares the ETag provided by the client (If-None-Match) with the current ETag of the resource on the server.
  • If the ETags match, the server responds with a 304 Not Modified status code, indicating that the resource hasn't changed since the client's last request. The server doesn’t send the resource again, saving bandwidth and reducing load time.
  • If the ETags don’t match or the resource has been modified, the server sends the updated resource with a 200 OK status code.

Benefits of Using ETag:

  • Efficient Cache Validation: ETag allows clients to check if their cached copy of a resource is still valid without having to download the entire resource again.
  • Reduced Bandwidth Usage: By sending 304 Not Modified responses, servers save bandwidth, leading to faster loading times for clients.
  • Optimized User Experience: Users experience faster responses and reduced data usage, enhancing overall performance.

Why API Caching Matters

API caching can significantly improve the performance and responsiveness of your app by storing previously fetched data and serving it without the need for repeated network requests. Here are some key benefits:

  1. Improved Performance: Caching reduces the load on the network and speeds up data retrieval.
  2. Enhanced User Experience: Faster data loading times lead to a smoother and more responsive user experience.
  3. Reduced Network Costs: Minimizing the number of network requests can lower data usage, which is particularly beneficial for users on limited data plans.
  4. Offline Support: Cached data can provide basic functionality when the network is unavailable.

Code Changes and Additions

Here’s a breakdown of the changes and updates from Part 2 to Part 3:

Update to NetworkRequest Protocol:

  • Added isApiCacheEnabled property to enable or disable caching on a per-request basis.

New APICacheable Protocol:

  • Defines methods for storing and retrieving cached responses.

Update to HTTPResponseHandler Protocol:

  • Defines method for extracting ETag to check if data is updated or not on server.

Implementation of DefaultAPICache:

  • A singleton class that implements the APICacheable protocol using an LRU (Least Recently Used) cache.

Integration in NetworkEngine:

  • Checks for cached responses before making network requests.
  • Stores successful responses in the cache.

Updated Code

Below are the updated and new code segments that incorporate the API cache feature:

  1. NetworkRequest Protocol Update:
public protocol NetworkRequest {
var url: URL? { get }
var method: HTTPMethod { get }
var headers: [HTTPHeader: String]? { get }
var parameters: Encodable? { get }
var timeoutInterval: TimeInterval { get }
var isApiCacheEnabled: Bool? { get }
}

extension NetworkRequest {
var timeoutInterval: TimeInterval {
return 30
}

var isApiCacheEnabled: Bool? {
return false
}
}

var isApiCacheEnabled: Bool? { get }:

  • Declares a read-only property isApiCacheEnabled of optional type Bool. This property indicates whether caching is enabled for this API request.

2. APICacheable Protocol and DefaultAPICache Implementation:

public protocol APICacheable {
var cache: SwiftlyLRU<String, (response: Decodable, eTag: String?)> { get }

func cacheKey(for request: NetworkRequest) -> String
func storeCacheResponse(_ response: Decodable, for request: NetworkRequest, eTag: String?)
func getCachedResponse(for request: NetworkRequest) -> (response: Decodable, eTag: String?)?
}

public class DefaultAPICache {
public static let shared = DefaultAPICache()

public let cache: SwiftlyLRU<String, (response: Decodable, eTag: String?)>

private init(capacity: Int = 100) {
cache = SwiftlyLRU<String, (response: Decodable, eTag: String?)>(capacity: capacity)
}
}

extension DefaultAPICache: APICacheable {
public func cacheKey(for request: NetworkRequest) -> String {
guard let urlString = request.url?.absoluteString else {
return ""
}
let method = request.method.rawValue
return "\(method)_\(urlString)"
}

public func storeCacheResponse(_ response: Decodable, for request: NetworkRequest, eTag: String?) {
let key = cacheKey(for: request)
cache[key] = (response, eTag)
}

public func getCachedResponse(for request: NetworkRequest) -> (response: Decodable, eTag: String?)? {
let key = cacheKey(for: request)
return cache[key]
}
}

Explanation: This protocol defines the requirements for an API cacheable entity:

  • cache Property: A read-only property representing an LRU cache that stores responses along with their optional eTags.
  • cacheKey(for:) Method: A method to generate a unique key for caching based on a network request.
  • storeCacheResponse(_:for:eTag:) Method: A method to store a response in the cache associated with a particular network request and an optional eTag.
  • getCachedResponse(for:) Method: A method to retrieve a cached response for a given network request, returning the response and its eTag if available.

3. HTTPResponse Protocol Update:

import Foundation

/// Protocol defining the handling of HTTP responses.
public protocol HTTPResponseHandler {
// Previous Methods
func extractETag(from response: URLResponse?) -> String?
}

extension HTTPResponseHandler {
// Previous Methods
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: This protocol method defines the requirements for handling updated HTTP responses:

  • extractETag(from:) Method: A method signature that specifies the need to extract an ETag (an identifier for a specific version of a resource) from an HTTP response. The method takes an optional URLResponse and returns an optional String.

ETag (Entity Tag) is a mechanism used in HTTP (Hypertext Transfer Protocol) for web cache validation and conditional requests. It’s a string identifier assigned by the server to a specific version of a resource (like a web page, image, or data). The ETag represents the state of the resource at the time it was generated or last modified.

4. NetworkEngine Integration:

The NetworkEngine struct encapsulates networking functionality to handle API requests. It incorporates caching mechanisms (APICacheable) and response handling (HTTPResponseHandler), including support for ETag-based cache validation.

1.) invokeEngine Method:

func invokeEngine<T>(_ request: NetworkRequest, decodeTo type: T.Type, completion: @escaping (Result<T, NetworkError>) -> Void) where T : Decodable {
let context = NetworkRequestContext(request: request, type: type, completion: completion)
guard let apiCacheEnabled = request.isApiCacheEnabled, apiCacheEnabled,
let cachedResponse = cache.getCachedResponse(for: request) as? (response: T, eTag: String?) else {
logger.log(file: #file, function: #function, line: #line, logLevel: .error, "Cache data not available")
fetch(context)
return
}
logger.log(file: #file, function: #function, line: #line, logLevel: .info, "Data fetched from Cache")
completion(.success(cachedResponse.response))
do {
try performRefreshableRequest(with: request.urlRequest(), context: context, eTag: cachedResponse.eTag)
} catch {
logAndDispatch(context: context, with: error)
}
}
  • invokeEngine: Initiates a network request based on the provided NetworkRequest.
  • Checks if API caching (isApiCacheEnabled) is enabled for the request.
  • Retrieves cached response (cachedResponse) if available using cache.getCachedResponse(for:).
  • If cache miss, logs an error and initiates a network fetch (fetch(context)).
  • If cache hit, logs success, completes with cached response, and optionally makes a conditional request (performRefreshableRequest) using the cached ETag.

2.) fetch Method:

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
handleEngineResponse(context: context, urlRequest: urlRequest, responseObject: (response, data, error))
}.resume()
}
catch {
logAndDispatch(context: context, with: error)
}
}
  • fetch: Executes an asynchronous data task using URLSession to fetch data from the network.
  • Converts NetworkRequest into a URLRequest.
  • Upon completion, calls handleEngineResponse to process the response.

3.) handleEngineResponse Method:

private func handleEngineResponse<T>(context: NetworkRequestContext<T>, urlRequest: URLRequest, responseObject: (URLResponse?, Data?, Error?)) where T: Decodable {
logger.log(file: #file, function: #function, line: #line, logLevel: .custom(.request), urlRequest) // Log request
logger.log(file: #file, function: #function, line: #line, logLevel: .custom(.response), responseObject) // Log Response

do {
let decodedObject = try responseHandler.handle(response: responseObject.0, data: responseObject.1, error: responseObject.2, type: T.self)
storeCacheIfEnabled(context: context, response: responseObject.0, decodedObject: decodedObject)
DispatchQueue.main.async {
context.completion(.success(decodedObject))
}
} catch {
logAndDispatch(context: context, with: error)
}
}
  • handleEngineResponse: Handles the HTTP response received from the server.
  • Logs the request and response using logger.
  • Uses responseHandler to decode (handle) the response data to the desired type (T).
  • Stores the response in the cache if caching is enabled (storeCacheIfEnabled).
  • Dispatches the decoded object to the completion handler on the main queue.

4.) storeCacheIfEnabled Method:

private func storeCacheIfEnabled<T>(context: NetworkRequestContext<T>, response: URLResponse?, decodedObject: T) where T: Decodable {
guard let isApiCacheEnabled = context.request.isApiCacheEnabled, !isApiCacheEnabled,
let eTag = responseHandler.extractETag(from: response) else {
logger.log(file: #file, function: #function, line: #line, logLevel: .debug, "Error while storing the data in Cache \n isCacheEnabled: \(String(describing: context.request.isApiCacheEnabled)) \n ETag: \(String(describing: responseHandler.extractETag(from: response)))")
return
}
cache.storeCacheResponse(decodedObject, for: context.request, eTag: eTag)
}
  • storeCacheIfEnabled: Checks if API caching is enabled (context.request.isApiCacheEnabled).
  • Retrieves the ETag from the server response (responseHandler.extractETag).
  • Stores the decoded object in the cache (cache.storeCacheResponse) with the associated ETag.

5.) performRefreshableRequest Method:

private func performRefreshableRequest<T>(with urlRequest: URLRequest, context: NetworkRequestContext<T>, eTag: String?) where T: Decodable {
urlSession.dataTask(with: urlRequest) { data, response, error in
if responseHandler.extractETag(from: response) != eTag {
handleEngineResponse(context: context, urlRequest: urlRequest, responseObject: (response, data, error))
}
return
}.resume()
}
  • performRefreshableRequest: Executes a conditional request using the provided URLRequest.
  • Compares the current ETag (responseHandler.extractETag) with the stored ETag (eTag).
  • If ETags don’t match, fetches and handles the updated response using handleEngineResponse.

Conclusion

With the API caching feature integrated into your network layer, your app can handle network requests more efficiently, resulting in improved performance, enhanced user experience, reduced network costs, and basic offline support.

Part 1: https://rohitsainier.medium.com/building-a-robust-network-layer-in-ios-using-swift-660870e976a9

Part 2: https://rohitsainier.medium.com/creating-a-robust-network-layer-in-swift-part-2-9839ad871cf9

Feel free to connect with me on LinkedIn for more insights and updates on iOS development and other tech topics.

Stay tuned for more updates and enhancements in this series. Feel free to share your thoughts and feedback!

--

--

Rohit Saini

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