// // 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, adUnitID: 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) 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, .postRedeemWebPurchase, .health, .getOfferings, .getProductEntitlementMapping, .getVirtualCurrencies, .spendVirtualCurrencies, .appHealthReport, .appHealthReportAvailability, .isPurchaseAllowedByRestoreBehavior, .remoteConfig, .rewardVerificationStatus: return true case .getIntroEligibility, .postSubscriberAttributes, .postAttributionData, .postAdServicesToken, .postOfferForSigning, .postExternalPurchaseToken, .getCustomerCenterConfig, .postCreateTicket, .tokenLogin, .tokenRefresh, .tokenLogOut: return false } } var needsNonceForSigning: Bool { switch self { case .getCustomerInfo, .logIn, .postReceiptData, .postRedeemWebPurchase, .getVirtualCurrencies, .spendVirtualCurrencies, .health, .appHealthReportAvailability, .isPurchaseAllowedByRestoreBehavior, .remoteConfig, .rewardVerificationStatus, .tokenLogin, .tokenRefresh, .tokenLogOut: return true case .getOfferings, .getIntroEligibility, .postSubscriberAttributes, .postAttributionData, .postAdServicesToken, .postOfferForSigning, .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: 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, adUnitID): let path = "subscribers/\(Self.escape(appUserID))/ads/reward_verifications/" + Self.escape(clientTransactionID) guard let adUnitID = adUnitID?.notEmptyOrWhitespaces, let encodedAdUnitID = adUnitID.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return path } return "\(path)?ad_unit_id=\(encodedAdUnitID)" 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 [:] } } }