Skip to content

Add Workflows network layer#3300

Open
vegaro wants to merge 3 commits intomainfrom
feat/multipage-paywalls-milestone-1-go8
Open

Add Workflows network layer#3300
vegaro wants to merge 3 commits intomainfrom
feat/multipage-paywalls-milestone-1-go8

Conversation

@vegaro
Copy link
Copy Markdown
Member

@vegaro vegaro commented Apr 6, 2026

Motivation

This is the first milestone toward multipage paywalls on Android. The backend exposes two endpoints for workflows: one to list available workflows for a subscriber and one to fetch the full published workflow document. The detail endpoint uses an envelope pattern where the server decides whether to return the workflow payload inline or redirect to a CDN URL. This PR adds the full networking layer so the SDK can fetch and parse workflows end-to-end.

Description

Endpoints

Two new Endpoint subtypes: GetWorkflows (list) and GetWorkflow (detail). The list endpoint participates in signature verification (same as offerings); the detail endpoint does not.

Models

All workflow types live in WorkflowModels.kt: WorkflowSummary, WorkflowsListResponse, PublishedWorkflow (the full document with steps, screens, trigger actions), and WorkflowFetchResult which bundles the parsed workflow with any enrolled_variants from the response envelope.

WorkflowResponseAction is an enum (INLINE, USE_CDN) so the envelope action is never compared as a raw string.

Response envelope processing

WorkflowDetailHttpProcessor handles the detail endpoint's envelope outside of Backend:

  • inline: unwraps the data object as the workflow payload.
  • use_cdn: delegates to an injected WorkflowCdnFetcher to download the compiled JSON from the CDN URL.
  • Extracts enrolled_variants from the envelope for experiment tracking.

WorkflowDetailHttpProcessor is its own class Backend.call() thin (just network + delegate) and makes envelope logic independently testable.

CDN fetching

WorkflowCdnFetcher is a fun interface so tests can substitute a lambda. The production implementation, FileCachedWorkflowCdnFetcher, uses FileRepository for disk caching when available and falls back to a direct URL read otherwise. Injected into Backend via PurchasesFactory.


Note

Medium Risk
Adds new backend requests and response processing for workflow documents, including optional CDN download and disk caching, which introduces new network/file I/O paths and envelope parsing that could fail at runtime. Changes are additive but touch Backend request flow and endpoint signature-verification configuration.

Overview
Adds a new workflows network layer: Endpoint.GetWorkflows (list, participates in signature verification) and Endpoint.GetWorkflow (detail, no signature verification), with corresponding Backend.getWorkflows/getWorkflow methods and callback de-duping.

Introduces workflow parsing and envelope normalization: new serializable models (WorkflowsListResponse, PublishedWorkflow, etc.), WorkflowJsonParser, and WorkflowDetailHttpProcessor that unwraps inline payloads or fetches compiled JSON via CDN (use_cdn) while preserving enrolled_variants.

Wires production CDN fetching with disk caching by injecting FileCachedWorkflowCdnFetcher(DefaultFileRepository) from PurchasesFactory, and adds unit tests covering endpoint paths/flags, backend workflow fetching, and envelope/CDN handling.

Reviewed by Cursor Bugbot for commit 895becd. Bugbot is set up for automated code reviews on this repo. Configure here.

@vegaro vegaro added the pr:fix A bug fix label Apr 6, 2026
@vegaro vegaro changed the title Add workflows network layer (multipage paywalls milestone 1) Add Workflows network layer Apr 6, 2026
@vegaro vegaro force-pushed the feat/multipage-paywalls-milestone-1-go8 branch from 6ebd871 to be06ed6 Compare April 6, 2026 13:25
vegaro added a commit to RevenueCat/purchases-ios that referenced this pull request Apr 6, 2026
- 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 RevenueCat/purchases-android#3300

Made-with: Cursor
@vegaro vegaro marked this pull request as ready for review April 7, 2026 09:59
@vegaro vegaro requested a review from a team as a code owner April 7, 2026 09:59
@vegaro vegaro requested a review from facumenzella April 7, 2026 09:59
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 37aaf43. Configure here.

- Throw JSONException instead of IllegalArgumentException for unknown
  workflow response actions so AsyncCall.run() catches it gracefully
- Use imported InternalRevenueCatAPI in @file:OptIn instead of FQN

Made-with: Cursor
import com.revenuecat.purchases.InternalRevenueCatAPI
import kotlinx.serialization.json.Json

internal object WorkflowJsonParser {
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.

This feels like an unnecessary abstraction to me. Or what am I missing? Can't we reuse an existing parser?

val keyIterator = keys()
while (keyIterator.hasNext()) {
val key = keyIterator.next()
map[key] = optString(key)
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.

optString will convert anything to string? How does that work? Can we add tests for non-string keys?

Copy link
Copy Markdown
Member

@facumenzella facumenzella left a comment

Choose a reason for hiding this comment

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

Just a couple of questions. Looks good

Copy link
Copy Markdown
Contributor

@tonidero tonidero left a comment

Choose a reason for hiding this comment

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

Not a lot of context and just some thoughts. But happy to iterate on this in future PRs since it's still not used

private val eventsDispatcher: Dispatcher,
private val httpClient: HTTPClient,
private val backendHelper: BackendHelper,
workflowCdnFetcher: WorkflowCdnFetcher = FileCachedWorkflowCdnFetcher(fileRepository = null),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hmm why default here to a different one since we're creating the Backend instance in the PurchasesFactory with a value? If this is for tests, I wonder if we should just make this explicit there... Also, it would make fileRespository not nullable, which feels better?

@Serializable
internal data class WorkflowsListResponse(
val workflows: List<WorkflowSummary> = emptyList(),
@SerialName("ui_config") val uiConfig: UiConfig,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just curious... why is the uiConfig needed here if we also have it in the individual workflow's model?

val screens: Map<String, WorkflowScreen>,
@SerialName("ui_config") val uiConfig: UiConfig,
@SerialName("content_max_width") val contentMaxWidth: Int? = null,
val metadata: JsonObject? = null,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hmm I guess this could have nested data? Otherwise, we could make this a Map<String, Any> for ease of use.

?: throw JSONException("Unknown workflow response action: $rawAction")
val normalizedPayload = when (action) {
WorkflowResponseAction.INLINE -> root.getJSONObject("data").toString()
WorkflowResponseAction.USE_CDN -> workflowCdnFetcher.fetchCompiledWorkflowJson(root.getString("url"))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I wonder if we should just stick to plain Json serialization here, and avoid the custom parsing we are doing here. Then, do the fetching of the compiled workflow as a higher level later on top of the network call? Feels the network call and the logic are a bit mixed right now...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants