Files
purchases-ios/Sources/Networking/RemoteConfigAPI.swift
T
Rick b82223e5a1 feat(remote-config): add static fallback config endpoint (#7182)
* feat(remote-config): add static fallback config endpoint

* refactor(remote-config): clarify static fallback path name

* test(remote-config): cover static fallback signature failures

* fix(remote-config): use static fallback only without cache

* test(remote-config): cover static fallback external blobs

* refactor(remote-config): align static fallback naming

* refactor(remote-config): scope static fallback path

* fix(remote-config): mark fallback no content fresh

* fix(remote-config): align fallback accept headers

* refactor(remote-config): rename fallback endpoint types

* fix(remote-config): enable etags for fallback config

* fix(remote-config): skip fallback when proxy is configured

* fix(remote-config): rely on etag cache for fallback

* refactor(remote-config): reuse fallback retry eligibility

* fix(remote-config): disable after fallback client errors
2026-07-10 14:17:31 +02:00

181 lines
5.8 KiB
Swift

//
// RemoteConfigAPI.swift
// RevenueCat
//
// Created by Rick van der Linden on 27/05/2026.
// Copyright © 2026 RevenueCat, Inc. All rights reserved.
import Foundation
protocol RemoteConfigAPIType: AnyObject {
typealias RemoteConfigResponseHandler = Backend.ResponseHandler<RemoteConfigFetchResult>
typealias FallbackResponseHandler = Backend.ResponseHandler<RemoteConfigFallbackFetchResult>
func getRemoteConfig(
request: RemoteConfigRequest,
isAppBackgrounded: Bool,
completion: @escaping RemoteConfigResponseHandler
)
func getRemoteConfigFallback(
domain: String,
isAppBackgrounded: Bool,
completion: @escaping FallbackResponseHandler
)
}
class RemoteConfigAPI: RemoteConfigAPIType {
typealias RemoteConfigResponseHandler = Backend.ResponseHandler<RemoteConfigFetchResult>
typealias FallbackResponseHandler = Backend.ResponseHandler<RemoteConfigFallbackFetchResult>
private let callbackCache: CallbackCache<RemoteConfigCallback>
private let fallbackCallbackCache: CallbackCache<RemoteConfigFallbackCallback>
private let backendConfig: BackendConfiguration
init(backendConfig: BackendConfiguration) {
self.backendConfig = backendConfig
self.callbackCache = .init()
self.fallbackCallbackCache = .init()
}
func getRemoteConfig(
request: RemoteConfigRequest,
isAppBackgrounded: Bool,
completion: @escaping RemoteConfigResponseHandler
) {
let factory = GetRemoteConfigOperation.createFactory(
configuration: self.backendConfig,
callbackCache: self.callbackCache,
request: request
)
let callback = RemoteConfigCallback(cacheKey: factory.cacheKey, completion: completion)
let cacheStatus = self.callbackCache.add(callback)
self.backendConfig.addCacheableOperation(
with: factory,
delay: .default(forBackgroundedApp: isAppBackgrounded),
cacheStatus: cacheStatus
)
}
func getRemoteConfigFallback(
domain: String,
isAppBackgrounded: Bool,
completion: @escaping FallbackResponseHandler
) {
let factory = GetRemoteConfigFallbackOperation.createFactory(
configuration: self.backendConfig,
callbackCache: self.fallbackCallbackCache,
domain: domain
)
let callback = RemoteConfigFallbackCallback(cacheKey: factory.cacheKey, completion: completion)
let cacheStatus = self.fallbackCallbackCache.add(callback)
self.backendConfig.addCacheableOperation(
with: factory,
delay: .default(forBackgroundedApp: isAppBackgrounded),
cacheStatus: cacheStatus
)
}
}
struct RemoteConfigFetchResult {
/// `nil` represents a successful `204 No Content` response. Malformed or undecodable
/// container bytes should fail before this result is created.
let container: RemoteConfigContainer?
let verificationResult: VerificationResult
init(response: VerifiedHTTPResponse<RemoteConfigContainer?>) {
self.container = response.body
self.verificationResult = response.verificationResult
}
}
struct RemoteConfigFallbackFetchResult {
let configuration: RemoteConfiguration
let verificationResult: VerificationResult
init(response: VerifiedHTTPResponse<RemoteConfiguration>) {
self.configuration = response.body
self.verificationResult = response.verificationResult
}
}
struct RemoteConfigContainer {
/// Underlying generic RC Container parsed from the remote config response.
let rcContainer: RCContainer
/// The first RC Container element, interpreted as the remote config payload for `/v1/config/<domain>`.
let configElement: RCContainer.Element
/// Inline blob elements delivered with the remote config response, keyed by stored checksum.
let inlineContentElements: [String: RCContainer.Element]
/// Parses a remote config response container and extracts the required config element.
///
/// The config element is authenticated by response signature verification over its payload bytes.
/// Inline content elements are opportunistic cache entries and are validated only when they are written
/// to the blob store.
init(data: Data) throws {
let container = try RCContainer(data: data)
try self.init(rcContainer: container)
}
init(rcContainer container: RCContainer) throws {
let configElement = try Self.configElement(in: container)
self.rcContainer = container
self.configElement = configElement
self.inlineContentElements = Dictionary(
container.elements.dropFirst().map { ($0.checksum, $0) },
uniquingKeysWith: { _, last in last }
)
}
}
extension RemoteConfigContainer: HTTPResponseBody {
static func create(with data: Data) throws -> RemoteConfigContainer {
return try .init(data: data)
}
}
extension RemoteConfigContainer {
/// Parses only the first RC Container element, which remote config defines as the config element.
///
/// This avoids parsing the whole container when signature verification only needs the config
/// payload.
static func configElement(from data: Data) throws -> RCContainer.Element {
var parser = RCContainer.ElementParser(data: data)
try parser.moveToFirstElement()
guard parser.hasRemainingBytes else {
throw RCContainer.Parser.FormatError.missingElement(index: 0)
}
return try parser.parseElement(index: 0)
}
static func configElement(in container: RCContainer) throws -> RCContainer.Element {
guard let configElement = container.elements.first else {
throw RCContainer.Parser.FormatError.missingElement(index: 0)
}
return configElement
}
}