Files
purchases-ios/Sources/Networking/HTTPClient/HTTPRequestPath.swift
T
Antonio PallaresandCursor eac1656a40 Add the hosted checkout session status endpoint (#7885)
* Decode the in-app web checkout open method

Marks a web purchase button as opening its checkout in the in-app bottom
sheet. Nothing reads it yet: routing lands with the button wiring.

Where the method reaches Browser.navigateTo there is no sheet to present,
so it takes the in-app browser rather than opening nothing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Log the in-app browser fallback for the checkout open method

Co-authored-by: Cursor <cursoragent@cursor.com>

* Model the checkout web view lifecycle as a single state

The three booleans tracking the web view's progress could combine into
states that cannot happen, and `hasFinished` guarded only the callback:
a navigation still in flight after the return URL could set `.failed`
and blank the page while the host was dismissing the sheet.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Hold the checkout status until a host is listening

Detents without `presentationContentInteraction`, iOS 16.4, turn a drag
on the page into a sheet resize, so 16.0 to 16.3 gets the full-height
sheet rather than an unscrollable medium one.

The sheet assigns `onFinished` on appearance, and loading can start
before that, so a page reaching a return URL first would drop it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim comments in the checkout sheet presentation

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add the hosted checkout entry point the paywall can call

Composes the external purchase preparation and the hosted checkout call
behind an @_spi(Internal) entry point on Purchases, so RevenueCatUI can
start a checkout with a single call.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Decode the hosted web checkout purchase button method

Co-authored-by: Cursor <cursoragent@cursor.com>

* Give the checkout sheet its own object ID in the Xcode project

Co-authored-by: Cursor <cursoragent@cursor.com>

* Resolve the hosted checkout open method in the model

Co-authored-by: Cursor <cursoragent@cursor.com>

* Leave the open method default to the caller

Co-authored-by: Cursor <cursoragent@cursor.com>

* Take the hosted checkout method without an open method

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add the checkout web view host

Owns the WKWebView that presents a payment provider's checkout page, detects the
return URL that ends the flow, and reports success or cancellation to its host.

Navigations are not restricted: the checkout URL comes from RevenueCat's backend
and the page it opens is free to reach the provider's own domains and any bank's
3DS challenge.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Take both return URLs from the checkout start response

The backend now returns success_url and cancel_url alongside checkout_url, so
the outcome is told by which one the page navigates to rather than by a query
parameter the SDK knows the name of.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Compare the root return path consistently

URL.path strips trailing slashes for every path but the root, so the length
guard was the only thing keeping https://host/ and https://host from matching.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop the unused reload from the checkout view model

Nothing calls it until something presents a retry, so leave it to whichever
change needs it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim comments in the checkout web view

Co-authored-by: Cursor <cursoragent@cursor.com>

* Model the checkout web view lifecycle as a single state

The three booleans tracking the web view's progress could combine into
states that cannot happen, and `hasFinished` guarded only the callback:
a navigation still in flight after the return URL could set `.failed`
and blank the page while the host was dismissing the sheet.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Leave the Test Store out of the hosted checkout

A test_ key stops at the eligibility gate and reports external purchases
as unavailable, so the caller buys through StoreKit rather than opening a
checkout the Test Store does not support yet.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Restore Package.resolved

Co-authored-by: Cursor <cursoragent@cursor.com>

* Follow the external purchase outcomes the manager reports now

The manager tells payment authorization apart from eligibility and stops
a preparation asked for while another is under way, neither of which
existed when the checkout was written. It also reuses the instance the
link-out flow uses, so a single preparation runs at a time however the
customer is taken to the checkout.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add the checkout web view host

Owns the WKWebView that presents a payment provider's checkout page, detects the
return URL that ends the flow, and reports success or cancellation to its host.

Navigations are not restricted: the checkout URL comes from RevenueCat's backend
and the page it opens is free to reach the provider's own domains and any bank's
3DS challenge.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Take both return URLs from the checkout start response

The backend now returns success_url and cancel_url alongside checkout_url, so
the outcome is told by which one the page navigates to rather than by a query
parameter the SDK knows the name of.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Compare the root return path consistently

URL.path strips trailing slashes for every path but the root, so the length
guard was the only thing keeping https://host/ and https://host from matching.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop the unused reload from the checkout view model

Nothing calls it until something presents a retry, so leave it to whichever
change needs it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim comments in the checkout web view

Co-authored-by: Cursor <cursoragent@cursor.com>

* Model the checkout web view lifecycle as a single state

The three booleans tracking the web view's progress could combine into
states that cannot happen, and `hasFinished` guarded only the callback:
a navigation still in flight after the return URL could set `.failed`
and blank the page while the host was dismissing the sheet.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Open the checkout the purchase button asks for in a sheet

A purchase button configured for the in-app checkout now runs Apple's
flow, creates the session and presents it, rather than buying through
StoreKit. The page reporting success or a cancellation takes the sheet
down and settles the paywall; a customer who closes the sheet without
the page saying anything is left for the backend to settle, which is
not wired up yet.

A device that cannot take part in Apple's programme still buys through
StoreKit, as an SDK that does not know this purchase method would.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Let the checkout page reach the bottom edge

Stopping above the home indicator leaves a strip of the host's
background under a page that fills its sheet. The web view insets its
own content by the safe area, so nothing the page puts at the bottom
goes out of reach.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add the checkout web view host

Owns the WKWebView that presents a payment provider's checkout page, detects the
return URL that ends the flow, and reports success or cancellation to its host.

Navigations are not restricted: the checkout URL comes from RevenueCat's backend
and the page it opens is free to reach the provider's own domains and any bank's
3DS challenge.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Take both return URLs from the checkout start response

The backend now returns success_url and cancel_url alongside checkout_url, so
the outcome is told by which one the page navigates to rather than by a query
parameter the SDK knows the name of.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Compare the root return path consistently

URL.path strips trailing slashes for every path but the root, so the length
guard was the only thing keeping https://host/ and https://host from matching.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop the unused reload from the checkout view model

Nothing calls it until something presents a retry, so leave it to whichever
change needs it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim comments in the checkout web view

Co-authored-by: Cursor <cursoragent@cursor.com>

* Model the checkout web view lifecycle as a single state

The three booleans tracking the web view's progress could combine into
states that cannot happen, and `hasFinished` guarded only the callback:
a navigation still in flight after the return URL could set `.failed`
and blank the page while the host was dismissing the sheet.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Let the checkout page reach the bottom edge

Stopping above the home indicator leaves a strip of the host's
background under a page that fills its sheet. The web view insets its
own content by the safe area, so nothing the page puts at the bottom
goes out of reach.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Read what the checkout returned off the view model

The status was pushed into SwiftUI state in the same breath as the
dismissal that reads it, and the view model handed it over and forgot
it, leaving nothing to fall back on. A page that returned before the
sheet was on screen also asked to be dismissed from inside its own
presentation, which SwiftUI is in no position to honour.

The status now stays on the view model, the sheet keeps what it
presented so it can ask at any time, and a checkout that returned
early waits a hop before asking to come down.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add the checkout web view host

Owns the WKWebView that presents a payment provider's checkout page, detects the
return URL that ends the flow, and reports success or cancellation to its host.

Navigations are not restricted: the checkout URL comes from RevenueCat's backend
and the page it opens is free to reach the provider's own domains and any bank's
3DS challenge.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Take both return URLs from the checkout start response

The backend now returns success_url and cancel_url alongside checkout_url, so
the outcome is told by which one the page navigates to rather than by a query
parameter the SDK knows the name of.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Compare the root return path consistently

URL.path strips trailing slashes for every path but the root, so the length
guard was the only thing keeping https://host/ and https://host from matching.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop the unused reload from the checkout view model

Nothing calls it until something presents a retry, so leave it to whichever
change needs it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim comments in the checkout web view

Co-authored-by: Cursor <cursoragent@cursor.com>

* Model the checkout web view lifecycle as a single state

The three booleans tracking the web view's progress could combine into
states that cannot happen, and `hasFinished` guarded only the callback:
a navigation still in flight after the return URL could set `.failed`
and blank the page while the host was dismissing the sheet.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Let the checkout page reach the bottom edge

Stopping above the home indicator leaves a strip of the host's
background under a page that fills its sheet. The web view insets its
own content by the safe area, so nothing the page puts at the bottom
goes out of reach.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim comments in the hosted checkout manager

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim the remaining hosted checkout result comments

Co-authored-by: Cursor <cursoragent@cursor.com>

* Send the paywall session without a paywall identifier

khepri#24938 made `paywall_id` optional precisely so that iOS could
send the session identifier, which is the key the paywall events are
joined on. Dropping the whole paywall object for a paywall that has no
identifier of its own threw that session away.

Also trims the last of the result comments.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim the hosted checkout session comments

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop the remaining hosted checkout session comments

Co-authored-by: Cursor <cursoragent@cursor.com>

* Silence missing docs for the whole session file

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add the checkout web view host

Owns the WKWebView that presents a payment provider's checkout page, detects the
return URL that ends the flow, and reports success or cancellation to its host.

Navigations are not restricted: the checkout URL comes from RevenueCat's backend
and the page it opens is free to reach the provider's own domains and any bank's
3DS challenge.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Take both return URLs from the checkout start response

The backend now returns success_url and cancel_url alongside checkout_url, so
the outcome is told by which one the page navigates to rather than by a query
parameter the SDK knows the name of.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Compare the root return path consistently

URL.path strips trailing slashes for every path but the root, so the length
guard was the only thing keeping https://host/ and https://host from matching.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop the unused reload from the checkout view model

Nothing calls it until something presents a retry, so leave it to whichever
change needs it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim comments in the checkout web view

Co-authored-by: Cursor <cursoragent@cursor.com>

* Model the checkout web view lifecycle as a single state

The three booleans tracking the web view's progress could combine into
states that cannot happen, and `hasFinished` guarded only the callback:
a navigation still in flight after the return URL could set `.failed`
and blank the page while the host was dismissing the sheet.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Let the checkout page reach the bottom edge

Stopping above the home indicator leaves a strip of the host's
background under a page that fills its sheet. The web view insets its
own content by the safe area, so nothing the page puts at the bottom
goes out of reach.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep the checkout sheet's callbacks on the main actor

The status the sheet reports is read off the view model, which is main
actor isolated, so the methods doing the reading have to say they run
there. Swift 5.10 does not infer it from the ViewModifier conformance
the way newer compilers do, and the iOS 16 job builds with Xcode 15.4.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Present the in-app web checkout outside Apple's external purchase programme

An ineligible customer, or an app that has not opted into the programme, now
gets the checkout presented with no external purchase token behind it, the same
way a web purchase in the browser is opened today, instead of being routed to a
StoreKit purchase.

A Test Store API key still leaves the caller to buy through StoreKit, since no
checkout session can be created for it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Present the in-app web checkout with a Test Store API key

The backend creates a sandbox checkout session for a test_ key, so there is no
reason to leave the caller to buy through StoreKit instead of presenting the
checkout, which is what a web purchase in the browser already does.

Apple's programme has no App Store to apply to behind such a key, so the
checkout is presented with no notice shown and no token minted.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep the console quiet for apps outside Apple's programme

The external purchase side already reports why no external purchase was
prepared, and says nothing at all where the app takes no part in the programme,
so the checkout has nothing to add.

Also covers the window before the notice comes up, where the one checkout at a
time rule now decides between a checkout that opens and one that is dropped.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Say what the outcome does rather than what it used to do

Co-authored-by: Cursor <cursoragent@cursor.com>

* Say that Apple's custom link does not apply rather than that nothing can be bought

An ineligible customer is not a customer who cannot buy outside the App Store:
the custom link may simply not apply where they are, and the purchase goes ahead
without it. Dropped to debug, since that is the normal case anywhere the link
does not reach.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop a note on an outcome no caller acts on

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop a note on an outcome no caller acts on

Co-authored-by: Cursor <cursoragent@cursor.com>

* Remove two conveniences on the preparation result that no caller uses

Co-authored-by: Cursor <cursoragent@cursor.com>

* Restore the test dependencies dropped from Package.resolved

Co-authored-by: Cursor <cursoragent@cursor.com>

* Run Apple's external purchase flow with a Test Store key

Eligibility, the notice and the token follow the app's entitlement and the
customer's storefront, none of which has anything to do with the key the SDK was
configured with, and the purchase behind such a token is a sandbox one either
way. Skipping the flow left a Test Store key, the shortest path a developer has
to trying the checkout out, as the one key that could not exercise it.

Reverts the carve-out added in #7643.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Route the hosted web checkout method to the checkout it asks for

The merge left the placeholder branch that sent hosted_web_checkout to StoreKit
alongside the one that opens the checkout, and the checkout no longer reports an
external purchase being unavailable: it presents itself in that case too.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Tell the customer when the in-app checkout is refused for a product they own

The backend answers 409/7772 for a product the customer already has, which
the SDK decoded as an unknown error and reported as a plain failure: the
sheet never opened and the tap looked like it did nothing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Word the already-owned alert as Apple does, by product category

Only the title carries over: Apple's own alert also names the renewal date
and offers to manage the subscription, both of which describe a purchase
made on the App Store rather than this one.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Present the in-app web checkout outside Apple's external purchase programme

An ineligible customer, or an app that has not opted into the programme, now
gets the checkout presented with no external purchase token behind it, the same
way a web purchase in the browser is opened today, instead of being routed to a
StoreKit purchase.

A Test Store API key still leaves the caller to buy through StoreKit, since no
checkout session can be created for it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep the console quiet for apps outside Apple's programme

The external purchase side already reports why no external purchase was
prepared, and says nothing at all where the app takes no part in the programme,
so the checkout has nothing to add.

Also covers the window before the notice comes up, where the one checkout at a
time rule now decides between a checkout that opens and one that is dropped.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Say what the outcome does rather than what it used to do

Co-authored-by: Cursor <cursoragent@cursor.com>

* Say that Apple's custom link does not apply rather than that nothing can be bought

An ineligible customer is not a customer who cannot buy outside the App Store:
the custom link may simply not apply where they are, and the purchase goes ahead
without it. Dropped to debug, since that is the normal case anywhere the link
does not reach.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop a note on an outcome no caller acts on

Co-authored-by: Cursor <cursoragent@cursor.com>

* Remove two conveniences on the preparation result that no caller uses

Co-authored-by: Cursor <cursoragent@cursor.com>

* Restore the test dependencies dropped from Package.resolved

Co-authored-by: Cursor <cursoragent@cursor.com>

* Ask for the checkout through the purchase handler

The handler is the paywall's way to the SDK, so reaching for the singleton
meant checking whether it was configured at all. It never had to: an
unconfigured paywall is never rendered, and the handler already stands in for
a purchase that cannot be made.

Drops the StoreKit fallback that only that check could reach.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Let the mapping speak for the outcome it produces

The init lists what turns into nothing, so the case did not need to repeat it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Present the in-app web checkout outside Apple's external purchase programme

An ineligible customer, or an app that has not opted into the programme, now
gets the checkout presented with no external purchase token behind it, the same
way a web purchase in the browser is opened today, instead of being routed to a
StoreKit purchase.

A Test Store API key still leaves the caller to buy through StoreKit, since no
checkout session can be created for it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep the console quiet for apps outside Apple's programme

The external purchase side already reports why no external purchase was
prepared, and says nothing at all where the app takes no part in the programme,
so the checkout has nothing to add.

Also covers the window before the notice comes up, where the one checkout at a
time rule now decides between a checkout that opens and one that is dropped.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Say what the outcome does rather than what it used to do

Co-authored-by: Cursor <cursoragent@cursor.com>

* Say that Apple's custom link does not apply rather than that nothing can be bought

An ineligible customer is not a customer who cannot buy outside the App Store:
the custom link may simply not apply where they are, and the purchase goes ahead
without it. Dropped to debug, since that is the normal case anywhere the link
does not reach.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop a note on an outcome no caller acts on

Co-authored-by: Cursor <cursoragent@cursor.com>

* Remove two conveniences on the preparation result that no caller uses

Co-authored-by: Cursor <cursoragent@cursor.com>

* Restore the test dependencies dropped from Package.resolved

Co-authored-by: Cursor <cursoragent@cursor.com>

* perf: add checkout request lane and generalize backend lanes

Checkout tap-path requests now bypass the shared backend serialization
stack via a dedicated lane, using the same generalized lane factory as
remote config.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address review feedback on backend lanes

Restore misplaced comments, add BackendLanesTests, and use plain Sendable
on BackendLanes now that the justification matches the type.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Clarify timeoutManager sharing comment wording

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim comments around the already-owned alert

Co-authored-by: Cursor <cursoragent@cursor.com>

* State where the hosted checkout is presented from

Presenting the sheet is main actor work, and the await in front of it said the
opposite: nothing suspends there, which `pod lib lint` counts as a warning and
so refuses the podspec.

Annotating the caller rather than dropping the await alone keeps this from
depending on whether a given compiler infers a View's methods to be isolated,
which is the same disagreement that put the sheet's callbacks on the main actor.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Present the in-app web checkout outside Apple's external purchase programme

An ineligible customer, or an app that has not opted into the programme, now
gets the checkout presented with no external purchase token behind it, the same
way a web purchase in the browser is opened today, instead of being routed to a
StoreKit purchase.

A Test Store API key still leaves the caller to buy through StoreKit, since no
checkout session can be created for it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep the console quiet for apps outside Apple's programme

The external purchase side already reports why no external purchase was
prepared, and says nothing at all where the app takes no part in the programme,
so the checkout has nothing to add.

Also covers the window before the notice comes up, where the one checkout at a
time rule now decides between a checkout that opens and one that is dropped.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Say what the outcome does rather than what it used to do

Co-authored-by: Cursor <cursoragent@cursor.com>

* Say that Apple's custom link does not apply rather than that nothing can be bought

An ineligible customer is not a customer who cannot buy outside the App Store:
the custom link may simply not apply where they are, and the purchase goes ahead
without it. Dropped to debug, since that is the normal case anywhere the link
does not reach.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop a note on an outcome no caller acts on

Co-authored-by: Cursor <cursoragent@cursor.com>

* Remove two conveniences on the preparation result that no caller uses

Co-authored-by: Cursor <cursoragent@cursor.com>

* Restore the test dependencies dropped from Package.resolved

Co-authored-by: Cursor <cursoragent@cursor.com>

* Test production checkout lane wiring

Build Backend through the convenience initializer in the parallel
checkout test so removing .checkout from dedicatedLanes fails the suite.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Restrict RequestLane to canonical constants

Replace the synthesized memberwise initializer with a private init so
lane values cannot be built ad hoc and miss dedicatedConfigurations keys.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Clarify apiSourceFailover is shared across lanes

Co-authored-by: Cursor <cursoragent@cursor.com>

* Remove RequestLane private init doc comment

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add checkout lane tests for shared lane and token refresh dedupe.

Pin that both checkout endpoints share one lane in a single backend, and that concurrent 401s across lanes trigger exactly one token refresh when HTTPClients share a TokenManager.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Clear HTTP client caches on every backend lane.

Backend now retains its BackendLanes and delegates cache clearing to all lane configurations, with a test that asserts each lane's HTTPClient is cleared.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Cover external purchase token in checkout lane parallel test.

Extend the production wiring test so both checkout tap-path requests complete while offerings hangs on the default lane.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Derive Backend default config from lanes.

Remove the redundant backendConfig parameter from Backend's required initializer so config always matches lanes[.default].

Co-authored-by: Cursor <cursoragent@cursor.com>

* Warn when a backend lane falls back to the default configuration.

Log a warning when a BackendLanes instance has dedicated lanes but the requested lane is missing, so miswired APIs are visible in production without noisy warnings for single-lane bundles.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Move test-only Backend and BackendLanes initializers to UnitTests.

Single-lane construction helpers now live in the test target so production code only exposes the multi-lane factory path.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Register BackendTestInitializers in StoreKitUnitTests.

MockBackend is compiled into both UnitTests and StoreKitUnitTests, so the
test-only Backend initializers helper must be in both targets' Sources phases.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Ask the backend what became of a checkout session

The checkout page returning to its success URL says the customer finished paying,
not that the purchase has landed, so the session is polled until the backend
settles it one way or the other.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Settle a checkout on the customer and the reason it ended

The session belongs to the customer who started it, so the poll asks about that
one rather than about whoever is current when an attempt goes out: a customer
change mid-poll would otherwise turn a landed purchase into no answer at all.

A confirmed purchase is fetched before it is reported, so a caller acting on the
outcome finds the entitlement on the customer instead of the state from before
the checkout. A fetch that does not land leaves the purchase standing.

The failure the backend gives now travels with the outcome, which tells a
declined payment from a checkout that never got going.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Let the view showing the checkout hand its links to the browser

The button set onOpenExternalURL on the view model it was about to present, and
WebCheckoutView assigns that same closure when it appears. Nothing could reach
the button's: the page only starts loading on that appearance, a line after.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Present the in-app web checkout outside Apple's external purchase programme

An ineligible customer, or an app that has not opted into the programme, now
gets the checkout presented with no external purchase token behind it, the same
way a web purchase in the browser is opened today, instead of being routed to a
StoreKit purchase.

A Test Store API key still leaves the caller to buy through StoreKit, since no
checkout session can be created for it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep the console quiet for apps outside Apple's programme

The external purchase side already reports why no external purchase was
prepared, and says nothing at all where the app takes no part in the programme,
so the checkout has nothing to add.

Also covers the window before the notice comes up, where the one checkout at a
time rule now decides between a checkout that opens and one that is dropped.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Say what the outcome does rather than what it used to do

Co-authored-by: Cursor <cursoragent@cursor.com>

* Say that Apple's custom link does not apply rather than that nothing can be bought

An ineligible customer is not a customer who cannot buy outside the App Store:
the custom link may simply not apply where they are, and the purchase goes ahead
without it. Dropped to debug, since that is the normal case anywhere the link
does not reach.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop a note on an outcome no caller acts on

Co-authored-by: Cursor <cursoragent@cursor.com>

* Remove two conveniences on the preparation result that no caller uses

Co-authored-by: Cursor <cursoragent@cursor.com>

* Restore the test dependencies dropped from Package.resolved

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop the note on who hands the checkout's links to the browser

Co-authored-by: Cursor <cursoragent@cursor.com>

* Restore the test dependencies dropped from Package.resolved

Co-authored-by: Cursor <cursoragent@cursor.com>

* Call what the paywall does with a checkout tap an action

Start read as a verb or an event, so HostedCheckout.Start(.failed) looked like it
began something. The cases are things the paywall does: present, or nothing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Name the already-owned mapping test's type after the rename

Co-authored-by: Cursor <cursoragent@cursor.com>

* Ask the purchase interceptor before starting a hosted checkout

Apps that gate purchases with onPurchaseInitiated (e.g. behind sign in) could be bypassed by the hosted checkout, and Apple's flow may mint a token that must be reported, so the interceptor now runs first.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop the token note from HostedCheckout.start's docs

Co-authored-by: Cursor <cursoragent@cursor.com>

* Track hosted checkouts as purchase initiated

The event was only used for its data to attribute the checkout session, so these purchases never showed up as initiated in paywall analytics, unlike the StoreKit and performPurchase paths.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Move the checkout poller to its own PR

Keeps this PR to the network layer, so it can be reviewed on its own. The poller, HostedCheckoutManager.pollCheckout and Purchases.pollHostedCheckout follow in a stacked PR.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Share the purchase tap checks between the in-app and hosted checkout paths

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim packageForPurchaseTap's doc comment

Co-authored-by: Cursor <cursoragent@cursor.com>

* Present the in-app web checkout outside Apple's external purchase programme

An ineligible customer, or an app that has not opted into the programme, now
gets the checkout presented with no external purchase token behind it, the same
way a web purchase in the browser is opened today, instead of being routed to a
StoreKit purchase.

A Test Store API key still leaves the caller to buy through StoreKit, since no
checkout session can be created for it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep the console quiet for apps outside Apple's programme

The external purchase side already reports why no external purchase was
prepared, and says nothing at all where the app takes no part in the programme,
so the checkout has nothing to add.

Also covers the window before the notice comes up, where the one checkout at a
time rule now decides between a checkout that opens and one that is dropped.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Say what the outcome does rather than what it used to do

Co-authored-by: Cursor <cursoragent@cursor.com>

* Say that Apple's custom link does not apply rather than that nothing can be bought

An ineligible customer is not a customer who cannot buy outside the App Store:
the custom link may simply not apply where they are, and the purchase goes ahead
without it. Dropped to debug, since that is the normal case anywhere the link
does not reach.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop a note on an outcome no caller acts on

Co-authored-by: Cursor <cursoragent@cursor.com>

* Remove two conveniences on the preparation result that no caller uses

Co-authored-by: Cursor <cursoragent@cursor.com>

* Restore the test dependencies dropped from Package.resolved

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add hosted checkout status snapshots and fix the email escaping expectation

* Read the hosted checkout status at the top level of the response

The backend now returns the operation's fields directly instead of nesting them under `operation`.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Read an unrecognized hosted checkout status as pending

The backend only ever sends started, in_progress, succeeded or failed, so an unknown case was a status it never returns.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Stop reading in_progress as a hosted checkout status

The backend no longer sends it. Should it still arrive, it reads as pending like any status the SDK does not recognize.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim hosted checkout status comments

Co-authored-by: Cursor <cursoragent@cursor.com>

* Move the hosted checkout query escaping next to the path escaping

Co-authored-by: Cursor <cursoragent@cursor.com>

* Read an in-progress hosted checkout session as still under way

purchases-js polls on it too, and the SDK was warning about it as unrecognized.

* Add request snapshots for the in-progress hosted checkout status test

* Say where hosted checkout failure codes come from

* Join the hosted checkout status cache key with a hyphen, like other operations

* Say what the hosted checkout status endpoint gives in plainer words

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 17:08:30 +02:00

744 lines
22 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 full relative path for this endpoint when using IAM tokens.
var relativeIAMPath: String { get }
/// The fallback relative path for this endpoint, if any.
var fallbackRelativePath: String? { get }
/// Whether this path resolves its base host from the API source provider (the main-API host
/// list with failover) rather than the static `serverHostURL`.
var usesAPISources: Bool { get }
/// Whether this path's `serverHostURL` is a fallback host rather than the main API host.
///
/// Requests to such a path are fallback attempts from the very first try, even though
/// `HTTPClient`'s own fallback walk never started, so they must not be treated as main-source
/// requests when picking a timeout or updating the per-host fail-fast memory.
var isFallbackHostPath: Bool { get }
/// Additional headers specific to this endpoint.
var additionalHeaders: HTTPRequest.Headers { get }
/// Provides endpoint-specific inputs for response signature verification.
var responseSignatureContextProvider: ResponseSignatureContextProvider { get }
/// Whether this path corresponds to an IAM request
var isIAMPath: Bool { get }
}
/// Provides endpoint-specific inputs for backend response signature verification.
///
/// Most endpoints use the raw response body and request body. Specialized endpoints can override
/// either input.
protocol ResponseSignatureContextProvider {
/// Returns the response bytes that should be verified against the backend signature.
///
/// This may throw when deriving the signed payload requires validating response structure first.
func responsePayloadForSignature(from body: Data?, statusCode: HTTPStatusCode) throws -> Data?
/// Returns the request body component to include in signature parameters, if any.
func requestBodyForSignature(for request: HTTPRequest) -> HTTPRequestBody?
}
struct DefaultResponseSignatureContextProvider: ResponseSignatureContextProvider {
func responsePayloadForSignature(from body: Data?, statusCode: HTTPStatusCode) throws -> Data? {
return body
}
func requestBodyForSignature(for request: HTTPRequest) -> HTTPRequestBody? {
return request.requestBody
}
}
extension HTTPRequestPath {
var fallbackUrls: [URL] {
return []
}
var supportsFallbackURLs: Bool {
!fallbackUrls.isEmpty
}
var fallbackRelativePath: String? {
return nil
}
var relativeIAMPath: String {
return self.relativePath
}
var usesAPISources: Bool {
return false
}
var isFallbackHostPath: Bool {
return false
}
var additionalHeaders: HTTPRequest.Headers {
return [:]
}
var responseSignatureContextProvider: ResponseSignatureContextProvider {
return DefaultResponseSignatureContextProvider()
}
var isIAMPath: Bool {
return false
}
func url(proxyURL: URL? = nil,
apiSourceURL: URL? = nil,
fallbackUrlIndex: Int? = nil,
preferIAMPath: Bool) -> URL? {
let baseURL: URL
if let proxyURL {
// When a Proxy URL is set, we don't support API sources or fallback URLs
guard fallbackUrlIndex == nil, apiSourceURL == nil else {
// This is to safe guard against a potential infinite loop if the caller mistakenly
// passes a proxyURL together with an apiSourceURL or a fallbackUrlIndex.
return nil
}
baseURL = proxyURL
} else if let fallbackUrlIndex {
return self.fallbackUrls[safe: fallbackUrlIndex]
} else if let apiSourceURL {
baseURL = apiSourceURL
} else {
baseURL = Self.serverHostURL
}
let path = preferIAMPath ? self.relativeIAMPath : self.relativePath
return URL(string: path, 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 spendVirtualCurrencies
case postRedeemWebPurchase
case postExternalPurchaseToken
case postCreateTicket
case isPurchaseAllowedByRestoreBehavior(appUserID: String)
case rewardVerificationStatus(appUserID: String, clientTransactionID: String, adUnitID: String?)
case remoteConfig(domain: String)
case tokenLogin
case tokenRefresh
case tokenLogOut
}
enum FallbackPath: Hashable {
case remoteConfig(domain: 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>)
case postHostedCheckout
case getHostedCheckoutStatus(operationSessionID: String, appUserID: String)
}
enum AdPath: Hashable {
case postEvents
}
}
extension HTTPRequest.Path: HTTPRequestPath {
static var serverHostURL: URL {
SystemInfo.apiBaseURL
}
var usesAPISources: Bool {
return true
}
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,
.postExternalPurchaseToken,
.getProductEntitlementMapping,
.getCustomerCenterConfig,
.getVirtualCurrencies,
.spendVirtualCurrencies,
.appHealthReport,
.postCreateTicket,
.isPurchaseAllowedByRestoreBehavior,
.rewardVerificationStatus,
.remoteConfig,
.tokenLogin,
.tokenRefresh,
.tokenLogOut:
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,
.spendVirtualCurrencies,
.appHealthReport,
.postCreateTicket,
.isPurchaseAllowedByRestoreBehavior,
.rewardVerificationStatus,
.tokenLogin,
.tokenRefresh,
.tokenLogOut:
return true
case .remoteConfig,
.health,
.appHealthReportAvailability,
.postExternalPurchaseToken:
return false
}
}
var supportsSignatureVerification: Bool {
switch self {
case .getCustomerInfo,
.logIn,
.postReceiptData,
.postRedeemWebPurchase,
.health,
.getOfferings,
.getProductEntitlementMapping,
.getVirtualCurrencies,
.spendVirtualCurrencies,
.appHealthReport,
.appHealthReportAvailability,
.isPurchaseAllowedByRestoreBehavior,
.remoteConfig,
.rewardVerificationStatus:
return true
case .getIntroEligibility,
.postSubscriberAttributes,
.postAttributionData,
.postAdServicesToken,
.postOfferForSigning,
.postExternalPurchaseToken,
.getCustomerCenterConfig,
.postCreateTicket,
.tokenLogin,
.tokenRefresh,
.tokenLogOut:
return false
}
}
var needsNonceForSigning: Bool {
switch self {
case .getCustomerInfo,
.logIn,
.postReceiptData,
.postRedeemWebPurchase,
.getVirtualCurrencies,
.spendVirtualCurrencies,
.health,
.appHealthReportAvailability,
.isPurchaseAllowedByRestoreBehavior,
.remoteConfig,
.rewardVerificationStatus,
.tokenLogin,
.tokenRefresh,
.tokenLogOut:
return true
case .getOfferings,
.getIntroEligibility,
.postSubscriberAttributes,
.postAttributionData,
.postAdServicesToken,
.postOfferForSigning,
.postExternalPurchaseToken,
.getProductEntitlementMapping,
.getCustomerCenterConfig,
.appHealthReport,
.postCreateTicket:
return false
}
}
var responseSignatureContextProvider: ResponseSignatureContextProvider {
switch self {
case .remoteConfig:
return RemoteConfigSignatureContextProvider()
default:
return DefaultResponseSignatureContextProvider()
}
}
var relativePath: String {
let component = self.pathComponent
if component.hasPrefix("/") { return component }
return "/v1/\(component)"
}
var relativeIAMPath: String {
let component = self.iamPathComponent
if component.hasPrefix("/") { return component }
return "/v1/\(component)"
}
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 .postExternalPurchaseToken:
return "external_purchase_tokens"
case let .getVirtualCurrencies(appUserID):
return "subscribers/\(Self.escape(appUserID))/virtual_currencies"
case .spendVirtualCurrencies:
return "customer/virtual_currencies/spend"
case .postCreateTicket:
return "customercenter/support/create-ticket"
case let .isPurchaseAllowedByRestoreBehavior(appUserID):
return "subscribers/\(Self.escape(appUserID))/restore/eligibility"
case let .rewardVerificationStatus(appUserID, clientTransactionID, adUnitID):
let path = "subscribers/\(Self.escape(appUserID))/ads/reward_verifications/"
+ Self.escape(clientTransactionID)
guard let adUnitID = adUnitID?.notEmptyOrWhitespaces,
let encodedAdUnitID = adUnitID.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
else {
return path
}
return "\(path)?ad_unit_id=\(encodedAdUnitID)"
case let .remoteConfig(domain):
return "config/\(Self.escape(domain))"
case .tokenLogin:
return "/auth/login"
case .tokenRefresh:
return "/auth/token"
case .tokenLogOut:
return "/auth/revoke"
}
}
var iamPathComponent: String {
// NOTE: cases below that "return self.pathComponent" are indicating
// that the corresponding server paths have not yet been migrated to
// support access token authorization
switch self {
case .getCustomerInfo:
return "customer"
case .getOfferings:
return "customer/offerings"
case .getIntroEligibility:
return "customer/intro_eligibility"
case .logIn:
assertionFailure("The .login endpoint is not allowed when IAM is enabled")
Logger.error("The .login endpoint is not allowed when IAM is enabled")
return self.pathComponent
case .postAttributionData:
return "customer/attribution"
case .postOfferForSigning:
return "offers"
case .postReceiptData:
return "receipts"
case .postSubscriberAttributes:
return "customer/attributes"
case .postAdServicesToken:
return "customer/adservices_attribution"
case .health:
return self.pathComponent
case .appHealthReport:
return "customer/health_report"
case .appHealthReportAvailability:
return self.pathComponent
case .getProductEntitlementMapping:
return "product_entitlement_mapping"
case .getCustomerCenterConfig:
return "customer/customercenter"
case .getVirtualCurrencies:
return "customer/virtual_currencies"
case .spendVirtualCurrencies:
return "customer/virtual_currencies/spend"
case .postRedeemWebPurchase:
return self.pathComponent
case .postExternalPurchaseToken:
return self.pathComponent
case .postCreateTicket:
return self.pathComponent
case .isPurchaseAllowedByRestoreBehavior:
return "customer/restore/eligibility"
case .rewardVerificationStatus:
return self.pathComponent
case .remoteConfig:
return self.pathComponent
case .tokenLogin:
return "/auth/login"
case .tokenRefresh:
return "/auth/token"
case .tokenLogOut:
return "/auth/revoke"
}
}
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 .postExternalPurchaseToken:
return "post_external_purchase_token"
case .appHealthReport:
return "get_app_health_report"
case .getVirtualCurrencies:
return "get_virtual_currencies"
case .spendVirtualCurrencies:
return "spend_virtual_currencies"
case .appHealthReportAvailability:
return "get_app_health_report_availability"
case .postCreateTicket:
return "post_create_ticket"
case .isPurchaseAllowedByRestoreBehavior:
return "post_restore_eligibility"
case .rewardVerificationStatus:
return "get_reward_verification_status"
case .remoteConfig:
return "remote_config"
case .tokenLogin:
return "token_login"
case .tokenRefresh:
return "token_refresh"
case .tokenLogOut:
return "token_logout"
}
}
var additionalHeaders: HTTPRequest.Headers {
switch self {
case .remoteConfig:
return [
HTTPClient.RequestHeader.accept.rawValue: HTTPClient.rcContainerFormatAcceptHeaderValue,
HTTPClient.RequestHeader.acceptRCElementEncoding.rawValue:
HTTPClient.rcContainerFormatElementEncodingHeaderValue
]
default:
return [:]
}
}
var isIAMPath: Bool {
switch self {
case .tokenLogin, .tokenRefresh, .tokenLogOut:
return true
default: return false
}
}
private static func escape(_ appUserID: String) -> String {
return appUserID.trimmedAndEscaped
}
}
extension HTTPRequest.FallbackPath: HTTPRequestPath {
// swiftlint:disable:next force_unwrapping
static let serverHostURL = URL(string: "https://api-production.8-lives-cat.io")!
var isFallbackHostPath: Bool {
switch self {
case .remoteConfig:
return true
}
}
var authenticated: Bool {
switch self {
case .remoteConfig:
return true
}
}
var shouldSendEtag: Bool {
switch self {
case .remoteConfig:
return true
}
}
var supportsSignatureVerification: Bool {
switch self {
case .remoteConfig:
return true
}
}
var needsNonceForSigning: Bool {
switch self {
case .remoteConfig:
return false
}
}
var responseSignatureContextProvider: ResponseSignatureContextProvider {
switch self {
case .remoteConfig:
return FallbackSignatureContextProvider()
}
}
var relativePath: String {
switch self {
case let .remoteConfig(domain):
return "/v1/config/\(domain.trimmedAndEscaped)"
}
}
var name: String {
switch self {
case .remoteConfig:
return "remote_config_fallback"
}
}
var additionalHeaders: HTTPRequest.Headers {
switch self {
case .remoteConfig:
return [:]
}
}
}