mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-09 22:35:56 +05:45
* Add workflows network layer for multipage paywalls - Add `getWorkflows` and `getWorkflow` endpoint paths - Add `WorkflowsListResponse` and `PublishedWorkflow` response models - Add `WorkflowDetailProcessor` to handle `inline`/`use_cdn` response actions - Add `WorkflowCdnFetcher` protocol and `DirectWorkflowCdnFetcher` implementation - Add `GetWorkflowsOperation` and `GetWorkflowOperation` cacheable network operations - Add `WorkflowsAPI` facade and wire into `Backend` - Add unit tests for all new components iOS equivalent of https://github.com/RevenueCat/purchases-android/pull/3300 Made-with: Cursor * fix xcodeproj * reset Package.resolved * Update WorkflowStep models to match actual backend response - Add WorkflowTrigger struct (name, type, action_id, component_id) - Add triggers, outputs, and metadata fields to WorkflowStep - Add metadata field to PublishedWorkflow - Remove value field from WorkflowTriggerAction (backend uses step_id only) Made-with: Cursor * Update WorkflowResponseTests to match updated models - Replace value/resolvedTargetStepId assertions with stepId - Remove testDecodeWorkflowTriggerActionValueTakesPrecedence (value field removed) - Add testDecodeWorkflowTrigger for new WorkflowTrigger struct - Add testDecodePublishedWorkflowWithMetadata - Update testDecodeWorkflowStepDefaults to cover triggers, outputs, metadata - Add testDecodeWorkflowStepMatchingActualBackendResponse with real backend payload Made-with: Cursor * Disable file_length lint rule in HTTPRequestPath.swift Made-with: Cursor * Fix GetWorkflowOperation to compute result once before distributing to callbacks CDN fetch and JSON decoding were running once per deduplicated callback. Compute the Result<WorkflowFetchResult, BackendError> once outside the performOnAllItemsAndRemoveFromCache loop, matching the pattern used by GetOfferingsOperation and other operations in the codebase. Made-with: Cursor * Fix CDN fetcher: use URLSession instead of Data(contentsOf:), classify errors correctly - Replace Data(contentsOf:) with URLSession.shared.dataTask + DispatchSemaphore in DirectWorkflowCdnFetcher; gets URLSession timeout, HTTP status validation, and proper network stack semantics - Add WorkflowDetailProcessingError.cdnFetchFailed typed error so CDN I/O failures are distinguishable from envelope parsing failures - Catch cdnFetchFailed in GetWorkflowOperation and map to NetworkError.networkError instead of NetworkError.decoding, fixing misleading error classification - Update WorkflowDetailProcessorTests to assert the typed error is thrown Made-with: Cursor * Replace semaphore CDN fetch with async/await using withCheckedThrowingContinuation - Make WorkflowCdnFetcher.fetchCompiledWorkflowData async throws; use withCheckedThrowingContinuation to bridge URLSession.dataTask into async, avoiding any thread-blocking - Make WorkflowDetailProcessor.process async throws to propagate async - Bridge into async in GetWorkflowOperation via Task {}; completion() is called inside the Task after CDN fetch and decoding complete - Update WorkflowDetailProcessorTests to async throws with await Made-with: Cursor * Make CDN fetcher and processor completion-handler based for consistency Avoids Task{} in GetWorkflowOperation and keeps all operations calling completion() synchronously from within the HTTP callback, matching every other operation in the codebase. - WorkflowCdnFetcher.fetchCompiledWorkflowData now takes a completion handler; DirectWorkflowCdnFetcher uses URLSession.dataTask (non-blocking, no semaphore) - WorkflowDetailProcessor.process now takes a completion handler; inline action completes synchronously, use_cdn fans out to the fetcher callback - GetWorkflowOperation splits into getWorkflow/handleResponse/backendResult/ distribute helpers to stay within line-length limits - WorkflowDetailProcessorTests updated to use waitUntilValue pattern Made-with: Cursor * Fix ambiguous cache key delimiter in GetWorkflowOperation Space-separated appUserID+workflowId could collide (e.g. user 'a b' + workflow 'c' == user 'a' + workflow 'b c'). Use newline as delimiter, matching the precedent set by GetWebBillingProductsOperation. Made-with: Cursor * PR comments * remove cdn fetcher * fix response in BackendGetWorkflowsTests.swift * fix WorkflowResponseTests * fix error * [skip ci] Generating new test snapshots (#6584) * [skip ci] Generating new test snapshots (#6585) * [skip ci] Generating new test snapshots (#6586) * [skip ci] Generating new test snapshots (#6587) * [skip ci] Generating new test snapshots (#6588) * [skip ci] Generating new test snapshots (#6589) * [skip ci] Generating new test snapshots (#6590) * Test CDN mock is not re-assignable per test * [skip ci] Generating new test snapshots (#6597) * [skip ci] Generating new test snapshots (#6598) * [skip ci] Generating new test snapshots (#6599) * [skip ci] Generating new test snapshots (#6600) * [skip ci] Generating new test snapshots (#6601) * [skip ci] Generating new test snapshots (#6602) * [skip ci] Generating new test snapshots (#6603) * getWorkflow is signed * add type parameter * add response verification for CDN response * remove workflows list * add value to WorkflowTriggerAction * step id * hash and filerepo * missingCdnHash * linter and project * fix project * revert Package.resolved * @unchecked Sendable * use hash in generateOrGetCachedFileURL * fix compilation * skip responseVerificationMode in cdn * change basePath of caches * remove reserialization * Use explicit type in GetWorkflowOperation.createFactory for greppability Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix swiftlint identifier_name violation in WorkflowDetailProcessorTests Rename short variable `d` to `doubleValue` in two `if case .double(let d)` patterns to satisfy the identifier_name rule requiring names >= 3 characters. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * remove default FileRepositoryType * fix project * fix test * test preserves camel case * Generating new test snapshots for `feat/workflows-network-layer` - ios-15 (#6654) * Generating new test snapshots for `feat/workflows-network-layer` - ios-14 (#6656) --------- Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com> Co-authored-by: Facundo Menzella <facumenzella@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Facundo Menzella <facumenzella@users.noreply.github.com>
411 lines
12 KiB
Swift
411 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)
|
|
|
|
}
|
|
|
|
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:
|
|
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:
|
|
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
|
|
}
|
|
}
|
|
|
|
var needsNonceForSigning: Bool {
|
|
switch self {
|
|
case .getCustomerInfo,
|
|
.logIn,
|
|
.postReceiptData,
|
|
.getVirtualCurrencies,
|
|
.health,
|
|
.appHealthReportAvailability,
|
|
.isPurchaseAllowedByRestoreBehavior:
|
|
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"
|
|
}
|
|
}
|
|
|
|
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"
|
|
}
|
|
}
|
|
|
|
private static func escape(_ appUserID: String) -> String {
|
|
return appUserID.trimmedAndEscaped
|
|
}
|
|
}
|