mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-09 22:35:56 +05:45
* 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
456 lines
13 KiB
Swift
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
|
|
}
|
|
}
|