Categories
Programming

Network Caching With Combine

I’m going to lead in to this post by saying that this isn’t a Definitively Correct way to do this; it works for my use case, but has some definitive issues. Still, in the interest of transparency—and not running out of content for this blog—I’m going to share it anyways.

Last week, I shared a fun little enum-based way of representing API endpoints in a type-safe manner. It’s also pretty easy to expand on to use as the key for an in-memory cache of endpoint results.

(This, by the by, is Caveat 1: it’s a purely in-memory cache, with nothing being persisted to disk. User quits out of your app? Cache is gone. For what I’m working on, though, this is the behavior we want, so it works well.)

Let’s get started with the actual API surface we want:

class ApiClient {
	func get<T: Decodable>(endpoint: Endpoint, reset: Bool = false) -> T? {
		...
	}
}

Now, the funky thing about how I’ve got this implemented is that this get method is idempotent – if you call it 10,000 times for the same Endpoint, it’s only going to spit out one network request, and will just continue returning nil until something is actually present. (Well, unless you’re setting reset to true – that’s really in there for debug purposes more than anything else. Give the API I’m using on this project, it’s almost never necessary.)

And, for use with SwiftUI, we want it to be called again when the network request finishes, so we’ll make it an ObservableObject.

Next, let’s work out how to store things to achieve that. The main thing we need is somewhere to store the network requests, and somewhere to store the results – the aforementioned in-memory cache. And, to get that “call the method again when the cache changes” behavior, we can make it @Published:

class ApiClient: ObservableObject {
	private var loaders: [Endpoint:AnyCancellable] = [:]
	@Published private var cache: [Endpoint:Decodable] = [:]
}

Now, to make Endpoint we need it to be viable dictionary key, which is pretty easy to accomplish:

extension Endpoint: Hashable { }

Finally, we need to implement the actual method. Without further ado:

class ApiClient: ObservableObject {
	private var loaders: [Endpoint:AnyCancellable] = [:]
	@Published private var cache: [Endpoint:Decodable] = [:]
	
	func get<T: Decodable>(endpoint: Endpoint, reset: Bool = false) -> T? {
		if reset {
			cache[endpoint] = nil
			loaders[endpoint] = nil // which implicitly calls loaders[endpoint]?.cancel(), via the deinit
		}
		if let _item = cache[endpoint], let item = _item as? T {
			print("\(endpoint): cache hit")
			return item
		}
		if loaders[endpoint] == nil {
			print("\(endpoint): cache miss; beginning load")
			loaders[endpoint] = URLSession.shared.dataTaskPublisher(for: endpoint.url) // 1
				.map(\.data)
				.decode(type: T.self, decoder: JSONDecoder()) // 2
				.receive(on: DispatchQueue.main)
				.sink(receiveCompletion: { (completion) in 
					print(completion)
				}, receiveValue: { (item) in 
					self.cache[endpoint] = item
				})
		}
		print("\(endpoint): cache miss; loading already in progress")
		return nil
	}
}

Two notes in there:

  1. You may want to swap out the use of URLSession.shared here for a parameter into the class’ constructor – it’ll make your testing a bit easier.
  2. Even more so than (1), you probably want to swap out JSONDecoder() here for something stored on the class – that decoder isn’t free to initialize!

Now, as I mentioned, this has some limitations. The first one I already went over – it’s a purely in-memory cache. The second is at the call site – since this is generic across Decodable, you have to annotate at the call site what the expected return type is, which isn’t the most ergonomic.

My actual thought on fixing that was very TypeScript-inspired – creating overloads that specify T based on the given endpoint. In Swift, this isn’t too difficult, just adding something like:

extension ApiClient {
	func getCategory(_ id: Category.ID, reset: Bool = false) -> Category? {
		get<Category>(endpoint: .category(id), reset: reset)
	}
}

Which, of course, can get a bit repetitive depending on the number of endpoints you have. But hey, it works for my use case, and it may be helpful to someone else, so: blog post!

As a post-credits scene of sort, the TypeScript version is a bit silly, and takes advantage of the absolutely bonkers way method overloads work in TypeScript. I’ll throw out some pseudocode:

class ApiClient{
	func get(endpoint: Endpoint.Category, reset: bool): Category?
	func get<T>(endpoint: Endpoint, reset: bool: T? {
		...
	}
}

Yes, method overloads like this are just declaring the method multiple times before the actual body. And yes, you can specify an individual enum case as a type.