Files
purchases-ios/Sources/Networking/HTTPClient/HTTPRequestPath.swift
T
c6ccc5e8d8 Add the networking layer for registering external purchase tokens (#7596)
* Add the networking layer for registering external purchase tokens

Registers a StoreKit external purchase token with the backend so the
resulting identifier can be handed to the checkout page. Nothing calls
this yet.

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

* Cover the created status of a first token registration

The backend answers 201 the first time a token is stored and 200 when
the same token is re-submitted.

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

* Don't send an ETag when registering an external purchase token

ETag responses are cached by URL alone, and every registration posts a
different body to the same URL, so a cached response could be replayed for
a token it doesn't belong to.

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

* Read only the identifier from the external purchase token response

The checkout page only needs the registration identifier, so the remaining
response fields have no consumer in the SDK.

Also drop the hand-written request body encoding, which the compiler
synthesizes identically, and the signature content, which this endpoint
doesn't sign.

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

* Generating new test snapshots (#7606)

* Generating new test snapshots (#7607)

* Generating new test snapshots (#7608)

* Generating new test snapshots (#7611)

* Generating new test snapshots (#7610)

* Generating new test snapshots (#7609)

* Generating new test snapshots (#7612)

* Generating new test snapshots (#7614)

* Generating new test snapshots (#7615)

* Document what PostExternalPurchaseTokenOperation is for

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

* Drop the comment on ExternalPurchaseTokenResponse

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 13:26:27 +02:00

737 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>)
}
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 [:]
}
}
}