Files
purchases-ios/Sources/Networking/WorkflowsAPI.swift
T
0b65086ec2 Add workflows network layer for multipage paywalls (#6557)
* 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>
2026-04-21 12:32:01 +02:00

107 lines
4.0 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
//
// WorkflowsAPI.swift
//
// Created by RevenueCat.
import Foundation
class WorkflowsAPI {
typealias WorkflowDetailResponseHandler = Backend.ResponseHandler<WorkflowFetchResult>
private let workflowDetailCallbackCache: CallbackCache<WorkflowDetailCallback>
private let backendConfig: BackendConfiguration
private let detailProcessor: WorkflowDetailProcessor
init(backendConfig: BackendConfiguration,
cdnFetch: WorkflowCdnFetch? = nil) {
self.backendConfig = backendConfig
self.workflowDetailCallbackCache = .init()
self.detailProcessor = WorkflowDetailProcessor(
cdnFetch: cdnFetch ?? Self.defaultCdnFetch(httpClient: backendConfig.httpClient)
)
}
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private static let workflowsFileRepository = FileRepository(basePath: "RevenueCat/workflows")
private static func defaultCdnFetch(httpClient: HTTPClient) -> WorkflowCdnFetch {
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) {
return Self.fileCachedCdnFetch(fileRepository: Self.workflowsFileRepository)
}
return Self.httpCdnFetch(httpClient: httpClient)
}
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private static func fileCachedCdnFetch(
fileRepository: FileRepositoryType
) -> WorkflowCdnFetch {
return { cdnUrl, hash, completion in
guard let url = URL(string: cdnUrl) else {
completion(.failure(URLError(.badURL)))
return
}
let checksum = hash.map { Checksum(algorithm: .sha256, value: $0) }
Task {
do {
let cachedURL = try await fileRepository.generateOrGetCachedFileURL(
for: url,
withChecksum: checksum
)
completion(.success(try Data(contentsOf: cachedURL)))
} catch FileRepository.Error.checksumMismatch {
completion(.failure(WorkflowDetailProcessingError.cdnHashMismatch))
} catch {
completion(.failure(error))
}
}
}
}
private static func httpCdnFetch(httpClient: HTTPClient) -> WorkflowCdnFetch {
return { cdnUrl, _, completion in
guard let url = URL(string: cdnUrl) else {
completion(.failure(URLError(.badURL)))
return
}
httpClient.fetchRawData(from: url, completion: completion)
}
}
func getWorkflow(appUserID: String,
workflowId: String,
isAppBackgrounded: Bool,
completion: @escaping WorkflowDetailResponseHandler) {
let config = NetworkOperation.UserSpecificConfiguration(httpClient: self.backendConfig.httpClient,
appUserID: appUserID)
let factory = GetWorkflowOperation.createFactory(
configuration: config,
workflowId: workflowId,
detailProcessor: self.detailProcessor,
workflowDetailCallbackCache: self.workflowDetailCallbackCache
)
let callback = WorkflowDetailCallback(cacheKey: factory.cacheKey, completion: completion)
let cacheStatus = self.workflowDetailCallbackCache.add(callback)
self.backendConfig.addCacheableOperation(
with: factory,
delay: .default(forBackgroundedApp: isAppBackgrounded),
cacheStatus: cacheStatus
)
}
}
// @unchecked because:
// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe.
extension WorkflowsAPI: @unchecked Sendable {}