Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
10a4c26
Add workflows network layer for multipage paywalls
vegaro Apr 6, 2026
27ba13e
fix xcodeproj
vegaro Apr 6, 2026
76e8094
reset Package.resolved
vegaro Apr 6, 2026
83cc392
Update WorkflowStep models to match actual backend response
vegaro Apr 7, 2026
e559dcc
Update WorkflowResponseTests to match updated models
vegaro Apr 7, 2026
7765b6a
Disable file_length lint rule in HTTPRequestPath.swift
vegaro Apr 7, 2026
6ee4a5b
Merge branch 'main' into feat/workflows-network-layer
vegaro Apr 7, 2026
9e3caf8
Fix GetWorkflowOperation to compute result once before distributing t…
vegaro Apr 7, 2026
a0339f0
Merge branch 'feat/workflows-network-layer' of github.com:RevenueCat/…
vegaro Apr 7, 2026
12498e8
Fix CDN fetcher: use URLSession instead of Data(contentsOf:), classif…
vegaro Apr 7, 2026
7c082bd
Replace semaphore CDN fetch with async/await using withCheckedThrowin…
vegaro Apr 7, 2026
95b96c7
Make CDN fetcher and processor completion-handler based for consistency
vegaro Apr 8, 2026
ef79c3d
Fix ambiguous cache key delimiter in GetWorkflowOperation
vegaro Apr 8, 2026
55db06c
PR comments
vegaro Apr 10, 2026
bde2ae6
remove cdn fetcher
vegaro Apr 10, 2026
c167f2d
fix response in BackendGetWorkflowsTests.swift
vegaro Apr 10, 2026
36642b1
fix WorkflowResponseTests
vegaro Apr 10, 2026
613aafa
fix error
vegaro Apr 10, 2026
2409291
[skip ci] Generating new test snapshots (#6584)
RCGitBot Apr 10, 2026
8b02381
[skip ci] Generating new test snapshots (#6585)
RCGitBot Apr 10, 2026
243c89e
[skip ci] Generating new test snapshots (#6586)
RCGitBot Apr 10, 2026
89f7f47
[skip ci] Generating new test snapshots (#6587)
RCGitBot Apr 10, 2026
9276217
[skip ci] Generating new test snapshots (#6588)
RCGitBot Apr 10, 2026
7a40c56
[skip ci] Generating new test snapshots (#6589)
RCGitBot Apr 10, 2026
ea64ecf
[skip ci] Generating new test snapshots (#6590)
RCGitBot Apr 10, 2026
30001f0
Merge branch 'main' into feat/workflows-network-layer
vegaro Apr 10, 2026
e7b8a9f
Test CDN mock is not re-assignable per test
vegaro Apr 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

66 changes: 64 additions & 2 deletions RevenueCat.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions Sources/Networking/Backend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class Backend {
let customerCenterConfig: CustomerCenterConfigAPI
let redeemWebPurchaseAPI: RedeemWebPurchaseAPI
let virtualCurrenciesAPI: VirtualCurrenciesAPI
let workflowsAPI: WorkflowsAPI

private let config: BackendConfiguration

Expand Down Expand Up @@ -65,6 +66,7 @@ class Backend {
let customerCenterConfig = CustomerCenterConfigAPI(backendConfig: backendConfig)
let redeemWebPurchaseAPI = RedeemWebPurchaseAPI(backendConfig: backendConfig)
let virtualCurrenciesAPI = VirtualCurrenciesAPI(backendConfig: backendConfig)
let workflowsAPI = WorkflowsAPI(backendConfig: backendConfig)

self.init(backendConfig: backendConfig,
customerAPI: customer,
Expand All @@ -75,7 +77,8 @@ class Backend {
internalAPI: internalAPI,
customerCenterConfig: customerCenterConfig,
redeemWebPurchaseAPI: redeemWebPurchaseAPI,
virtualCurrenciesAPI: virtualCurrenciesAPI)
virtualCurrenciesAPI: virtualCurrenciesAPI,
workflowsAPI: workflowsAPI)
}

required init(backendConfig: BackendConfiguration,
Expand All @@ -87,7 +90,8 @@ class Backend {
internalAPI: InternalAPI,
customerCenterConfig: CustomerCenterConfigAPI,
redeemWebPurchaseAPI: RedeemWebPurchaseAPI,
virtualCurrenciesAPI: VirtualCurrenciesAPI) {
virtualCurrenciesAPI: VirtualCurrenciesAPI,
workflowsAPI: WorkflowsAPI) {
self.config = backendConfig

self.customer = customerAPI
Expand All @@ -99,6 +103,7 @@ class Backend {
self.customerCenterConfig = customerCenterConfig
self.redeemWebPurchaseAPI = redeemWebPurchaseAPI
self.virtualCurrenciesAPI = virtualCurrenciesAPI
self.workflowsAPI = workflowsAPI
}

func clearHTTPClientCaches() {
Expand Down
28 changes: 28 additions & 0 deletions Sources/Networking/Caching/WorkflowsCallback.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//
// 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
//
// WorkflowsCallback.swift
//
// Created by RevenueCat.

import Foundation

struct WorkflowsListCallback: CacheKeyProviding {

let cacheKey: String
let completion: (Result<WorkflowsListResponse, BackendError>) -> Void

}

struct WorkflowDetailCallback: CacheKeyProviding {

let cacheKey: String
let completion: (Result<WorkflowFetchResult, BackendError>) -> Void

}
25 changes: 25 additions & 0 deletions Sources/Networking/HTTPClient/HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,31 @@ extension HTTPClient {
// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe.
extension HTTPClient: @unchecked Sendable {}

// MARK: - CDN

internal extension HTTPClient {

/// Fetches raw data from an arbitrary URL using the client's configured `URLSession`.
///
/// Use this for CDN or other non-RC-API requests where the SDK's path-based request building
/// is not applicable, but we still want the same session configuration (timeouts, connection limits).
func fetchRawData(from url: URL, completion: @escaping (Result<Data, Error>) -> Void) {
self.session.dataTask(with: url) { data, response, error in
if let error = error {
completion(.failure(error))
} else if let httpResponse = response as? HTTPURLResponse,
!(200..<300).contains(httpResponse.statusCode) {
completion(.failure(URLError(.badServerResponse)))
} else if let data = data {
completion(.success(data))
} else {
completion(.failure(URLError(.unknown)))
}
}.resume()
}

}

// MARK: - Private

internal extension HTTPClient {
Expand Down
24 changes: 24 additions & 0 deletions Sources/Networking/HTTPClient/HTTPRequestPath.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
//
// Created by Nacho Soto on 8/8/23.

// swiftlint:disable file_length

import Foundation

protocol HTTPRequestPath {
Expand Down Expand Up @@ -101,6 +103,8 @@ extension HTTPRequest {
case getProductEntitlementMapping
case getCustomerCenterConfig(appUserID: String)
case getVirtualCurrencies(appUserID: String)
case getWorkflows(appUserID: String)
case getWorkflow(appUserID: String, workflowId: String)
case postRedeemWebPurchase
case postCreateTicket
case isPurchaseAllowedByRestoreBehavior(appUserID: String)
Expand Down Expand Up @@ -187,6 +191,8 @@ extension HTTPRequest.Path: HTTPRequestPath {
.getProductEntitlementMapping,
.getCustomerCenterConfig,
.getVirtualCurrencies,
.getWorkflows,
.getWorkflow,
.appHealthReport,
.postCreateTicket,
.isPurchaseAllowedByRestoreBehavior:
Expand All @@ -213,6 +219,8 @@ extension HTTPRequest.Path: HTTPRequestPath {
.getProductEntitlementMapping,
.getCustomerCenterConfig,
.getVirtualCurrencies,
.getWorkflows,
.getWorkflow,
.appHealthReport,
.postCreateTicket,
.isPurchaseAllowedByRestoreBehavior:
Expand All @@ -232,6 +240,7 @@ extension HTTPRequest.Path: HTTPRequestPath {
.getOfferings,
.getProductEntitlementMapping,
.getVirtualCurrencies,
.getWorkflows,
.appHealthReport,
.appHealthReportAvailability,
.isPurchaseAllowedByRestoreBehavior:
Expand All @@ -243,6 +252,7 @@ extension HTTPRequest.Path: HTTPRequestPath {
.postOfferForSigning,
.postRedeemWebPurchase,
.getCustomerCenterConfig,
.getWorkflow,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it deliberate to have getWorkflows supportsSignatureVerification --> true while getWorkflow is false?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, the backend only supports signature verification for the endpoint that lists workflows

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am going to bring it up because it looks like an oversight to me

.postCreateTicket:
return false
}
Expand All @@ -267,6 +277,8 @@ extension HTTPRequest.Path: HTTPRequestPath {
.postRedeemWebPurchase,
.getProductEntitlementMapping,
.getCustomerCenterConfig,
.getWorkflows,
.getWorkflow,
.appHealthReport,
.postCreateTicket:
return false
Expand Down Expand Up @@ -327,6 +339,12 @@ extension HTTPRequest.Path: HTTPRequestPath {
case let .getVirtualCurrencies(appUserID):
return "subscribers/\(Self.escape(appUserID))/virtual_currencies"

case let .getWorkflows(appUserID):
return "subscribers/\(Self.escape(appUserID))/workflows"

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):
Expand Down Expand Up @@ -381,6 +399,12 @@ extension HTTPRequest.Path: HTTPRequestPath {
case .getVirtualCurrencies:
return "get_virtual_currencies"

case .getWorkflows:
return "get_workflows"

case .getWorkflow:
return "get_workflow"

case .appHealthReportAvailability:
return "get_app_health_report_availability"

Expand Down
132 changes: 132 additions & 0 deletions Sources/Networking/Operations/GetWorkflowOperation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//
// 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
//
// GetWorkflowOperation.swift
//
// Created by RevenueCat.

import Foundation

final class GetWorkflowOperation: CacheableNetworkOperation {

private let workflowDetailCallbackCache: CallbackCache<WorkflowDetailCallback>
private let configuration: AppUserConfiguration
private let workflowId: String
private let detailProcessor: WorkflowDetailProcessor

static func createFactory(
configuration: UserSpecificConfiguration,
workflowId: String,
detailProcessor: WorkflowDetailProcessor,
workflowDetailCallbackCache: CallbackCache<WorkflowDetailCallback>
) -> CacheableNetworkOperationFactory<GetWorkflowOperation> {
return .init({ cacheKey in
.init(
configuration: configuration,
workflowId: workflowId,
detailProcessor: detailProcessor,
workflowDetailCallbackCache: workflowDetailCallbackCache,
cacheKey: cacheKey
)
},
individualizedCacheKeyPart: configuration.appUserID + "\n" + workflowId)
}

private init(configuration: UserSpecificConfiguration,
workflowId: String,
detailProcessor: WorkflowDetailProcessor,
workflowDetailCallbackCache: CallbackCache<WorkflowDetailCallback>,
cacheKey: String) {
self.configuration = configuration
self.workflowId = workflowId
self.detailProcessor = detailProcessor
self.workflowDetailCallbackCache = workflowDetailCallbackCache

super.init(configuration: configuration, cacheKey: cacheKey)
}

override func begin(completion: @escaping () -> Void) {
self.getWorkflow(completion: completion)
}

}

// Restating inherited @unchecked Sendable from Foundation's Operation
extension GetWorkflowOperation: @unchecked Sendable {}

private extension GetWorkflowOperation {

func getWorkflow(completion: @escaping () -> Void) {
let appUserID = self.configuration.appUserID

guard appUserID.isNotEmpty else {
self.workflowDetailCallbackCache.performOnAllItemsAndRemoveFromCache(withCacheable: self) { callback in
callback.completion(.failure(.missingAppUserID()))
}
completion()
return
}

let request = HTTPRequest(
method: .get,
path: .getWorkflow(appUserID: appUserID, workflowId: self.workflowId)
)

httpClient.perform(request) { (response: VerifiedHTTPResponse<Data>.Result) in
self.handleResponse(response, completion: completion)
}
}

func handleResponse(_ response: VerifiedHTTPResponse<Data>.Result, completion: @escaping () -> Void) {
switch response {
case .failure(let networkError):
defer { completion() }
self.distribute(.failure(BackendError.networkError(networkError)))

case .success(let verifiedResponse):
self.detailProcessor.process(verifiedResponse.body) { processingResult in
defer { completion() }
self.distribute(self.backendResult(from: processingResult, envelopeData: verifiedResponse.body))
}
}
}

func backendResult(
from processingResult: Result<WorkflowDetailProcessingResult, Error>,
envelopeData: Data
) -> Result<WorkflowFetchResult, BackendError> {
switch processingResult {
case .success(let processed):
do {
let workflow = try PublishedWorkflow.create(with: processed.workflowData)
return .success(WorkflowFetchResult(workflow: workflow, enrolledVariants: processed.enrolledVariants))
} catch {
return .failure(BackendError.networkError(NetworkError.decoding(error, envelopeData)))
}
case .failure(let processingError as WorkflowDetailProcessingError):
switch processingError {
case .cdnFetchFailed(let underlyingError):
return .failure(.networkError(NetworkError.networkError(underlyingError)))
case .invalidEnvelopeJson, .unknownAction, .missingInlineData, .missingCdnUrl:
return .failure(.networkError(NetworkError.decoding(processingError, envelopeData)))
}
case .failure(let error):
return .failure(.networkError(NetworkError.decoding(error, envelopeData)))
}
}

func distribute(_ result: Result<WorkflowFetchResult, BackendError>) {
self.workflowDetailCallbackCache.performOnAllItemsAndRemoveFromCache(
withCacheable: self
) { callbackObject in
callbackObject.completion(result)
}
}

}
Loading