Cache and API Request with Combine

Posted: April 29, 2022

When working with remote APIs we often want to implement some sort of caching on the client for multiple reasons, e.g. performance or saving the bandwidth. We may also want to use the cache only when it is not outdated (as per our specific use case) and still hit the remote API even when the cache exists. Let’s look at a possible way on how to approach this using Combine.

To set the scene, let’s imagine we want to load weather data into our app. By nature it usually takes a while for the weather to change drastically (barring places like Melbourne and its four seasons in a day), so chances are we’ll want to cache the remote data and use that cache until a meaningful period has passed, after which it makes sense to request the remote data again.

Model

Let’s begin by creating a simple model to represent the weather data:

struct Weather {
    let lastUpdate: Date
    let temperature: Float
    let condition: String
}

A proper weather app will surely provide more information about the weather, but this model will serve just fine for the purposes of this article. The lastUpdate property will be used to determine how recent the weather data is, while the other two properties just describe the weather conditions.

Cache and API Services

Now let’s define the interface for the cache service. For this demo it will include only a method for loading from the cache, but obviously we’d want to have a way to save to the cache in an actual app.

import Combine

protocol WeatherCache {
    var cacheDurationSeconds: TimeInterval { get }

    func loadLocationWeather(latitude: Double, longitude: Double) -> AnyPublisher<Weather?, Error>
}

We simply want to load any cached weather data for the given combination of latitude and longitude (i.e. location). Note the publisher’s output type is optional, since it’s possible to not have any cached data. The cacheDurationSeconds property determines for how long a cache is valid.

The interface for the service loading the remote data will also have a single method. We won’t win any awards for the best network layer design but that’s not the point of this article.

import Combine

protocol WeatherAPIService {
    func fetchWeather(latitude: Double, longitude: Double) -> AnyPublisher<Weather, Error>
}

This time the publisher’s output type is not optional, since we always expect the service to respond with some data, or an error.

Weather Loading Service

Finally we need a service that will make use of both the cache and the remote API service. It will handle all the tricky parts and we’ll be able to load the weather data with a single method call, without having to worry about cache or making an HTTP request at the call site. Here’s what a stub for the service could look like:

import Combine

struct WeatherLoadingService {
    let cache: WeatherCache
    let apiService: WeatherAPIService
    
    func loadWeather(latitude: Double, longitude: Double) -> AnyPublisher<Weather, Error> {
        fatalError("not implemented")
    }
}

Ideally we’d abstract this API behind a protocol as well, but we won’t bother with that here. Before we move on to implementing the loadWeather method, let’s write tests for this service first to have our implementation easily verifiable. The implementation part continues here if you wish to go straight to that.

Tests

When working with Combine, writing the tests often involves setting up test expectations which is a bit verbose at times. We’ll help ourselves with a neat extension for XCTestCase adapted from this article on Swift by Sundell. Thanks to this extension we can write a lot of the publisher related code in a synchronous fashion, resulting in tests that are much easier to reason about:

import Combine
import XCTest

extension XCTestCase {
    
    func awaitPublisher<T: Publisher>(
        _ publisher: T,
        timeout: TimeInterval = 0.5,
        file: StaticString = #file,
        line: UInt = #line
    ) throws -> Result<T.Output, Error> {
        var result: Result<T.Output, Error>?
        let expectation = self.expectation(description: "Awaiting publisher")
        
        let cancellable = publisher.sink(
            receiveCompletion: { completion in
                switch completion {
                case .failure(let error):
                    result = .failure(error)
                case .finished:
                    break
                }
                
                expectation.fulfill()
            },
            receiveValue: { value in
                result = .success(value)
            }
        )
        
        waitForExpectations(timeout: timeout)
        
        cancellable.cancel()
        
        let unwrappedResult = try XCTUnwrap(
            result,
            "Awaited publisher did not produce any output",
            file: file,
            line: line
        )
        
        return unwrappedResult
    }
    
}

One modification I made is that the extension returns the whole Result enum instead of just its success value. Feel free to pause here and read the original article if you want to learn more about the idea behind the extension.

Test Mocks

Next we’ll add a mock for our cache service. We want to control the mock’s response and cache duration. Additionally we’ll also want to test the error response so we add MockError enum as well:

import Combine
@testable import WeatherDemo

enum MockError: Error {
    case forcedError
}

class CacheMock: WeatherCache {
    
    var numLoadCalls: Int = 0
    var cacheDurationSeconds: TimeInterval = 0
    var response: Weather?
    var shouldFail: Bool = false
    
    func loadLocationWeather(latitude: Double, longitude: Double) -> AnyPublisher<Weather?, Error> {
        Deferred {
            Future<Weather?, Error> { future in
                self.numLoadCalls += 1
                
                if self.shouldFail {
                    future(.failure(MockError.forcedError))
                } else {
                    future(.success(self.response))
                }
            }
        }.eraseToAnyPublisher()
    }
    
}

The mock for the API service is very similar:

import Combine
@testable import WeatherDemo

class APIServiceMock: WeatherAPIService {
    
    var numLoadCalls: Int = 0
    var response: Weather?
    var shouldFail: Bool = false
    
    func fetchWeather(latitude: Double, longitude: Double) -> AnyPublisher<Weather, Error> {
        Deferred {
            Future<Weather, Error> { future in
                self.numLoadCalls += 1
                
                if self.shouldFail {
                    future(.failure(MockError.forcedError))
                } else {
                    future(.success(self.response!))
                }
            }
        }.eraseToAnyPublisher()
    }
    
}

Testing WeatherLoadingService

Now we can get to the actual testing. Let’s define the test cases to have a better idea about what we need to test:

  1. When no cache exists, the remote API request must be made.
  2. When cache exists but is outdated, the remote API request must be made.
  3. When cache exists and is recent, the remote API request must not be made.
  4. When cache responds with an error, the remote API request must be made.
  5. If the remote API request is made and it fails, an error should be returned even if outdated cache exists.

Let’s create a new test class and add the test subject along with the mocks:

import XCTest
@testable import WeatherDemo

class WeatherLoadingServiceTests: XCTestCase {
    
    private var cache: CacheMock!
    private var apiService: APIServiceMock!
    private var sut: WeatherLoadingService!

    override func setUpWithError() throws {
        self.cache = CacheMock()
        self.apiService = APIServiceMock()
        self.sut = WeatherLoadingService(cache: cache, apiService: apiService)
    }

    override func tearDownWithError() throws {
        self.cache = nil
        self.apiService = nil
        self.sut = nil
    }

}

No Cache Test

In the first test we want to make sure the weather service correctly makes the call to the remote API when no cached data exist:

func test_api_request_is_made_when_cache_does_not_exist() throws {
    XCTAssertEqual(self.cache.numLoadCalls, 0)
    XCTAssertEqual(self.apiService.numLoadCalls, 0)
    
    let lastUpdate = Date()
    
    self.apiService.response = Weather(lastUpdate: lastUpdate, temperature: 10, condition: "Clear sky")
    
    let result = try awaitPublisher(self.sut.loadWeather(latitude: 10, longitude: 10))
    
    let weather = try result.get()
    
    XCTAssertEqual(weather.lastUpdate, lastUpdate)
    XCTAssertEqual(weather.temperature, 10)
    XCTAssertEqual(weather.condition, "Clear sky")
    XCTAssertEqual(self.cache.numLoadCalls, 1)
    XCTAssertEqual(self.apiService.numLoadCalls, 1)
}

Outdated Cache Test

Now we want to test that the API service is still triggered when the cache exists but is outdated:

func test_api_request_is_made_when_outdated_cache_exists() throws {
    XCTAssertEqual(self.cache.numLoadCalls, 0)
    XCTAssertEqual(self.apiService.numLoadCalls, 0)
    
    let outdated = Date(timeIntervalSince1970: 0)
    
    self.cache.cacheDurationSeconds = 10 * 60
    self.cache.response = Weather(lastUpdate: outdated, temperature: 5, condition: "Rain")
    
    let lastUpdate = Date()
    
    self.apiService.response = Weather(lastUpdate: lastUpdate, temperature: 10, condition: "Clear sky")
    
    let result = try awaitPublisher(self.sut.loadWeather(latitude: 10, longitude: 10))
    
    let weather = try result.get()
    
    XCTAssertEqual(weather.lastUpdate, lastUpdate)
    XCTAssertEqual(weather.temperature, 10)
    XCTAssertEqual(weather.condition, "Clear sky")
    XCTAssertEqual(self.cache.numLoadCalls, 1)
    XCTAssertEqual(self.apiService.numLoadCalls, 1)
}

Recent Cache Test

In this scenario we test that the weather service correctly uses the cache and avoids making unnecessary remote API requests:

func test_api_request_is_not_made_when_recent_cache_exists() throws {
    XCTAssertEqual(self.cache.numLoadCalls, 0)
    XCTAssertEqual(self.apiService.numLoadCalls, 0)
    
    let lastUpdate = Date()
    
    self.cache.cacheDurationSeconds = 10 * 60
    self.cache.response = Weather(lastUpdate: lastUpdate, temperature: 5, condition: "Rain")
    
    let result = try awaitPublisher(self.sut.loadWeather(latitude: 10, longitude: 10))
    
    let weather = try result.get()
    
    XCTAssertEqual(weather.lastUpdate, lastUpdate)
    XCTAssertEqual(weather.temperature, 5)
    XCTAssertEqual(weather.condition, "Rain")
    XCTAssertEqual(self.cache.numLoadCalls, 1)
    XCTAssertEqual(self.apiService.numLoadCalls, 0)
}

Cache Error Test

Next we want to ensure the remote data is fetched when the cache responds with an error:

func test_api_request_is_made_when_cache_fails() throws {
    XCTAssertEqual(self.cache.numLoadCalls, 0)
    XCTAssertEqual(self.apiService.numLoadCalls, 0)
    
    let lastUpdate = Date()
    
    self.cache.shouldFail = true
    
    self.apiService.response = Weather(lastUpdate: lastUpdate, temperature: 10, condition: "Clear sky")
    
    let result = try awaitPublisher(self.sut.loadWeather(latitude: 10, longitude: 10))
    
    let weather = try result.get()
    
    XCTAssertEqual(weather.lastUpdate, lastUpdate)
    XCTAssertEqual(weather.temperature, 10)
    XCTAssertEqual(weather.condition, "Clear sky")
    XCTAssertEqual(self.cache.numLoadCalls, 1)
    XCTAssertEqual(self.apiService.numLoadCalls, 1)
}

Remote API Error Test

Finally we test that we receive an error when fetching the remote API fails, even if we have an outdated cache data:

func test_error_is_returned_when_api_request_fails() throws {
    XCTAssertEqual(self.cache.numLoadCalls, 0)
    XCTAssertEqual(self.apiService.numLoadCalls, 0)
    
    let outdated = Date(timeIntervalSince1970: 0)
    
    self.cache.cacheDurationSeconds = 10 * 60
    self.cache.response = Weather(lastUpdate: outdated, temperature: 5, condition: "Rain")
    
    self.apiService.shouldFail = true
    
    let result = try awaitPublisher(self.sut.loadWeather(latitude: 10, longitude: 10))
    
    if case .success = result {
        XCTFail("expected an error")
    }
    
    XCTAssertEqual(self.cache.numLoadCalls, 1)
    XCTAssertEqual(self.apiService.numLoadCalls, 1)
}

Implementing Weather Loading Service

Now onto the main bit. We go back to the WeatherLoadingService.swift file and begin implementing the loadWeather method.

Since we always want to go through the cache first, that’s what we’ll start with:

func loadWeather(latitude: Double, longitude: Double) -> AnyPublisher<Weather, Error> {
    return self.cache.loadLocationWeather(latitude: latitude, longitude: longitude)
}

Now we’ll want to check whether the cache (if any) is recent, if not then we want to ignore it. We can use the map operator in combination with another method to check the cache’s date:

func loadWeather(latitude: Double, longitude: Double) -> AnyPublisher<Weather, Error> {
    return self.cache.loadLocationWeather(latitude: latitude, longitude: longitude)
                      .map({ (weather: Weather?) -> Weather? in
                          if self.isCacheRecent(weather) {
                              return weather
                          }
                          return nil
                      })
}

private func isCacheRecent(_ weather: Weather?) -> Bool {
    guard let weather = weather else { return false }
    
    let threshold: TimeInterval = Date().timeIntervalSince1970 - self.cache.cacheDurationSeconds
    return weather.lastUpdate.timeIntervalSince1970 >= threshold
}

Next we want to ignore potential error response from the cache with the catch operator. We simply return Just publisher with the nil value, which essentially says there is no cache available:

func loadWeather(latitude: Double, longitude: Double) -> AnyPublisher<Weather, Error> {
    return self.cache.loadLocationWeather(...)
                      .map({ ... })
                      .catch({ _ in
                          Just(nil)
                      })
}

At this point we are dealing with an optional value of type Weather, which is nil in these cases:

  1. The cache does not exist.
  2. The cache exists but is outdated.
  3. The cache responded with an error.

In any other case the value won’t be nil and we can treat it as a valid cache response. However if it is nil we still want to make the request to the remote API, and that’s where flatMap joins the party:

func loadWeather(latitude: Double, longitude: Double) -> AnyPublisher<Weather, Error> {
    return self.cache.loadLocationWeather(...)
                      .map({ ... })
                      .catch({ ... })
                      .flatMap({ (response: Weather?) -> AnyPublisher<Weather, Error> in
                          // Valid cache, use it
                          if let response = response {
                              return Just(response)
                                          .setFailureType(to: Error.self) // (1)
                                          .eraseToAnyPublisher()
                          }
                          
                          // No cache, hit the remote API
                          return self.apiService.fetchWeather(latitude: latitude, longitude: longitude)
                      })
                      .eraseToAnyPublisher()
}

As mentioned we use the cached response whenever it’s not nil by wrapping it in the Just publisher. Since this publisher never errors out, we need to explicitly set the failure type (1) to match error type of the API service publisher, which is used only when the cached value is nil. And with that our implementation is done, we can now run the tests to confirm the outputs match our expectations!

iOS 13 Compatibility

If we want to deploy to iOS 13, the compiler will complain about our usage of flatMap.

Xcode Cloud

The reason for this is that the error types of the upstream publisher (the one returned by catch) does not match the error type of the publisher returned by the flatMap. Since we catch any errors returned by the cache publisher, the resulting type is Publisher<Weather?, Never>, whereas the flatMap returns Publisher<Weather, Error>. As we can see the error types are not the same and the compiler has trouble figuring out how to put these two together.

To fix this, we need to explicitly change the error type of the upstream publisher before using flatMap:

func loadWeather(latitude: Double, longitude: Double) -> AnyPublisher<Weather, Error> {
    return self.cache.loadLocationWeather(...)
                      .map({ ... })
                      .catch({ ... })
                      .setFailureType(to: Error.self)
                      .flatMap({ ... })
                      .eraseToAnyPublisher()
}

You can learn more about this problem in this more in-depth article by Donny Wals.