Files

527 lines
25 KiB
Swift
Raw Permalink 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
/// 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 HTTPRequest {
2026-10-23 11:11:52 -06:00
struct Path: Hashable {
2026-10-23 11:11:52 -06:00
// swiftlint:disable:next nesting
enum ETagBehavior {
case send
case ignore
}
2026-10-23 11:11:52 -06:00
private static let fallbackServerHostURLs = [
URL(string: "https://api-production.8-lives-cat.io")
]
2026-10-23 11:11:52 -06:00
static func == (lhs: Self, rhs: Self) -> Bool {
return lhs.name == rhs.name &&
lhs.serverHostURL == rhs.serverHostURL &&
lhs.relativePath == rhs.relativePath
}
2026-10-23 11:11:52 -06:00
static var serverHostURL: URL { SystemInfo.apiBaseURL }
2026-10-23 11:11:52 -06:00
/// The base URL for requests to this path.
var serverHostURL: URL { self.customServerHostURL ?? Self.serverHostURL }
2026-10-23 11:11:52 -06:00
private var customServerHostURL: URL?
2026-10-23 11:11:52 -06:00
/// The fallback URLs to use when the main server is down.
///
/// Not all endpoints have a fallback URL, but some do.
var fallbackUrls: [URL]
2026-10-23 11:11:52 -06:00
var supportsFallbackURLs: Bool { !fallbackUrls.isEmpty }
2026-10-23 11:11:52 -06:00
/// Whether requests to this path are authenticated.
var authenticated: Bool
2024-04-10 13:00:48 +02:00
2026-10-23 11:11:52 -06:00
/// Whether requests to this path can be cached using `ETagManager`.
var etagBehavior: ETagBehavior
2024-04-10 13:00:48 +02:00
2026-10-23 11:11:52 -06:00
var shouldSendEtag: Bool { etagBehavior == .send }
2024-04-10 13:00:48 +02:00
2026-10-23 11:11:52 -06:00
/// Whether the endpoint will perform signature verification.
var supportsSignatureVerification: Bool
2026-10-23 11:11:52 -06:00
/// Whether endpoint requires a nonce for signature verification.
var needsNonceForSigning: Bool
2026-10-23 11:11:52 -06:00
/// The name of the endpoint.
var name: String
2026-10-23 11:11:52 -06:00
var pathComponent: String
2026-10-23 11:11:52 -06:00
/// The full relative path for this endpoint.
var relativePath: String {
pathComponent.hasPrefix("/") ? pathComponent : "/v1/\(pathComponent)"
}
var iamPathComponent: String
/// The full relative path for this endpoint when using IAM tokens.
var relativeIAMPath: String {
iamPathComponent.hasPrefix("/") ? iamPathComponent : "/v1/\(iamPathComponent)"
}
/// The fallback relative path for this endpoint, if any.
var fallbackRelativePath: String?
/// 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
/// 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
/// Additional headers specific to this endpoint.
var additionalHeaders: HTTPRequest.Headers
/// Provides endpoint-specific inputs for response signature verification.
var responseSignatureContextProvider: ResponseSignatureContextProvider
/// Whether this path corresponds to an IAM request
var isIAMPath: Bool
init(name: String,
serverHostURL: URL? = nil,
authenticated: Bool = true,
etagBehavior: ETagBehavior,
supportsSignatureVerification: Bool,
needsNonceForSigning: Bool,
pathComponent: String,
iamPathComponent: String? = nil,
fallbackRelativePath: String? = nil,
fallbackURLs: [URL]? = nil,
usesAPISources: Bool = true,
isFallbackHostPath: Bool = false,
additionalHeaders: HTTPRequest.Headers = [:],
responseSignatureContextProvider: ResponseSignatureContextProvider? = nil,
isIAMPath: Bool = false) {
self.customServerHostURL = serverHostURL
self.authenticated = authenticated
self.etagBehavior = etagBehavior
self.supportsSignatureVerification = supportsSignatureVerification
self.needsNonceForSigning = needsNonceForSigning
self.name = name
self.pathComponent = pathComponent
self.iamPathComponent = (iamPathComponent ?? pathComponent)
self.fallbackRelativePath = fallbackRelativePath
self.usesAPISources = usesAPISources
self.isFallbackHostPath = isFallbackHostPath
self.additionalHeaders = additionalHeaders
let signatureContextProvider = responseSignatureContextProvider ?? DefaultResponseSignatureContextProvider()
self.responseSignatureContextProvider = signatureContextProvider
self.isIAMPath = isIAMPath
if let fallbackURLs {
self.fallbackUrls = fallbackURLs
} else if let fallbackRelativePath {
self.fallbackUrls = 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: \(name)"
assertionFailure(errorMessage)
Logger.error(errorMessage)
return nil
}
return fallbackUrl
}
} else {
self.fallbackUrls = []
}
return
}
func hash(into hasher: inout Hasher) {
hasher.combine(name)
hasher.combine(serverHostURL)
hasher.combine(relativePath)
}
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 {
// 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
}
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)
}
}
}
2026-10-23 11:11:52 -06:00
extension HTTPRequest.Path {
// swiftlint:disable:next force_unwrapping
2026-10-23 11:11:52 -06:00
private static let remoteConfigFallbackHost = URL(string: "https://api-production.8-lives-cat.io")!
2026-10-23 11:11:52 -06:00
// swiftlint:disable:next force_unwrapping
private static let apiDiagnosticsHost = URL(string: "https://api-diagnostics.revenuecat.com")!
// swiftlint:disable:next force_unwrapping
private static let postFeatureEventsHost = URL(string: "https://api-paywalls.revenuecat.com")!
// swiftlint:disable:next force_unwrapping
private static let postAdEventsHost = URL(string: "https://a.revenue.cat")!
static let logIn = HTTPRequest.Path(name: "log_in",
etagBehavior: .send,
supportsSignatureVerification: true,
needsNonceForSigning: true,
pathComponent: "subscribers/identify")
static let health = HTTPRequest.Path(name: "post_health",
authenticated: false,
etagBehavior: .ignore,
supportsSignatureVerification: true,
needsNonceForSigning: true,
pathComponent: "health")
static func getCustomerInfo(appUserID: String) -> Self {
HTTPRequest.Path(name: "get_customer",
etagBehavior: .send,
supportsSignatureVerification: true,
needsNonceForSigning: true,
pathComponent: "subscribers/\(appUserID.trimmedAndEscaped)",
iamPathComponent: "customer")
}
2026-10-23 11:11:52 -06:00
static func getOfferings(appUserID: String) -> Self {
HTTPRequest.Path(name: "get_offerings",
etagBehavior: .send,
supportsSignatureVerification: true,
needsNonceForSigning: false,
pathComponent: "subscribers/\(appUserID.trimmedAndEscaped)/offerings",
iamPathComponent: "customer/offerings",
fallbackRelativePath: "/v1/offerings")
}
2026-10-23 11:11:52 -06:00
static func getIntroEligibility(appUserID: String) -> Self {
HTTPRequest.Path(name: "get_intro_eligibility",
etagBehavior: .send,
supportsSignatureVerification: false,
needsNonceForSigning: false,
pathComponent: "subscribers/\(appUserID.trimmedAndEscaped)/intro_eligibility",
iamPathComponent: "customer/intro_eligibility")
}
2026-10-23 11:11:52 -06:00
static func remoteConfigFallback(domain: String) -> Self {
HTTPRequest.Path(name: "remote_config_fallback",
serverHostURL: remoteConfigFallbackHost,
etagBehavior: .send,
supportsSignatureVerification: true,
needsNonceForSigning: false,
pathComponent: "config/\(domain.trimmedAndEscaped)",
fallbackRelativePath: nil,
usesAPISources: false,
isFallbackHostPath: true,
responseSignatureContextProvider: FallbackSignatureContextProvider())
}
2026-10-23 11:11:52 -06:00
static let postDiagnostics = HTTPRequest.Path(name: "post_diagnostics",
serverHostURL: apiDiagnosticsHost,
etagBehavior: .ignore,
supportsSignatureVerification: false,
needsNonceForSigning: false,
pathComponent: "diagnostics",
usesAPISources: false)
static let postFeatureEvents = HTTPRequest.Path(name: "post_paywall_events",
serverHostURL: postFeatureEventsHost,
etagBehavior: .ignore,
supportsSignatureVerification: false,
needsNonceForSigning: false,
pathComponent: "events",
usesAPISources: false)
static let postAdEvents = HTTPRequest.Path(name: "post_ad_events",
serverHostURL: postAdEventsHost,
etagBehavior: .ignore,
supportsSignatureVerification: false,
needsNonceForSigning: false,
pathComponent: "events",
usesAPISources: false)
static func postAttributionData(appUserID: String) -> Self {
HTTPRequest.Path(name: "post_attribution",
etagBehavior: .send,
supportsSignatureVerification: false,
needsNonceForSigning: false,
pathComponent: "subscribers/\(appUserID.trimmedAndEscaped)/attribution",
iamPathComponent: "customer/attribution")
}
2026-10-23 11:11:52 -06:00
static let postOfferForSigning = HTTPRequest.Path(name: "post_offer_for_signing",
etagBehavior: .send,
supportsSignatureVerification: false,
needsNonceForSigning: false,
pathComponent: "offers")
static let postReceiptData = HTTPRequest.Path(name: "post_receipt",
etagBehavior: .send,
supportsSignatureVerification: true,
needsNonceForSigning: true,
pathComponent: "receipts")
static func postSubscriberAttributes(appUserID: String) -> Self {
HTTPRequest.Path(name: "post_attributes",
etagBehavior: .send,
supportsSignatureVerification: false,
needsNonceForSigning: false,
pathComponent: "subscribers/\(appUserID.trimmedAndEscaped)/attributes",
iamPathComponent: "customer/attributes")
}
2026-10-23 11:11:52 -06:00
static func postAdServicesToken(appUserID: String) -> Self {
HTTPRequest.Path(name: "post_adservices_token",
etagBehavior: .send,
supportsSignatureVerification: false,
needsNonceForSigning: false,
pathComponent: "subscribers/\(appUserID.trimmedAndEscaped)/adservices_attribution",
iamPathComponent: "customer/adservices_attribution")
}
2026-10-23 11:11:52 -06:00
static func appHealthReport(appUserID: String) -> Self {
HTTPRequest.Path(name: "get_app_health_report",
etagBehavior: .send,
supportsSignatureVerification: true,
needsNonceForSigning: false,
pathComponent: "subscribers/\(appUserID.trimmedAndEscaped)/health_report",
iamPathComponent: "customer/health_report")
}
2026-10-23 11:11:52 -06:00
static func appHealthReportAvailability(appUserID: String) -> Self {
HTTPRequest.Path(name: "get_app_health_report_availability",
authenticated: false,
etagBehavior: .ignore,
supportsSignatureVerification: true,
needsNonceForSigning: true,
pathComponent: "subscribers/\(appUserID.trimmedAndEscaped)/health_report_availability")
}
2026-10-23 11:11:52 -06:00
static let getProductEntitlementMapping = HTTPRequest.Path(name: "get_product_entitlement_mapping",
etagBehavior: .send,
supportsSignatureVerification: true,
needsNonceForSigning: false,
pathComponent: "product_entitlement_mapping",
fallbackRelativePath: "/v1/product_entitlement_mapping")
static func getCustomerCenterConfig(appUserID: String) -> Self {
HTTPRequest.Path(name: "customer_center",
etagBehavior: .send,
supportsSignatureVerification: false,
needsNonceForSigning: false,
pathComponent: "customercenter/\(appUserID.trimmedAndEscaped)",
iamPathComponent: "customer/customercenter")
}
static func getVirtualCurrencies(appUserID: String) -> Self {
HTTPRequest.Path(name: "get_virtual_currencies",
etagBehavior: .send,
supportsSignatureVerification: true,
needsNonceForSigning: true,
pathComponent: "subscribers/\(appUserID.trimmedAndEscaped)/virtual_currencies",
iamPathComponent: "customer/virtual_currencies")
}
static let spendVirtualCurrencies = HTTPRequest.Path(name: "spend_virtual_currencies",
etagBehavior: .send,
supportsSignatureVerification: true,
needsNonceForSigning: true,
pathComponent: "customer/virtual_currencies/spend")
static let postRedeemWebPurchase = HTTPRequest.Path(name: "post_redeem_web_purchase",
etagBehavior: .send,
supportsSignatureVerification: true,
needsNonceForSigning: true,
pathComponent: "subscribers/redeem_purchase")
static let postExternalPurchaseToken = HTTPRequest.Path(name: "post_external_purchase_token",
authenticated: true,
etagBehavior: .ignore,
supportsSignatureVerification: false,
needsNonceForSigning: false,
pathComponent: "external_purchase_tokens")
static let postCreateTicket = HTTPRequest.Path(name: "post_create_ticket",
etagBehavior: .send,
supportsSignatureVerification: false,
needsNonceForSigning: false,
pathComponent: "customercenter/support/create-ticket")
static func isPurchaseAllowedByRestoreBehavior(appUserID: String) -> Self {
HTTPRequest.Path(name: "post_restore_eligibility",
etagBehavior: .send,
supportsSignatureVerification: true,
needsNonceForSigning: true,
pathComponent: "subscribers/\(appUserID.trimmedAndEscaped)/restore/eligibility",
iamPathComponent: "customer/restore/eligibility")
}
static func rewardVerificationStatus(appUserID: String, clientTransactionID: String, adUnitID: String?) -> Self {
let txn = clientTransactionID.trimmedAndEscaped
var path = "subscribers/\(appUserID.trimmedAndEscaped)/ads/reward_verifications/\(txn)"
if let adUnitID = adUnitID?.notEmptyOrWhitespaces,
let encodedAdUnitID = adUnitID.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) {
path += "?ad_unit_id=\(encodedAdUnitID)"
}
return HTTPRequest.Path(name: "get_reward_verification_status",
etagBehavior: .send,
supportsSignatureVerification: true,
needsNonceForSigning: true,
pathComponent: path)
}
static func remoteConfig(domain: String) -> Self {
HTTPRequest.Path(name: "remote_config",
etagBehavior: .ignore,
supportsSignatureVerification: true,
needsNonceForSigning: true,
pathComponent: "config/\(domain.trimmedAndEscaped)",
additionalHeaders: [
HTTPClient.RequestHeader.accept.rawValue: HTTPClient.rcContainerFormatAcceptHeaderValue,
HTTPClient.RequestHeader.acceptRCElementEncoding.rawValue:
HTTPClient.rcContainerFormatElementEncodingHeaderValue
],
responseSignatureContextProvider: RemoteConfigSignatureContextProvider())
}
// MARK: - Web Billing Paths
static func getWebOfferingProducts(appUserID: String) -> Self {
HTTPRequest.Path(name: "get_web_offering_products",
etagBehavior: .send,
supportsSignatureVerification: false,
needsNonceForSigning: false,
pathComponent: "/rcbilling/v1/subscribers/\(appUserID.trimmedAndEscaped)/offering_products",
iamPathComponent: "/rcbilling/v1/customer/offering_products")
}
static func getWebBillingProducts(userId: String, productIds: Set<String>) -> Self {
let encodedUserId = userId.trimmedAndEscaped
let encodedProductIds = productIds.map { productId in
"id=\(productId.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? productId)"
}.joined(separator: "&")
let path = "/rcbilling/v1/subscribers/\(encodedUserId)/products?\(encodedProductIds)"
let iamPath = "/rcbilling/v1/customer/products?\(encodedProductIds)"
return HTTPRequest.Path(name: "get_web_products",
etagBehavior: .send,
supportsSignatureVerification: false,
needsNonceForSigning: false,
pathComponent: path,
iamPathComponent: iamPath)
}
static let postHostedCheckout = HTTPRequest.Path(name: "post_hosted_checkout",
etagBehavior: .ignore,
supportsSignatureVerification: false,
needsNonceForSigning: false,
pathComponent: "/rcbilling/v1/hosted-checkout")
static func getHostedCheckoutStatus(operationSessionID: String, appUserID: String) -> Self {
let path = "/rcbilling/v1/hosted-checkout/\(operationSessionID.trimmedAndEscaped)" +
"?app_user_id=\(appUserID.trimmedAndEscapedForQuery)"
return HTTPRequest.Path(name: "get_hosted_checkout_status",
etagBehavior: .ignore,
supportsSignatureVerification: false,
needsNonceForSigning: false,
pathComponent: path)
}
// MARK: - IAM Token Paths
static let tokenLogin = HTTPRequest.Path(name: "token_login",
etagBehavior: .send,
supportsSignatureVerification: false,
needsNonceForSigning: true,
pathComponent: "/auth/login",
isIAMPath: true)
static let tokenRefresh = HTTPRequest.Path(name: "token_refresh",
etagBehavior: .send,
supportsSignatureVerification: false,
needsNonceForSigning: true,
pathComponent: "/auth/token",
isIAMPath: true)
static let tokenLogOut = HTTPRequest.Path(name: "token_logout",
etagBehavior: .send,
supportsSignatureVerification: false,
needsNonceForSigning: true,
pathComponent: "/auth/revoke",
isIAMPath: true)
}