mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-09 22:35:56 +05:45
528 lines
25 KiB
Swift
528 lines
25 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
|
|
|
|
/// 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 {
|
|
|
|
struct Path: Hashable {
|
|
|
|
// swiftlint:disable:next nesting
|
|
enum ETagBehavior {
|
|
case send
|
|
case ignore
|
|
}
|
|
|
|
private static let fallbackServerHostURLs = [
|
|
URL(string: "https://api-production.8-lives-cat.io")
|
|
]
|
|
|
|
static func == (lhs: Self, rhs: Self) -> Bool {
|
|
return lhs.name == rhs.name &&
|
|
lhs.serverHostURL == rhs.serverHostURL &&
|
|
lhs.relativePath == rhs.relativePath
|
|
}
|
|
|
|
static var serverHostURL: URL { SystemInfo.apiBaseURL }
|
|
|
|
/// The base URL for requests to this path.
|
|
var serverHostURL: URL { self.customServerHostURL ?? Self.serverHostURL }
|
|
|
|
private var customServerHostURL: URL?
|
|
|
|
/// The fallback URLs to use when the main server is down.
|
|
///
|
|
/// Not all endpoints have a fallback URL, but some do.
|
|
var fallbackUrls: [URL]
|
|
|
|
var supportsFallbackURLs: Bool { !fallbackUrls.isEmpty }
|
|
|
|
/// Whether requests to this path are authenticated.
|
|
var authenticated: Bool
|
|
|
|
/// Whether requests to this path can be cached using `ETagManager`.
|
|
var etagBehavior: ETagBehavior
|
|
|
|
var shouldSendEtag: Bool { etagBehavior == .send }
|
|
|
|
/// Whether the endpoint will perform signature verification.
|
|
var supportsSignatureVerification: Bool
|
|
|
|
/// Whether endpoint requires a nonce for signature verification.
|
|
var needsNonceForSigning: Bool
|
|
|
|
/// The name of the endpoint.
|
|
var name: String
|
|
|
|
var pathComponent: String
|
|
|
|
/// 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)
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
extension HTTPRequest.Path {
|
|
|
|
// swiftlint:disable:next force_unwrapping
|
|
private static let remoteConfigFallbackHost = URL(string: "https://api-production.8-lives-cat.io")!
|
|
|
|
// 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")
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
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())
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
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)
|
|
}
|