Files
purchases-ios/Sources/Networking/HTTPClient/HTTPRequestPath.swift
T
Rick 7ee15c73a2 feat(remote-config): add network scaffolding for GET /v2/config endpoint (#6854)
* feat(remote-config): add network scaffolding for GET /v2/config endpoint

Adds RemoteConfigResponse model, GetRemoteConfigOperation, RemoteConfigAPI,
and wires everything into Backend. Unknown topic keys in the manifest are
silently dropped for forward compatibility. Mirrors Android PR #3435.

* fix(remote-config): use lowercase "default" variant key in test fixtures

Aligns with Android convention (DEFAULT_ENTRY_ID = "default") established
in purchases-android PR #3437.

* test(remote-config): add snapshot files for all CI OS versions

BackendGetRemoteConfigTests was only recording snapshots for iOS 26.
CI also runs tests on iOS 14/15/16/17/18, tvOS 18, macOS, and watchOS,
each of which requires its own snapshot file.

Derived from existing snapshot patterns in BackendGetRewardVerificationStatusTests.

* fix(remote-config): log warning when unknown topic key is received

Follows the codebase convention of logging unknown backend values
(e.g. WorkflowsResponse unknown_workflow_trigger_type) rather than
silently dropping them.

* refactor(remote-config): rename wireKey to rawValue, add WIP comment for signature verification

Aligns Topic with Swift's RawRepresentable convention and matches the
Android SDK's comment indicating signature verification will be enabled
once the remote config endpoint is finalized.

* refactor(remote-config): enable etag and simplify HTTPRequestPath cases
2026-06-02 17:19:05 +02:00

456 lines
13 KiB
Swift

//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// HTTPRequestPath.swift
//
// Created by Nacho Soto on 8/8/23.
// swiftlint:disable file_length
import Foundation
protocol HTTPRequestPath {
/// The base URL for requests to this path.
static var serverHostURL: URL { get }
/// The fallback URLs to use when the main server is down.
///
/// Not all endpoints have a fallback URL, but some do.
var fallbackUrls: [URL] { get }
/// Whether requests to this path are authenticated.
var authenticated: Bool { get }
/// Whether requests to this path can be cached using `ETagManager`.
var shouldSendEtag: Bool { get }
/// Whether the endpoint will perform signature verification.
var supportsSignatureVerification: Bool { get }
/// Whether endpoint requires a nonce for signature verification.
var needsNonceForSigning: Bool { get }
/// The name of the endpoint.
var name: String { get }
/// The full relative path for this endpoint.
var relativePath: String { get }
/// The fallback relative path for this endpoint, if any.
var fallbackRelativePath: String? { get }
}
extension HTTPRequestPath {
var fallbackUrls: [URL] {
return []
}
var supportsFallbackURLs: Bool {
!fallbackUrls.isEmpty
}
var fallbackRelativePath: String? {
return nil
}
var url: URL? { return self.url(proxyURL: nil) }
func url(proxyURL: URL? = nil, fallbackUrlIndex: Int? = nil) -> URL? {
let baseURL: URL
if let proxyURL {
// When a Proxy URL is set, we don't support fallback URLs
guard fallbackUrlIndex == nil else {
// This is to safe guard against a potential infinite loop if the caller mistakenly
// passes both a proxyURL and a fallbackUrlIndex.
return nil
}
baseURL = proxyURL
} else if let fallbackUrlIndex {
return self.fallbackUrls[safe: fallbackUrlIndex]
} else {
baseURL = Self.serverHostURL
}
return URL(string: self.relativePath, relativeTo: baseURL)
}
}
// MARK: - Main paths
extension HTTPRequest {
enum Path: Hashable {
case getCustomerInfo(appUserID: String)
case getOfferings(appUserID: String)
case getIntroEligibility(appUserID: String)
case logIn
case postAttributionData(appUserID: String)
case postOfferForSigning
case postReceiptData
case postSubscriberAttributes(appUserID: String)
case postAdServicesToken(appUserID: String)
case health
case appHealthReport(appUserID: String)
case appHealthReportAvailability(appUserID: String)
case getProductEntitlementMapping
case getCustomerCenterConfig(appUserID: String)
case getVirtualCurrencies(appUserID: String)
case getWorkflow(appUserID: String, workflowId: String)
case getWorkflows(appUserID: String, type: String?)
case postRedeemWebPurchase
case postCreateTicket
case isPurchaseAllowedByRestoreBehavior(appUserID: String)
case rewardVerificationStatus(appUserID: String, clientTransactionID: String)
// WIP: endpoint path and signing requirements subject to change
case getRemoteConfig
}
enum FeatureEventsPath: Hashable {
case postEvents
}
enum DiagnosticsPath: Hashable {
case postDiagnostics
}
enum WebBillingPath: Hashable {
case getWebOfferingProducts(appUserID: String)
case getWebBillingProducts(userId: String, productIds: Set<String>)
}
enum AdPath: Hashable {
case postEvents
}
}
extension HTTPRequest.Path: HTTPRequestPath {
static var serverHostURL: URL {
SystemInfo.apiBaseURL
}
private static let fallbackServerHostURLs = [
URL(string: "https://api-production.8-lives-cat.io")
]
var fallbackRelativePath: String? {
switch self {
case .getOfferings:
return "/v1/offerings"
case .getProductEntitlementMapping:
return "/v1/product_entitlement_mapping"
default:
return nil
}
}
var fallbackUrls: [URL] {
guard let fallbackRelativePath = self.fallbackRelativePath else {
return []
}
return Self.fallbackServerHostURLs.compactMap { baseURL in
guard let baseURL = baseURL,
let fallbackUrl = URL(string: fallbackRelativePath, relativeTo: baseURL) else {
let errorMessage = "Invalid fallback URL configuration for path: \(self.name)"
assertionFailure(errorMessage)
Logger.error(errorMessage)
return nil
}
return fallbackUrl
}
}
var authenticated: Bool {
switch self {
case .getCustomerInfo,
.getOfferings,
.getWorkflow,
.getWorkflows,
.getIntroEligibility,
.logIn,
.postAttributionData,
.postOfferForSigning,
.postReceiptData,
.postSubscriberAttributes,
.postAdServicesToken,
.postRedeemWebPurchase,
.getProductEntitlementMapping,
.getCustomerCenterConfig,
.getVirtualCurrencies,
.appHealthReport,
.postCreateTicket,
.isPurchaseAllowedByRestoreBehavior,
.rewardVerificationStatus,
.getRemoteConfig:
return true
case .health,
.appHealthReportAvailability:
return false
}
}
var shouldSendEtag: Bool {
switch self {
case .getCustomerInfo,
.getOfferings,
.getWorkflow,
.getWorkflows,
.getIntroEligibility,
.logIn,
.postAttributionData,
.postOfferForSigning,
.postReceiptData,
.postSubscriberAttributes,
.postAdServicesToken,
.postRedeemWebPurchase,
.getProductEntitlementMapping,
.getCustomerCenterConfig,
.getVirtualCurrencies,
.appHealthReport,
.postCreateTicket,
.isPurchaseAllowedByRestoreBehavior,
.rewardVerificationStatus,
.getRemoteConfig:
return true
case .health,
.appHealthReportAvailability:
return false
}
}
var supportsSignatureVerification: Bool {
switch self {
case .getCustomerInfo,
.logIn,
.postReceiptData,
.health,
.getOfferings,
.getProductEntitlementMapping,
.getVirtualCurrencies,
.getWorkflow,
.getWorkflows,
.appHealthReport,
.appHealthReportAvailability,
.isPurchaseAllowedByRestoreBehavior:
return true
case .getIntroEligibility,
.postSubscriberAttributes,
.postAttributionData,
.postAdServicesToken,
.postOfferForSigning,
.postRedeemWebPurchase,
.getCustomerCenterConfig,
.postCreateTicket,
// WIP: Move to true when we have the final endpoint for remote config, and we can remove the fallback
.getRemoteConfig:
return false
case .rewardVerificationStatus:
return true
}
}
var needsNonceForSigning: Bool {
switch self {
case .getCustomerInfo,
.logIn,
.postReceiptData,
.getVirtualCurrencies,
.health,
.appHealthReportAvailability,
.isPurchaseAllowedByRestoreBehavior,
.rewardVerificationStatus:
return true
case .getWorkflow,
.getWorkflows,
.getOfferings,
.getIntroEligibility,
.postSubscriberAttributes,
.postAttributionData,
.postAdServicesToken,
.postOfferForSigning,
.postRedeemWebPurchase,
.getProductEntitlementMapping,
.getCustomerCenterConfig,
.appHealthReport,
.postCreateTicket,
.getRemoteConfig:
return false
}
}
var relativePath: String {
switch self {
case .getRemoteConfig:
return "/v2/\(self.pathComponent)"
default:
return "/v1/\(self.pathComponent)"
}
}
var pathComponent: String {
switch self {
case let .getCustomerInfo(appUserID):
return "subscribers/\(Self.escape(appUserID))"
case let .getOfferings(appUserID):
return "subscribers/\(Self.escape(appUserID))/offerings"
case let .getIntroEligibility(appUserID):
return "subscribers/\(Self.escape(appUserID))/intro_eligibility"
case let .appHealthReport(appUserID):
return "subscribers/\(Self.escape(appUserID))/health_report"
case let .appHealthReportAvailability(appUserID):
return "subscribers/\(Self.escape(appUserID))/health_report_availability"
case .logIn:
return "subscribers/identify"
case let .postAttributionData(appUserID):
return "subscribers/\(Self.escape(appUserID))/attribution"
case let .postAdServicesToken(appUserID):
return "subscribers/\(Self.escape(appUserID))/adservices_attribution"
case .postOfferForSigning:
return "offers"
case .postReceiptData:
return "receipts"
case let .postSubscriberAttributes(appUserID):
return "subscribers/\(Self.escape(appUserID))/attributes"
case .health:
return "health"
case .getProductEntitlementMapping:
return "product_entitlement_mapping"
case let .getCustomerCenterConfig(appUserID):
return "customercenter/\(Self.escape(appUserID))"
case .postRedeemWebPurchase:
return "subscribers/redeem_purchase"
case let .getVirtualCurrencies(appUserID):
return "subscribers/\(Self.escape(appUserID))/virtual_currencies"
case let .getWorkflow(appUserID, workflowId):
return "subscribers/\(Self.escape(appUserID))/workflows/\(Self.escape(workflowId))"
case let .getWorkflows(appUserID, type):
let base = "subscribers/\(Self.escape(appUserID))/workflows"
if let type = type {
return "\(base)?type=\(Self.escape(type))"
}
return base
case .postCreateTicket:
return "customercenter/support/create-ticket"
case let .isPurchaseAllowedByRestoreBehavior(appUserID):
return "subscribers/\(Self.escape(appUserID))/restore/eligibility"
case let .rewardVerificationStatus(appUserID, clientTransactionID):
return "subscribers/\(Self.escape(appUserID))/ads/reward_verifications/\(Self.escape(clientTransactionID))"
case .getRemoteConfig:
return "config"
}
}
var name: String {
switch self {
case .getCustomerInfo:
return "get_customer"
case .getOfferings:
return "get_offerings"
case .getWorkflow:
return "get_workflow"
case .getWorkflows:
return "get_workflows"
case .getIntroEligibility:
return "get_intro_eligibility"
case .logIn:
return "log_in"
case .postAttributionData:
return "post_attribution"
case .postAdServicesToken:
return "post_adservices_token"
case .postOfferForSigning:
return "post_offer_for_signing"
case .postReceiptData:
return "post_receipt"
case .postSubscriberAttributes:
return "post_attributes"
case .health:
return "post_health"
case .getProductEntitlementMapping:
return "get_product_entitlement_mapping"
case .getCustomerCenterConfig:
return "customer_center"
case .postRedeemWebPurchase:
return "post_redeem_web_purchase"
case .appHealthReport:
return "get_app_health_report"
case .getVirtualCurrencies:
return "get_virtual_currencies"
case .appHealthReportAvailability:
return "get_app_health_report_availability"
case .postCreateTicket:
return "post_create_ticket"
case .isPurchaseAllowedByRestoreBehavior:
return "post_restore_eligibility"
case .rewardVerificationStatus:
return "get_reward_verification_status"
case .getRemoteConfig:
return "get_remote_config"
}
}
private static func escape(_ appUserID: String) -> String {
return appUserID.trimmedAndEscaped
}
}