Files
purchases-ios/Sources/Networking/HTTPClient/HTTPRequestPath.swift
T
Pol MiroandRevenueCat Git Bot abad547968 AdMob SSV: add @_spi(Internal) poll endpoint on Purchases (#6641)
* Add AdMob SSV poll endpoint and `pollAdMobSSVStatus` SPI

* Trim verbose AdMob SSV transport comments.

Align AdsAPI and cache-key inline documentation with existing repository comment density while preserving behavior.

* Trim pollAdMobSSVStatus SPI documentation.

Reduce prescriptive guidance in the internal API comment while keeping the key cancellation behavior note.

* [skip ci] Generating new test snapshots (#6648)

* [skip ci] Generating new test snapshots (#6647)

* [skip ci] Generating new test snapshots (#6646)

* [skip ci] Generating new test snapshots (#6645)

* [skip ci] Generating new test snapshots (#6644)

* [skip ci] Generating new test snapshots (#6642)

* [skip ci] Generating new test snapshots (#6643)

* Trigger CI for PR 6641 checks.

Add a non-skip commit so required CircleCI statuses are reported on the current head.

* Validate non-empty client transaction ID for AdMob SSV polling.

Guard against empty client transaction IDs in the SPI and backend operation and add focused backend tests for the new error path.

* [skip ci] Generating new test snapshots (#6652)

* [skip ci] Generating new test snapshots (#6653)

* Trigger CI for PR 6641 checks.

Add a non-skip commit so required CircleCI statuses are reported on the current head.

* Enable signature verification for AdMob SSV status path.

Update HTTP request path tests to cover AdMob SSV signature and nonce behavior.

* fix: address review — move AdMob SPI extension to dedicated file

* fix: address review — fail fast when AdMob status mock is unstubbed

* fix: address review — dedupe client transaction validation in backend operation

* fix: address review — use enum for AdMob SSV poll status

* fix: address review — allow extracted Purchases extensions to access backend

* fix: address review — log unknown AdMob SSV statuses

* fix: address review — add Purchases AdMob SSV mapping tests

* fix: address review — map missing client transaction ID to configuration error

* fix: address review — keep backend private by co-locating AdMob extension

* fix: address review — remove unused raw-value conformance

* fix: address review — assert warning log for unknown AdMob status

* fix: address review — surface unknown AdMob SSV status to adapters

* fix: address review — enable eTag support for AdMob SSV status path

---------

Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com>
2026-04-22 15:55:13 +02:00

423 lines
12 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 fallback relative path for this endpoint, if any.
var fallbackRelativePath: String? { get }
}
extension HTTPRequestPath {
var fallbackUrls: [URL] {
return []
}
var supportsFallbackURLs: Bool {
!fallbackUrls.isEmpty
}
var fallbackRelativePath: String? {
return nil
}
var url: URL? { return self.url(proxyURL: nil) }
func url(proxyURL: URL? = nil, fallbackUrlIndex: Int? = nil) -> URL? {
let baseURL: URL
if let proxyURL {
// When a Proxy URL is set, we don't support fallback URLs
guard fallbackUrlIndex == nil else {
// This is to safe guard against a potential infinite loop if the caller mistakenly
// passes both a proxyURL and a fallbackUrlIndex.
return nil
}
baseURL = proxyURL
} else if let fallbackUrlIndex {
return self.fallbackUrls[safe: fallbackUrlIndex]
} else {
baseURL = Self.serverHostURL
}
return URL(string: self.relativePath, 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 getWorkflow(appUserID: String, workflowId: String)
case postRedeemWebPurchase
case postCreateTicket
case isPurchaseAllowedByRestoreBehavior(appUserID: String)
case adMobSSVStatus(appUserID: String, clientTransactionID: 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
}
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,
.getProductEntitlementMapping,
.getCustomerCenterConfig,
.getVirtualCurrencies,
.getWorkflow,
.appHealthReport,
.postCreateTicket,
.isPurchaseAllowedByRestoreBehavior,
.adMobSSVStatus:
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,
.getWorkflow,
.appHealthReport,
.postCreateTicket,
.isPurchaseAllowedByRestoreBehavior,
.adMobSSVStatus:
return true
case .health,
.appHealthReportAvailability:
return false
}
}
var supportsSignatureVerification: Bool {
switch self {
case .getCustomerInfo,
.logIn,
.postReceiptData,
.health,
.getOfferings,
.getProductEntitlementMapping,
.getVirtualCurrencies,
.getWorkflow,
.appHealthReport,
.appHealthReportAvailability,
.isPurchaseAllowedByRestoreBehavior:
return true
case .getIntroEligibility,
.postSubscriberAttributes,
.postAttributionData,
.postAdServicesToken,
.postOfferForSigning,
.postRedeemWebPurchase,
.getCustomerCenterConfig,
.postCreateTicket:
return false
case .adMobSSVStatus:
return true
}
}
var needsNonceForSigning: Bool {
switch self {
case .getCustomerInfo,
.logIn,
.postReceiptData,
.getVirtualCurrencies,
.health,
.appHealthReportAvailability,
.isPurchaseAllowedByRestoreBehavior,
.adMobSSVStatus:
return true
case .getOfferings,
.getIntroEligibility,
.postSubscriberAttributes,
.postAttributionData,
.postAdServicesToken,
.postOfferForSigning,
.postRedeemWebPurchase,
.getProductEntitlementMapping,
.getCustomerCenterConfig,
.getWorkflow,
.appHealthReport,
.postCreateTicket:
return false
}
}
var relativePath: String {
return "/v1/\(self.pathComponent)"
}
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 let .getVirtualCurrencies(appUserID):
return "subscribers/\(Self.escape(appUserID))/virtual_currencies"
case let .getWorkflow(appUserID, workflowId):
return "subscribers/\(Self.escape(appUserID))/workflows/\(Self.escape(workflowId))"
case .postCreateTicket:
return "customercenter/support/create-ticket"
case let .isPurchaseAllowedByRestoreBehavior(appUserID):
return "subscribers/\(Self.escape(appUserID))/restore/eligibility"
case let .adMobSSVStatus(appUserID, clientTransactionID):
return "subscribers/\(Self.escape(appUserID))/ads/admob/ssv/\(Self.escape(clientTransactionID))"
}
}
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 .appHealthReport:
return "get_app_health_report"
case .getVirtualCurrencies:
return "get_virtual_currencies"
case .getWorkflow:
return "get_workflow"
case .appHealthReportAvailability:
return "get_app_health_report_availability"
case .postCreateTicket:
return "post_create_ticket"
case .isPurchaseAllowedByRestoreBehavior:
return "post_restore_eligibility"
case .adMobSSVStatus:
return "get_admob_ssv_status"
}
}
private static func escape(_ appUserID: String) -> String {
return appUserID.trimmedAndEscaped
}
}