Files
purchases-ios/Sources/Networking/HTTPClient/HTTPRequestPath.swift
T
f14a56dbdf Add the networking layer for starting a hosted checkout (#7627)
* Add the networking layer for starting a hosted checkout

POST /rcbilling/v1/hosted-checkout creates the checkout session with the
payment provider and returns the page to present for it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Send the Apple external purchase token id when starting a checkout

The endpoint ties the checkout session to the registered token, so the
token id is also part of the request's dedup key: two taps carrying
different tokens must never share one session.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Document what PostHostedCheckoutOperation is for

Co-authored-by: Cursor <cursoragent@cursor.com>

* Retry the hosted checkout request

Retries only ever fire on a 429, which the server rejects before creating
a session, so there is nothing to strand. Giving up instead costs the
customer the tap they are waiting on.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Name a case where no external purchase token applies

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop the comments on the hosted checkout path cases

Co-authored-by: Cursor <cursoragent@cursor.com>

* Generating new test snapshots (#7633)

* Generating new test snapshots (#7634)

* Generating new test snapshots (#7635)

* Generating new test snapshots (#7636)

* Generating new test snapshots (#7637)

* Generating new test snapshots (#7638)

* Generating new test snapshots (#7639)

* Generating new test snapshots (#7640)

* Generating new test snapshots (#7641)

* Cover the created status of a new checkout session

The endpoint answers 201, which is the status the flow actually reads.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Spell ID and URL the way the rest of the SDK does

The decoder converts from snake case, which yields Id and Url, so the
names have to be mapped rather than inferred.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Send the presented offering context when starting a checkout

A purchase made through the in-app sheet has to carry the same
attribution a StoreKit purchase from that paywall would, so placement,
targeting rule, workflow, step and paywall travel with it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add snapshots for the presented offering context request

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com>
2026-09-07 16:42:50 +02:00

738 lines
22 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 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 {
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 {
// 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)
}
}
// 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 spendVirtualCurrencies
case postRedeemWebPurchase
case postExternalPurchaseToken
case postCreateTicket
case isPurchaseAllowedByRestoreBehavior(appUserID: String)
case rewardVerificationStatus(appUserID: String, clientTransactionID: String)
case remoteConfig(domain: String)
case tokenLogin
case tokenRefresh
case tokenLogOut
}
enum FallbackPath: Hashable {
case remoteConfig(domain: String)
}
enum FeatureEventsPath: Hashable {
case postEvents
}
enum DiagnosticsPath: Hashable {
case postDiagnostics
}
enum WebBillingPath: Hashable {
case getWebOfferingProducts(appUserID: String)
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")
]
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,
.getIntroEligibility,
.logIn,
.postAttributionData,
.postOfferForSigning,
.postReceiptData,
.postSubscriberAttributes,
.postAdServicesToken,
.postRedeemWebPurchase,
.postExternalPurchaseToken,
.getProductEntitlementMapping,
.getCustomerCenterConfig,
.getVirtualCurrencies,
.spendVirtualCurrencies,
.appHealthReport,
.postCreateTicket,
.isPurchaseAllowedByRestoreBehavior,
.rewardVerificationStatus,
.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,
.getVirtualCurrencies,
.spendVirtualCurrencies,
.appHealthReport,
.postCreateTicket,
.isPurchaseAllowedByRestoreBehavior,
.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,
.getVirtualCurrencies,
.spendVirtualCurrencies,
.appHealthReport,
.appHealthReportAvailability,
.isPurchaseAllowedByRestoreBehavior,
.remoteConfig,
.rewardVerificationStatus:
return true
case .getIntroEligibility,
.postSubscriberAttributes,
.postAttributionData,
.postAdServicesToken,
.postOfferForSigning,
.postRedeemWebPurchase,
.postExternalPurchaseToken,
.getCustomerCenterConfig,
.postCreateTicket,
.tokenLogin,
.tokenRefresh,
.tokenLogOut:
return false
}
}
var needsNonceForSigning: Bool {
switch self {
case .getCustomerInfo,
.logIn,
.postReceiptData,
.getVirtualCurrencies,
.spendVirtualCurrencies,
.health,
.appHealthReportAvailability,
.isPurchaseAllowedByRestoreBehavior,
.remoteConfig,
.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"
case let .getVirtualCurrencies(appUserID):
return "subscribers/\(Self.escape(appUserID))/virtual_currencies"
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))"
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"
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
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"
case .getVirtualCurrencies:
return "get_virtual_currencies"
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"
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 [:]
}
}
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 [:]
}
}
}