
Http Cache
- 3 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Generates an HTTP caching layer for Swift networking with Cache-Control parsing, ETag/conditional requests, stale-while-revalidate, and offline fallback.
About
Generates a Swift HTTP caching layer that parses Cache-Control, handles ETag/Last-Modified conditional requests, supports stale-while-revalidate, and falls back offline. A developer uses it to cache API responses, cut network calls, and add offline support.
- ETag/304 conditional requests and stale-while-revalidate
- Wraps an existing APIClient as a decorator
Http Cache by the numbers
- 3 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #3,722 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill http-cacheAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Generates an HTTP caching layer for Swift networking with Cache-Control parsing, ETag/conditional requests, stale-while-revalidate, and offline fallback.
Files
HTTP Cache Generator
Generate a production HTTP caching layer that integrates with your existing networking code. Supports Cache-Control directives, ETag/Last-Modified conditional requests, stale-while-revalidate, and offline fallback.
When This Skill Activates
Use this skill when the user:
- Asks to "add HTTP caching" or "cache API responses"
- Wants "offline support" or "offline fallback"
- Mentions "reduce API calls" or "cache network responses"
- Asks about "ETag" or "conditional requests" or "304 Not Modified"
- Wants "stale-while-revalidate" behavior
Pre-Generation Checks
1. Project Context Detection
- [ ] Check Swift version (requires Swift 5.9+)
- [ ] Check deployment target (iOS 16+ / macOS 13+)
- [ ] Search for existing caching implementations
- [ ] Identify source file locations
2. Networking Layer Detection
Search for existing networking code:
Glob: **/*API*.swift, **/*Client*.swift, **/*Network*.swift
Grep: "APIClient" or "URLSession" or "HTTPURLResponse"If networking-layer generator was used, detect the APIClient protocol and generate a decorator that wraps it.
3. Conflict Detection
Search for existing caching:
Glob: **/*Cache*.swift
Grep: "URLCache" or "ResponseCache" or "CachePolicy"If found, ask user whether to replace or extend.
Configuration Questions
Ask user via AskUserQuestion:
1. Cache storage sizes?
- Small (10 MB memory / 50 MB disk)
- Medium (25 MB memory / 100 MB disk) — recommended
- Large (50 MB memory / 250 MB disk)
2. Caching strategy?
- Respect server Cache-Control headers (standard)
- Cache-first with background revalidation (stale-while-revalidate)
- Manual per-endpoint policies
3. Offline support?
- Yes — serve stale cache when network unavailable
- No — only cache while online
4. Integration style?
- Decorator wrapping existing APIClient (recommended if networking-layer exists)
- Standalone cache you call directly
Generation Process
Step 1: Read Templates
Read http-cache-patterns.md for architecture guidance. Read templates.md for production Swift code.
Step 2: Create Core Files
Generate these files: 1. HTTPCacheConfiguration.swift — Memory/disk sizes, default policy 2. CachePolicy.swift — Per-endpoint enum (default, noCache, forceCache, cacheFirst) 3. CacheControlHeader.swift — Cache-Control header parser 4. ConditionalRequestHandler.swift — ETag/Last-Modified/304 handling 5. HTTPResponseCache.swift — Protocol + disk-backed response store
Step 3: Create Integration Files
Based on configuration:
CachingAPIClient.swift— Decorator wrapping existing APIClient (if decorator style)NetworkReachability.swift— NWPathMonitor wrapper (if offline support selected)
Step 4: Determine File Location
Check project structure:
- If
Sources/Networking/exists →Sources/Networking/Cache/ - If
App/Networking/exists →App/Networking/Cache/ - If
Networking/exists →Networking/Cache/ - Otherwise →
Cache/
Output Format
After generation, provide:
Files Created
Networking/Cache/
├── HTTPCacheConfiguration.swift # Memory/disk sizes, default policy
├── CachePolicy.swift # Per-endpoint caching enum
├── CacheControlHeader.swift # Cache-Control header parser
├── ConditionalRequestHandler.swift # ETag/Last-Modified/304
├── HTTPResponseCache.swift # Protocol + disk implementation
├── CachingAPIClient.swift # Decorator for existing APIClient
└── NetworkReachability.swift # NWPathMonitor (optional)Integration Steps
Wrap your existing client:
let baseClient = URLSessionAPIClient(configuration: .production)
let cachingClient = CachingAPIClient(
wrapping: baseClient,
cache: DiskHTTPResponseCache(),
configuration: .default
)
// Use cachingClient everywhere you used baseClient
let users = try await cachingClient.request(UsersEndpoint())Per-endpoint cache policy:
struct UsersEndpoint: APIEndpoint, CacheConfigurable {
var cachePolicy: CachePolicy { .cacheFirst(maxAge: 300) }
}
struct OrdersEndpoint: APIEndpoint, CacheConfigurable {
var cachePolicy: CachePolicy { .noCache }
}With SwiftUI:
struct UsersView: View {
@Environment(\.apiClient) private var apiClient
var body: some View {
List(users) { user in
Text(user.name)
}
.task {
// Automatically uses cache if available
users = try await apiClient.request(UsersEndpoint())
}
}
}Testing
@Test
func cachedResponseReturnedOnSecondRequest() async throws {
let mockClient = MockAPIClient()
let cache = InMemoryHTTPResponseCache()
let cachingClient = CachingAPIClient(wrapping: mockClient, cache: cache)
mockClient.mockResponse(for: UsersEndpoint.self, response: [.mock])
// First request hits network
let first = try await cachingClient.request(UsersEndpoint())
#expect(mockClient.requestCount == 1)
// Second request comes from cache
let second = try await cachingClient.request(UsersEndpoint())
#expect(mockClient.requestCount == 1) // No additional network call
#expect(first == second)
}References
- http-cache-patterns.md — Cache-Control directives, ETag flow, stale-while-revalidate
- templates.md — All production Swift templates
- Related:
generators/networking-layer— Base networking layer this decorates
HTTP Cache Patterns and Best Practices
Why Not Just URLCache?
URLCache works for basic scenarios but has limitations:
1. No per-endpoint control — Can't cache /users for 5 min and skip /orders 2. Server must cooperate — Requires proper Cache-Control headers from server 3. No stale-while-revalidate — Can't serve stale data while refreshing 4. No offline fallback — Doesn't serve expired cache when offline 5. Opaque storage — Hard to inspect, clear selectively, or migrate
This generator creates a transparent caching layer you control.
Cache-Control Directives
Standard Directives
| Directive | Meaning |
|---|---|
max-age=N | Response valid for N seconds |
no-cache | Must revalidate before using cached copy |
no-store | Never cache this response |
private | Only cache in user's device, not shared proxies |
public | May be cached by any cache |
must-revalidate | Must check server when stale |
stale-while-revalidate=N | May serve stale for N seconds while refreshing |
immutable | Content will never change (versioned URLs) |
Parsing Example
Cache-Control: public, max-age=300, stale-while-revalidate=60Means: cache for 5 minutes, then serve stale for 1 more minute while refreshing in background.
How Cache-Control Maps to Behavior
Request arrives
├── Has cached response?
│ ├── No → Fetch from network, cache if cacheable
│ └── Yes → Check freshness
│ ├── Fresh (within max-age) → Return cached
│ ├── Stale but within stale-while-revalidate
│ │ ├── Return cached immediately
│ │ └── Background: revalidate and update cache
│ └── Stale beyond tolerance
│ ├── Has ETag/Last-Modified? → Conditional request (If-None-Match / If-Modified-Since)
│ │ ├── 304 Not Modified → Return cached, refresh expiry
│ │ └── 200 → Return new, update cache
│ └── No validators → Fetch freshETag / Conditional Request Flow
How It Works
1. Server sends ETag: "abc123" (or Last-Modified: <date>) with response 2. Client stores ETag alongside cached data 3. On next request, client sends If-None-Match: "abc123" 4. Server responds:
304 Not Modified— cached data is still valid, no body transferred200 OK— new data, update cache
Benefits
- Bandwidth savings — 304 responses have no body
- Freshness guarantee — Server confirms data hasn't changed
- Works with no-cache — Even
no-cacheresponses use conditional requests
Implementation Pattern
// Store ETag when caching
struct CachedResponse {
let data: Data
let etag: String?
let lastModified: String?
let cachedAt: Date
let maxAge: TimeInterval?
}
// Send conditional headers on revalidation
func conditionalHeaders(for cached: CachedResponse) -> [String: String] {
var headers: [String: String] = [:]
if let etag = cached.etag {
headers["If-None-Match"] = etag
}
if let lastModified = cached.lastModified {
headers["If-Modified-Since"] = lastModified
}
return headers
}Stale-While-Revalidate Pattern
Best UX pattern for frequently accessed data:
1. Return stale cached data immediately (no loading spinner) 2. Fetch fresh data in background 3. Update cache and notify UI when fresh data arrives
// Conceptual flow
func requestWithSWR<T>(_ endpoint: Endpoint) async throws -> T {
if let cached = cache.get(endpoint.cacheKey), cached.isStale, cached.withinSWRWindow {
// Return stale immediately
Task { await revalidateInBackground(endpoint) }
return cached.value
}
// ... normal fetch
}When to Use Stale-While-Revalidate
| Use Case | SWR? | Why |
|---|---|---|
| User profile | ✅ | Data changes rarely, stale is fine |
| Feed/timeline | ✅ | Show last known, refresh in background |
| Shopping cart | ❌ | Must be accurate |
| Payment status | ❌ | Must be real-time |
| Config/settings | ✅ | Changes rarely, stale is fine briefly |
Offline Fallback Strategy
Network Reachability
Use NWPathMonitor (Network framework) — not Reachability third-party library:
import Network
actor NetworkReachability {
private let monitor = NWPathMonitor()
private(set) var isConnected = true
func start() {
monitor.pathUpdateHandler = { [weak self] path in
Task { await self?.update(path.status == .satisfied) }
}
monitor.start(queue: DispatchQueue(label: "network.monitor"))
}
private func update(_ connected: Bool) {
isConnected = connected
}
}Offline Decision Tree
Network unavailable
├── Has cached response (even expired)?
│ ├── Yes → Return cached + flag as stale
│ └── No → Throw NetworkError.offlineStale Indicator for UI
struct CacheResult<T> {
let value: T
let source: CacheSource
enum CacheSource {
case network // Fresh from server
case cache // Within max-age
case staleCache // Expired but served offline/SWR
}
var isStale: Bool { source == .staleCache }
}Cache Key Design
Good Cache Keys
Include everything that makes the response unique:
func cacheKey(for request: URLRequest) -> String {
var components = [request.httpMethod ?? "GET", request.url?.absoluteString ?? ""]
// Include relevant headers that affect response
if let accept = request.value(forHTTPHeaderField: "Accept") {
components.append("accept:\(accept)")
}
return components.joined(separator: "|")
}Don't Include
- Auth tokens (same endpoint, different users = same cache)
- Timestamps or request IDs
- Transient headers (User-Agent, etc.)
Exception: If different users get different responses from the same endpoint, include a user identifier in the cache key.
Disk Cache with LRU Eviction
File-Based Storage
CachesDirectory/
└── HTTPCache/
├── manifest.json # Index of cached entries
└── responses/
├── abc123.data # Response body
└── abc123.meta # Headers, ETag, expiryLRU Eviction
When disk cache exceeds size limit: 1. Sort entries by last access time 2. Remove oldest entries until under limit 3. Remove both .data and .meta files
Size Calculation
func currentDiskUsage() -> Int {
let fileManager = FileManager.default
guard let contents = try? fileManager.contentsOfDirectory(
at: cacheDirectory,
includingPropertiesForKeys: [.fileSizeKey]
) else { return 0 }
return contents.reduce(0) { total, url in
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0
return total + size
}
}Decorator Pattern for Integration
Why a Decorator?
The CachingAPIClient wraps any APIClient conformance without modifying it:
// Before: direct client
let client: APIClient = URLSessionAPIClient(config: .production)
// After: cached client (same interface)
let client: APIClient = CachingAPIClient(
wrapping: URLSessionAPIClient(config: .production),
cache: DiskHTTPResponseCache()
)- Existing code doesn't change
- Cache can be added/removed without touching call sites
- Easy to test: wrap a MockAPIClient with caching
Endpoint Cache Configuration
Endpoints opt into caching via protocol conformance:
protocol CacheConfigurable {
var cachePolicy: CachePolicy { get }
}
// Endpoints that don't conform use the default policy
extension APIEndpoint {
var cachePolicy: CachePolicy {
(self as? CacheConfigurable)?.cachePolicy ?? .default
}
}Anti-Patterns to Avoid
Don't Cache Mutations
// ❌ Never cache POST/PUT/DELETE responses
// ✅ Only cache GET (and optionally HEAD) requestsDon't Cache Auth-Dependent Without User Key
// ❌ Cache /api/me across users
// ✅ Include user ID in cache key for personalized endpointsDon't Ignore Cache-Control: no-store
// ❌ Cache everything regardless of server headers
// ✅ Respect no-store — the server says this data must not be persistedDon't Use In-Memory Only
// ❌ NSCache alone — cleared on memory pressure, lost on app restart
// ✅ Disk cache with memory layer for fast accessTesting Caches
Test Cache Hit/Miss
@Test func cacheHitAvoidNetworkCall() async throws {
let mock = MockAPIClient()
let cache = InMemoryHTTPResponseCache()
let client = CachingAPIClient(wrapping: mock, cache: cache)
mock.mockResponse(for: UsersEndpoint.self, response: [.mock])
_ = try await client.request(UsersEndpoint())
_ = try await client.request(UsersEndpoint())
#expect(mock.requestCount(for: UsersEndpoint.self) == 1)
}Test Cache Expiry
@Test func expiredCacheRefetches() async throws {
let cache = InMemoryHTTPResponseCache()
// Pre-populate with expired entry
cache.store(key: "GET|/users", data: oldData, maxAge: -1)
let mock = MockAPIClient()
let client = CachingAPIClient(wrapping: mock, cache: cache)
_ = try await client.request(UsersEndpoint())
#expect(mock.requestCount(for: UsersEndpoint.self) == 1) // Had to fetch
}Test Offline Fallback
@Test func offlineServesStaleCache() async throws {
let cache = InMemoryHTTPResponseCache()
cache.store(key: "GET|/users", data: staleData, maxAge: -1)
let mock = MockAPIClient()
mock.mockError(for: UsersEndpoint.self, error: NetworkError.networkUnavailable)
let client = CachingAPIClient(wrapping: mock, cache: cache, offlineEnabled: true)
let result = try await client.request(UsersEndpoint())
#expect(result == staleData.decoded()) // Served stale
}HTTP Cache Code Templates
Production-ready Swift templates for HTTP response caching. All code targets iOS 16+ / macOS 13+ and uses modern Swift concurrency.
CachePolicy.swift
import Foundation
/// Defines how an individual endpoint's responses should be cached.
enum CachePolicy: Sendable {
/// Use default behavior: respect server Cache-Control headers.
case `default`
/// Never cache this endpoint's responses.
case noCache
/// Always use cache if available, regardless of age.
/// Falls back to network only on cache miss.
case forceCache
/// Return cache immediately, revalidate in background.
/// `maxAge` controls how long the cached response is considered fresh.
case cacheFirst(maxAge: TimeInterval = 300)
/// Custom max-age override, ignoring server headers.
case custom(maxAge: TimeInterval)
}
/// Conform endpoints to this protocol to specify per-endpoint cache behavior.
protocol CacheConfigurable {
var cachePolicy: CachePolicy { get }
}HTTPCacheConfiguration.swift
import Foundation
/// Global configuration for the HTTP cache layer.
struct HTTPCacheConfiguration: Sendable {
/// Maximum memory cache size in bytes.
let memoryCapacity: Int
/// Maximum disk cache size in bytes.
let diskCapacity: Int
/// Default TTL when server provides no Cache-Control header.
let defaultMaxAge: TimeInterval
/// Whether to serve stale cache when offline.
let offlineFallbackEnabled: Bool
/// Default cache policy for endpoints that don't specify one.
let defaultPolicy: CachePolicy
static let small = HTTPCacheConfiguration(
memoryCapacity: 10 * 1024 * 1024, // 10 MB
diskCapacity: 50 * 1024 * 1024, // 50 MB
defaultMaxAge: 300,
offlineFallbackEnabled: true,
defaultPolicy: .default
)
static let medium = HTTPCacheConfiguration(
memoryCapacity: 25 * 1024 * 1024, // 25 MB
diskCapacity: 100 * 1024 * 1024, // 100 MB
defaultMaxAge: 300,
offlineFallbackEnabled: true,
defaultPolicy: .default
)
static let large = HTTPCacheConfiguration(
memoryCapacity: 50 * 1024 * 1024, // 50 MB
diskCapacity: 250 * 1024 * 1024, // 250 MB
defaultMaxAge: 300,
offlineFallbackEnabled: true,
defaultPolicy: .default
)
static let `default` = medium
}CacheControlHeader.swift
import Foundation
/// Parsed representation of a Cache-Control HTTP header.
struct CacheControlDirectives: Sendable {
var maxAge: TimeInterval?
var staleWhileRevalidate: TimeInterval?
var noCache: Bool = false
var noStore: Bool = false
var mustRevalidate: Bool = false
var isPublic: Bool = false
var isPrivate: Bool = false
var immutable: Bool = false
/// Whether this response is cacheable at all.
var isCacheable: Bool {
!noStore
}
/// Parse a Cache-Control header value string.
static func parse(_ headerValue: String) -> CacheControlDirectives {
var directives = CacheControlDirectives()
let parts = headerValue
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
for part in parts {
if part == "no-cache" {
directives.noCache = true
} else if part == "no-store" {
directives.noStore = true
} else if part == "must-revalidate" {
directives.mustRevalidate = true
} else if part == "public" {
directives.isPublic = true
} else if part == "private" {
directives.isPrivate = true
} else if part == "immutable" {
directives.immutable = true
} else if part.hasPrefix("max-age=") {
let value = part.dropFirst("max-age=".count)
directives.maxAge = TimeInterval(value)
} else if part.hasPrefix("stale-while-revalidate=") {
let value = part.dropFirst("stale-while-revalidate=".count)
directives.staleWhileRevalidate = TimeInterval(value)
}
}
return directives
}
}ConditionalRequestHandler.swift
import Foundation
/// Handles ETag and Last-Modified conditional request headers.
struct ConditionalRequestHandler: Sendable {
/// Add conditional headers to a request based on cached response metadata.
static func addConditionalHeaders(
to request: inout URLRequest,
cachedEntry: CachedResponseEntry
) {
if let etag = cachedEntry.etag {
request.setValue(etag, forHTTPHeaderField: "If-None-Match")
}
if let lastModified = cachedEntry.lastModified {
request.setValue(lastModified, forHTTPHeaderField: "If-Modified-Since")
}
}
/// Check if a response indicates the cached version is still valid.
static func isNotModified(_ response: HTTPURLResponse) -> Bool {
response.statusCode == 304
}
/// Extract cache validators from a response.
static func extractValidators(from response: HTTPURLResponse) -> (etag: String?, lastModified: String?) {
let etag = response.value(forHTTPHeaderField: "ETag")
let lastModified = response.value(forHTTPHeaderField: "Last-Modified")
return (etag, lastModified)
}
}HTTPResponseCache.swift
import Foundation
/// Metadata stored alongside cached response data.
struct CachedResponseEntry: Codable, Sendable {
let data: Data
let statusCode: Int
let etag: String?
let lastModified: String?
let cacheControl: String?
let cachedAt: Date
let maxAge: TimeInterval
var lastAccessedAt: Date
/// Whether the entry is fresh (within max-age).
var isFresh: Bool {
Date().timeIntervalSince(cachedAt) < maxAge
}
/// Whether the entry is within the stale-while-revalidate window.
func isWithinSWRWindow(swrDuration: TimeInterval) -> Bool {
let age = Date().timeIntervalSince(cachedAt)
return age < (maxAge + swrDuration)
}
}
/// Protocol for HTTP response cache storage.
protocol HTTPResponseCaching: Actor {
func get(_ key: String) -> CachedResponseEntry?
func store(_ key: String, entry: CachedResponseEntry)
func remove(_ key: String)
func removeAll()
}
/// Disk-backed HTTP response cache with LRU eviction.
actor DiskHTTPResponseCache: HTTPResponseCaching {
private let cacheDirectory: URL
private let maxDiskSize: Int
private var memoryCache: [String: CachedResponseEntry] = [:]
private let maxMemoryEntries: Int
init(
directory: URL? = nil,
maxDiskSize: Int = 100 * 1024 * 1024,
maxMemoryEntries: Int = 100
) {
self.cacheDirectory = directory ?? FileManager.default
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent("HTTPCache", isDirectory: true)
self.maxDiskSize = maxDiskSize
self.maxMemoryEntries = maxMemoryEntries
try? FileManager.default.createDirectory(
at: cacheDirectory,
withIntermediateDirectories: true
)
}
func get(_ key: String) -> CachedResponseEntry? {
// Check memory first
if var entry = memoryCache[key] {
entry.lastAccessedAt = Date()
memoryCache[key] = entry
return entry
}
// Check disk
let fileURL = cacheDirectory.appendingPathComponent(key.sha256Hash)
guard let data = try? Data(contentsOf: fileURL),
var entry = try? JSONDecoder().decode(CachedResponseEntry.self, from: data) else {
return nil
}
// Promote to memory
entry.lastAccessedAt = Date()
memoryCache[key] = entry
evictMemoryIfNeeded()
return entry
}
func store(_ key: String, entry: CachedResponseEntry) {
// Store in memory
memoryCache[key] = entry
evictMemoryIfNeeded()
// Store on disk
let fileURL = cacheDirectory.appendingPathComponent(key.sha256Hash)
if let data = try? JSONEncoder().encode(entry) {
try? data.write(to: fileURL)
}
Task { await evictDiskIfNeeded() }
}
func remove(_ key: String) {
memoryCache.removeValue(forKey: key)
let fileURL = cacheDirectory.appendingPathComponent(key.sha256Hash)
try? FileManager.default.removeItem(at: fileURL)
}
func removeAll() {
memoryCache.removeAll()
try? FileManager.default.removeItem(at: cacheDirectory)
try? FileManager.default.createDirectory(
at: cacheDirectory,
withIntermediateDirectories: true
)
}
// MARK: - Eviction
private func evictMemoryIfNeeded() {
guard memoryCache.count > maxMemoryEntries else { return }
let sorted = memoryCache.sorted { $0.value.lastAccessedAt < $1.value.lastAccessedAt }
let toRemove = sorted.prefix(memoryCache.count - maxMemoryEntries)
for (key, _) in toRemove {
memoryCache.removeValue(forKey: key)
}
}
private func evictDiskIfNeeded() {
let fileManager = FileManager.default
guard let contents = try? fileManager.contentsOfDirectory(
at: cacheDirectory,
includingPropertiesForKeys: [.fileSizeKey, .contentAccessDateKey],
options: .skipsHiddenFiles
) else { return }
let totalSize = contents.reduce(0) { total, url in
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0
return total + size
}
guard totalSize > maxDiskSize else { return }
// Sort by access date, remove oldest
let sorted = contents.sorted { a, b in
let dateA = (try? a.resourceValues(forKeys: [.contentAccessDateKey]))?.contentAccessDate ?? .distantPast
let dateB = (try? b.resourceValues(forKeys: [.contentAccessDateKey]))?.contentAccessDate ?? .distantPast
return dateA < dateB
}
var currentSize = totalSize
for url in sorted {
guard currentSize > maxDiskSize else { break }
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0
try? fileManager.removeItem(at: url)
currentSize -= size
}
}
}
/// In-memory cache for testing.
actor InMemoryHTTPResponseCache: HTTPResponseCaching {
private var storage: [String: CachedResponseEntry] = [:]
func get(_ key: String) -> CachedResponseEntry? { storage[key] }
func store(_ key: String, entry: CachedResponseEntry) { storage[key] = entry }
func remove(_ key: String) { storage.removeValue(forKey: key) }
func removeAll() { storage.removeAll() }
}
// MARK: - String Hashing Extension
extension String {
/// SHA-256 hash for safe file names.
var sha256Hash: String {
import CryptoKit
let data = Data(self.utf8)
let hash = SHA256.hash(data: data)
return hash.compactMap { String(format: "%02x", $0) }.joined()
}
}CachingAPIClient.swift
import Foundation
/// Decorator that adds HTTP caching to any APIClient conformance.
///
/// Uses the decorator pattern: wraps an existing client without modifying it.
/// Cache behavior is controlled per-endpoint via `CacheConfigurable`.
final class CachingAPIClient: APIClient, Sendable {
private let wrapped: APIClient
private let cache: any HTTPResponseCaching
private let configuration: HTTPCacheConfiguration
private let reachability: NetworkReachability?
init(
wrapping client: APIClient,
cache: any HTTPResponseCaching,
configuration: HTTPCacheConfiguration = .default,
reachability: NetworkReachability? = nil
) {
self.wrapped = client
self.cache = cache
self.configuration = configuration
self.reachability = reachability
}
func request<E: APIEndpoint>(_ endpoint: E) async throws -> E.Response {
let cacheKey = Self.cacheKey(for: endpoint)
let policy = (endpoint as? CacheConfigurable)?.cachePolicy ?? configuration.defaultPolicy
switch policy {
case .noCache:
return try await wrapped.request(endpoint)
case .forceCache:
if let cached = await cache.get(cacheKey) {
return try JSONDecoder().decode(E.Response.self, from: cached.data)
}
return try await fetchAndCache(endpoint, key: cacheKey)
case .cacheFirst(let maxAge):
if let cached = await cache.get(cacheKey), cached.isFresh {
return try JSONDecoder().decode(E.Response.self, from: cached.data)
}
// Stale-while-revalidate: return stale and refresh
if let cached = await cache.get(cacheKey) {
let decoded = try JSONDecoder().decode(E.Response.self, from: cached.data)
Task { try? await self.fetchAndCache(endpoint, key: cacheKey) }
return decoded
}
return try await fetchAndCache(endpoint, key: cacheKey, maxAge: maxAge)
case .default, .custom:
return try await handleDefault(endpoint, key: cacheKey, policy: policy)
}
}
// MARK: - Private
private func handleDefault<E: APIEndpoint>(
_ endpoint: E,
key: String,
policy: CachePolicy
) async throws -> E.Response {
// Check cache
if let cached = await cache.get(key), cached.isFresh {
return try JSONDecoder().decode(E.Response.self, from: cached.data)
}
// Try network
do {
let maxAge: TimeInterval? = if case .custom(let age) = policy { age } else { nil }
return try await fetchAndCache(endpoint, key: key, maxAge: maxAge)
} catch {
// Offline fallback
if configuration.offlineFallbackEnabled,
let cached = await cache.get(key) {
return try JSONDecoder().decode(E.Response.self, from: cached.data)
}
throw error
}
}
@discardableResult
private func fetchAndCache<E: APIEndpoint>(
_ endpoint: E,
key: String,
maxAge: TimeInterval? = nil
) async throws -> E.Response {
let response = try await wrapped.request(endpoint)
let data = try JSONEncoder().encode(response)
let entry = CachedResponseEntry(
data: data,
statusCode: 200,
etag: nil,
lastModified: nil,
cacheControl: nil,
cachedAt: Date(),
maxAge: maxAge ?? configuration.defaultMaxAge,
lastAccessedAt: Date()
)
await cache.store(key, entry: entry)
return response
}
private static func cacheKey<E: APIEndpoint>(for endpoint: E) -> String {
let method = "\(endpoint.method)"
let path = endpoint.path
let query = endpoint.queryItems?
.sorted { $0.name < $1.name }
.map { "\($0.name)=\($0.value ?? "")" }
.joined(separator: "&") ?? ""
return "\(method)|\(path)|\(query)"
}
}NetworkReachability.swift
import Foundation
import Network
/// Monitors network connectivity using NWPathMonitor.
///
/// Use to detect offline state and enable stale cache fallback.
actor NetworkReachability {
private let monitor: NWPathMonitor
private(set) var isConnected: Bool = true
private(set) var isExpensive: Bool = false
init() {
self.monitor = NWPathMonitor()
}
func start() {
monitor.pathUpdateHandler = { [weak self] path in
Task { [weak self] in
await self?.updatePath(path)
}
}
monitor.start(queue: DispatchQueue(label: "com.app.network.monitor"))
}
func stop() {
monitor.cancel()
}
private func updatePath(_ path: NWPath) {
isConnected = path.status == .satisfied
isExpensive = path.isExpensive
}
}