Files
purchases-ios/Sources/Networking/HTTPClient/HTTPRequestPath.swift
T

737 lines
22 KiB
Swift
Raw Normal View History

//
// 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 }
2025-10-29 18:43:19 +01:00
/// The fallback URLs to use when the main server is down.
///
2025-10-29 18:43:19 +01:00
/// 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 full relative path for this endpoint when using IAM tokens.
var relativeIAMPath: String { get }
/// The fallback relative path for this endpoint, if any.
var fallbackRelativePath: String? { get }
/// Whether this path resolves its base host from the API source provider (the main-API host
/// list with failover) rather than the static `serverHostURL`.
var usesAPISources: Bool { get }
/// Whether this path's `serverHostURL` is a fallback host rather than the main API host.
///
/// Requests to such a path are fallback attempts from the very first try, even though
/// `HTTPClient`'s own fallback walk never started, so they must not be treated as main-source
/// requests when picking a timeout or updating the per-host fail-fast memory.
var isFallbackHostPath: Bool { get }
/// Additional headers specific to this endpoint.
var additionalHeaders: HTTPRequest.Headers { get }
/// Provides endpoint-specific inputs for response signature verification.
var responseSignatureContextProvider: ResponseSignatureContextProvider { get }
/// Whether this path corresponds to an IAM request
var isIAMPath: Bool { get }
}
/// Provides endpoint-specific inputs for backend response signature verification.
///
/// Most endpoints use the raw response body and request body. Specialized endpoints can override
/// either input.
protocol ResponseSignatureContextProvider {
/// Returns the response bytes that should be verified against the backend signature.
///
/// This may throw when deriving the signed payload requires validating response structure first.
func responsePayloadForSignature(from body: Data?, statusCode: HTTPStatusCode) throws -> Data?
/// Returns the request body component to include in signature parameters, if any.
func requestBodyForSignature(for request: HTTPRequest) -> HTTPRequestBody?
}
struct DefaultResponseSignatureContextProvider: ResponseSignatureContextProvider {
func responsePayloadForSignature(from body: Data?, statusCode: HTTPStatusCode) throws -> Data? {
return body
}
func requestBodyForSignature(for request: HTTPRequest) -> HTTPRequestBody? {
return request.requestBody
}
}
extension HTTPRequestPath {
2025-10-29 18:43:19 +01:00
var fallbackUrls: [URL] {
return []
}
var supportsFallbackURLs: Bool {
!fallbackUrls.isEmpty
}
var fallbackRelativePath: String? {
return nil
}
var relativeIAMPath: String {
return self.relativePath
}
var usesAPISources: Bool {
return false
}
var isFallbackHostPath: Bool {
return false
}
var additionalHeaders: HTTPRequest.Headers {
return [:]
}
var responseSignatureContextProvider: ResponseSignatureContextProvider {
return DefaultResponseSignatureContextProvider()
}
var isIAMPath: Bool {
return false
}
func url(proxyURL: URL? = nil,
apiSourceURL: URL? = nil,
fallbackUrlIndex: Int? = nil,
preferIAMPath: Bool) -> URL? {
let baseURL: URL
if let proxyURL {
// When a Proxy URL is set, we don't support API sources or fallback URLs
guard fallbackUrlIndex == nil, apiSourceURL == nil else {
2025-10-29 18:43:19 +01:00
// This is to safe guard against a potential infinite loop if the caller mistakenly
// passes a proxyURL together with an apiSourceURL or a fallbackUrlIndex.
return nil
}
2025-10-29 18:43:19 +01:00
baseURL = proxyURL
} else if let fallbackUrlIndex {
return self.fallbackUrls[safe: fallbackUrlIndex]
} else if let apiSourceURL {
baseURL = apiSourceURL
} else {
baseURL = Self.serverHostURL
}
let path = preferIAMPath ? self.relativeIAMPath : self.relativePath
return URL(string: path, 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)
2025-07-07 12:11:27 -05:00
case getVirtualCurrencies(appUserID: String)
2026-08-27 16:14:19 -06:00
case spendVirtualCurrencies
case postRedeemWebPurchase
case postExternalPurchaseToken
case postCreateTicket
case isPurchaseAllowedByRestoreBehavior(appUserID: String)
case rewardVerificationStatus(appUserID: String, clientTransactionID: String)
case remoteConfig(domain: String)
2026-08-21 14:04:22 -06:00
case tokenLogin
case tokenRefresh
case tokenLogOut
}
enum FallbackPath: Hashable {
case remoteConfig(domain: String)
}
enum FeatureEventsPath: Hashable {
case postEvents
}
2024-04-10 13:00:48 +02:00
enum DiagnosticsPath: Hashable {
case postDiagnostics
}
enum WebBillingPath: Hashable {
case getWebOfferingProducts(appUserID: String)
2025-07-29 18:31:15 +02:00
case getWebBillingProducts(userId: String, productIds: Set<String>)
case postHostedCheckout
}
enum AdPath: Hashable {
case postEvents
}
}
extension HTTPRequest.Path: HTTPRequestPath {
static var serverHostURL: URL {
SystemInfo.apiBaseURL
}
var usesAPISources: Bool {
return true
}
private static let fallbackServerHostURLs = [
URL(string: "https://api-production.8-lives-cat.io")
]
2025-10-29 18:43:19 +01:00
var fallbackRelativePath: String? {
switch self {
2025-10-29 18:43:19 +01:00
case .getOfferings:
return "/v1/offerings"
2025-10-29 18:43:19 +01:00
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,
.getIntroEligibility,
.logIn,
.postAttributionData,
.postOfferForSigning,
.postReceiptData,
.postSubscriberAttributes,
.postAdServicesToken,
.postRedeemWebPurchase,
.postExternalPurchaseToken,
.getProductEntitlementMapping,
.getCustomerCenterConfig,
2025-07-07 12:11:27 -05:00
.getVirtualCurrencies,
2026-08-27 16:14:19 -06:00
.spendVirtualCurrencies,
.appHealthReport,
.postCreateTicket,
.isPurchaseAllowedByRestoreBehavior,
.rewardVerificationStatus,
2026-08-21 14:04:22 -06:00
.remoteConfig,
.tokenLogin,
.tokenRefresh,
.tokenLogOut:
return true
case .health,
.appHealthReportAvailability:
return false
}
}
var shouldSendEtag: Bool {
switch self {
case .getCustomerInfo,
.getOfferings,
.getIntroEligibility,
.logIn,
.postAttributionData,
.postOfferForSigning,
.postReceiptData,
.postSubscriberAttributes,
.postAdServicesToken,
.postRedeemWebPurchase,
.getProductEntitlementMapping,
.getCustomerCenterConfig,
2025-07-07 12:11:27 -05:00
.getVirtualCurrencies,
2026-08-27 16:14:19 -06:00
.spendVirtualCurrencies,
.appHealthReport,
.postCreateTicket,
.isPurchaseAllowedByRestoreBehavior,
2026-08-21 14:04:22 -06:00
.rewardVerificationStatus,
.tokenLogin,
.tokenRefresh,
.tokenLogOut:
return true
case .remoteConfig,
.health,
.appHealthReportAvailability,
.postExternalPurchaseToken:
return false
}
}
var supportsSignatureVerification: Bool {
switch self {
case .getCustomerInfo,
.logIn,
.postReceiptData,
.health,
.getOfferings,
.getProductEntitlementMapping,
2025-07-07 12:11:27 -05:00
.getVirtualCurrencies,
2026-08-27 16:14:19 -06:00
.spendVirtualCurrencies,
.appHealthReport,
.appHealthReportAvailability,
.isPurchaseAllowedByRestoreBehavior,
.remoteConfig,
.rewardVerificationStatus:
return true
case .getIntroEligibility,
.postSubscriberAttributes,
.postAttributionData,
.postAdServicesToken,
.postOfferForSigning,
.postRedeemWebPurchase,
.postExternalPurchaseToken,
.getCustomerCenterConfig,
2026-08-21 14:04:22 -06:00
.postCreateTicket,
.tokenLogin,
.tokenRefresh,
.tokenLogOut:
return false
}
}
var needsNonceForSigning: Bool {
switch self {
case .getCustomerInfo,
.logIn,
.postReceiptData,
2025-07-07 12:11:27 -05:00
.getVirtualCurrencies,
2026-08-27 16:14:19 -06:00
.spendVirtualCurrencies,
.health,
.appHealthReportAvailability,
.isPurchaseAllowedByRestoreBehavior,
.remoteConfig,
2026-08-21 14:04:22 -06:00
.rewardVerificationStatus,
.tokenLogin,
.tokenRefresh,
.tokenLogOut:
return true
case .getOfferings,
.getIntroEligibility,
.postSubscriberAttributes,
.postAttributionData,
.postAdServicesToken,
.postOfferForSigning,
.postRedeemWebPurchase,
.postExternalPurchaseToken,
.getProductEntitlementMapping,
.getCustomerCenterConfig,
.appHealthReport,
.postCreateTicket:
return false
}
}
var responseSignatureContextProvider: ResponseSignatureContextProvider {
switch self {
case .remoteConfig:
return RemoteConfigSignatureContextProvider()
default:
return DefaultResponseSignatureContextProvider()
}
}
var relativePath: String {
let component = self.pathComponent
if component.hasPrefix("/") { return component }
return "/v1/\(component)"
}
var relativeIAMPath: String {
let component = self.iamPathComponent
if component.hasPrefix("/") { return component }
return "/v1/\(component)"
}
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 .postExternalPurchaseToken:
return "external_purchase_tokens"
2025-07-07 12:11:27 -05:00
case let .getVirtualCurrencies(appUserID):
return "subscribers/\(Self.escape(appUserID))/virtual_currencies"
2026-08-27 16:14:19 -06:00
case .spendVirtualCurrencies:
assertionFailure("The .spendVirtualCurrencies endpoint is only allowed when IAM is enabled")
Logger.error("The .spendVirtualCurrencies endpoint is only allowed when IAM is enabled")
return "customer/virtual_currencies/spend"
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 let .remoteConfig(domain):
return "config/\(Self.escape(domain))"
2026-08-21 14:04:22 -06:00
case .tokenLogin:
return "/auth/login"
case .tokenRefresh:
return "/auth/token"
case .tokenLogOut:
return "/auth/revoke"
}
}
var iamPathComponent: String {
// NOTE: cases below that "return self.pathComponent" are indicating
// that the corresponding server paths have not yet been migrated to
// support access token authorization
switch self {
case .getCustomerInfo:
return "customer"
case .getOfferings:
return "customer/offerings"
case .getIntroEligibility:
return "customer/intro_eligibility"
case .logIn:
assertionFailure("The .login endpoint is not allowed when IAM is enabled")
Logger.error("The .login endpoint is not allowed when IAM is enabled")
return self.pathComponent
case .postAttributionData:
return "customer/attribution"
case .postOfferForSigning:
return "offers"
case .postReceiptData:
return "receipts"
case .postSubscriberAttributes:
return "customer/attributes"
case .postAdServicesToken:
return "customer/adservices_attribution"
case .health:
return self.pathComponent
case .appHealthReport:
return "customer/health_report"
case .appHealthReportAvailability:
return self.pathComponent
case .getProductEntitlementMapping:
return "product_entitlement_mapping"
case .getCustomerCenterConfig:
return "customer/customercenter"
case .getVirtualCurrencies:
return "customer/virtual_currencies"
2026-08-27 16:14:19 -06:00
case .spendVirtualCurrencies:
return "customer/virtual_currencies/spend"
case .postRedeemWebPurchase:
return self.pathComponent
case .postExternalPurchaseToken:
return self.pathComponent
case .postCreateTicket:
return self.pathComponent
case .isPurchaseAllowedByRestoreBehavior:
return "customer/restore/eligibility"
case .rewardVerificationStatus:
return self.pathComponent
case .remoteConfig:
return self.pathComponent
2026-08-21 14:04:22 -06:00
case .tokenLogin:
return "/auth/login"
case .tokenRefresh:
return "/auth/token"
case .tokenLogOut:
return "/auth/revoke"
}
}
var name: String {
switch self {
case .getCustomerInfo:
return "get_customer"
case .getOfferings:
return "get_offerings"
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 .postExternalPurchaseToken:
return "post_external_purchase_token"
case .appHealthReport:
return "get_app_health_report"
2025-07-07 12:11:27 -05:00
case .getVirtualCurrencies:
return "get_virtual_currencies"
2026-08-27 16:14:19 -06:00
case .spendVirtualCurrencies:
return "spend_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 .remoteConfig:
return "remote_config"
2026-08-21 14:04:22 -06:00
case .tokenLogin:
return "token_login"
case .tokenRefresh:
return "token_refresh"
case .tokenLogOut:
return "token_logout"
}
}
var additionalHeaders: HTTPRequest.Headers {
switch self {
case .remoteConfig:
return [
HTTPClient.RequestHeader.accept.rawValue: HTTPClient.rcContainerFormatAcceptHeaderValue,
HTTPClient.RequestHeader.acceptRCElementEncoding.rawValue:
HTTPClient.rcContainerFormatElementEncodingHeaderValue
]
default:
return [:]
}
}
2026-08-21 14:04:22 -06:00
var isIAMPath: Bool {
switch self {
case .tokenLogin, .tokenRefresh, .tokenLogOut:
return true
default: return false
}
}
private static func escape(_ appUserID: String) -> String {
return appUserID.trimmedAndEscaped
}
}
extension HTTPRequest.FallbackPath: HTTPRequestPath {
// swiftlint:disable:next force_unwrapping
static let serverHostURL = URL(string: "https://api-production.8-lives-cat.io")!
var isFallbackHostPath: Bool {
switch self {
case .remoteConfig:
return true
}
}
var authenticated: Bool {
switch self {
case .remoteConfig:
return true
}
}
var shouldSendEtag: Bool {
switch self {
case .remoteConfig:
return true
}
}
var supportsSignatureVerification: Bool {
switch self {
case .remoteConfig:
return true
}
}
var needsNonceForSigning: Bool {
switch self {
case .remoteConfig:
return false
}
}
var responseSignatureContextProvider: ResponseSignatureContextProvider {
switch self {
case .remoteConfig:
return FallbackSignatureContextProvider()
}
}
var relativePath: String {
switch self {
case let .remoteConfig(domain):
return "/v1/config/\(domain.trimmedAndEscaped)"
}
}
var name: String {
switch self {
case .remoteConfig:
return "remote_config_fallback"
}
}
var additionalHeaders: HTTPRequest.Headers {
switch self {
case .remoteConfig:
return [:]
}
}
}