Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ public object CheckoutProtocol {
encode = ::encodeWindowOpenResult,
)

internal val defaultDelegations: List<EmbeddedCheckoutProtocol.Delegation> = listOf(
EmbeddedCheckoutProtocol.Delegation(windowOpen.delegation),
)

internal val supportedProtocolMethods: Set<String> = setOf(
EmbeddedCheckoutProtocol.Event.ready,
start.method,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,12 @@ internal fun String.redactedUrlForLogging(): String = toUri().redactedForLogging
* Applies Checkout Kit's curated Embedded Checkout Protocol query parameters.
*/
internal fun String.appendEcpParams(): String =
EmbeddedCheckoutProtocol.url(this, delegations = listOf(CheckoutProtocol.windowOpen.delegation))
EmbeddedCheckoutProtocol.url(
this,
options = EmbeddedCheckoutProtocol.Options(
delegations = CheckoutProtocol.defaultDelegations,
),
)

private val CONFIRMATION_PATH_REGEX = Regex(pattern = "^(thank[-_]+you)$", option = RegexOption.IGNORE_CASE)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,19 +294,19 @@ class CheckoutWebViewTest {

@Test
fun `present discards cached checkout view for mismatched query params`() {
CheckoutWebView.preload("https://checkout.shopify.com/cart/123?ec_auth=first", activity)
CheckoutWebView.preload("https://checkout.shopify.com/cart/123?cart=first", activity)
ShadowLooper.shadowMainLooper().runToEndOfTasks()
val cachedView = CheckoutWebView.cachedPreloadViewForTesting()!!

val presentedView = CheckoutWebView.checkoutViewFor(
"https://checkout.shopify.com/cart/123?ec_auth=second",
"https://checkout.shopify.com/cart/123?cart=second",
activity,
)
ShadowLooper.shadowMainLooper().runToEndOfTasks()

assertThat(presentedView).isNotSameAs(cachedView)
assertThat(shadowOf(cachedView).wasDestroyCalled()).isTrue()
assertThat(shadowOf(presentedView).lastLoadedUrl).contains("ec_auth=second")
assertThat(shadowOf(presentedView).lastLoadedUrl).contains("cart=second")
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,13 @@ class UriExtensionsTest {
}

@Test
fun `appendEcpParams replaces caller-supplied ECP params`() {
val url = "$BASE_URL?ec_version=override&ec_delegate=custom"
fun `appendEcpParams replaces caller-supplied supported ECP params and strips unsupported ECP params`() {
val url = "$BASE_URL?ec_version=override&ec_delegate=custom&ec_auth=token&ec_color_scheme=dark"
val result = url.appendEcpParams().toUri()
assertThat(result.getQueryParameters("ec_version")).containsExactly(CheckoutProtocol.SPEC_VERSION)
assertThat(result.getQueryParameters("ec_delegate")).containsExactly("window.open")
assertThat(result.getQueryParameters("ec_auth")).isEmpty()
assertThat(result.getQueryParameters("ec_color_scheme")).isEmpty()
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -632,8 +632,25 @@ public final class com/shopify/ucp/embedded/checkout/EmbeddedCheckoutProtocol {
public final fun getStart ()Lcom/shopify/ucp/embedded/checkout/NotificationDescriptor;
public final fun getTotalsChange ()Lcom/shopify/ucp/embedded/checkout/NotificationDescriptor;
public final fun getWindowOpen ()Lcom/shopify/ucp/embedded/checkout/DelegationDescriptor;
public final fun url (Ljava/lang/String;Ljava/util/List;)Ljava/lang/String;
public static synthetic fun url$default (Lcom/shopify/ucp/embedded/checkout/EmbeddedCheckoutProtocol;Ljava/lang/String;Ljava/util/List;ILjava/lang/Object;)Ljava/lang/String;
public final fun url (Ljava/lang/String;Lcom/shopify/ucp/embedded/checkout/EmbeddedCheckoutProtocol$Options;)Ljava/lang/String;
public static synthetic fun url$default (Lcom/shopify/ucp/embedded/checkout/EmbeddedCheckoutProtocol;Ljava/lang/String;Lcom/shopify/ucp/embedded/checkout/EmbeddedCheckoutProtocol$Options;ILjava/lang/Object;)Ljava/lang/String;
}

public final class com/shopify/ucp/embedded/checkout/EmbeddedCheckoutProtocol$Delegation {
public static final field Companion Lcom/shopify/ucp/embedded/checkout/EmbeddedCheckoutProtocol$Delegation$Companion;
public fun <init> (Ljava/lang/String;)V
public fun equals (Ljava/lang/Object;)Z
public final fun getRawValue ()Ljava/lang/String;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final class com/shopify/ucp/embedded/checkout/EmbeddedCheckoutProtocol$Delegation$Companion {
public final fun getAll ()Ljava/util/List;
public final fun getFulfillmentAddressChange ()Lcom/shopify/ucp/embedded/checkout/EmbeddedCheckoutProtocol$Delegation;
public final fun getPaymentCredential ()Lcom/shopify/ucp/embedded/checkout/EmbeddedCheckoutProtocol$Delegation;
public final fun getPaymentInstrumentsChange ()Lcom/shopify/ucp/embedded/checkout/EmbeddedCheckoutProtocol$Delegation;
public final fun getWindowOpen ()Lcom/shopify/ucp/embedded/checkout/EmbeddedCheckoutProtocol$Delegation;
}

public final class com/shopify/ucp/embedded/checkout/EmbeddedCheckoutProtocol$Event {
Expand All @@ -656,6 +673,15 @@ public final class com/shopify/ucp/embedded/checkout/EmbeddedCheckoutProtocol$Ev
public final fun getAll ()Ljava/util/Set;
}

public final class com/shopify/ucp/embedded/checkout/EmbeddedCheckoutProtocol$Options {
public fun <init> ()V
public fun <init> (Ljava/util/List;Ljava/lang/String;Ljava/lang/String;)V
public synthetic fun <init> (Ljava/util/List;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getAuth ()Ljava/lang/String;
public final fun getColorScheme ()Ljava/lang/String;
public final fun getDelegations ()Ljava/util/List;
}

public final class com/shopify/ucp/embedded/checkout/EmbeddedCheckoutProtocolKt {
public static final fun decodeProtocolRequest (Ljava/lang/String;)Lcom/shopify/ucp/embedded/checkout/EcpRequest;
public static final fun encodeJsonRpcError (Lkotlinx/serialization/json/JsonElement;ILjava/lang/String;)Ljava/lang/String;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,47 @@ import java.nio.charset.StandardCharsets
public object EmbeddedCheckoutProtocol {
public const val SPEC_VERSION: String = "2026-04-08"

/**
* Options controlling the query parameters appended to a checkout URL when
* initiating the Embedded Checkout Protocol handshake.
*/
public class Options(
public val delegations: List<Delegation> = emptyList(),
public val colorScheme: String? = null,
public val auth: String? = null,
)

/**
* Delegations the host can request from the business, as declared by the
* service in `x-delegations`. String-backed and extensible: a host may
* advertise a delegation this build predates, so unknown values round-trip
* intact.
*/
public class Delegation(
public val rawValue: String,
) {
override fun equals(other: Any?): Boolean =
other is Delegation && rawValue == other.rawValue

override fun hashCode(): Int = rawValue.hashCode()

override fun toString(): String = rawValue

public companion object {
public val paymentInstrumentsChange: Delegation = Delegation("payment.instruments_change")
public val paymentCredential: Delegation = Delegation("payment.credential")
public val fulfillmentAddressChange: Delegation = Delegation("fulfillment.address_change")
public val windowOpen: Delegation = Delegation("window.open")

public val all: List<Delegation> = listOf(
paymentInstrumentsChange,
paymentCredential,
fulfillmentAddressChange,
windowOpen,
)
}
}

public val start: NotificationDescriptor<Checkout>
get() = embeddedCheckoutStartDescriptor

Expand All @@ -42,12 +83,12 @@ public object EmbeddedCheckoutProtocol {

/**
* Returns the given checkout URL with the query parameters required to
* initiate the Embedded Checkout Protocol handshake using ec_version and
* ec_delegate query parameters.
* initiate the Embedded Checkout Protocol handshake using ec_version,
* ec_delegate, ec_auth, and ec_color_scheme query parameters.
*/
public fun url(
url: String,
delegations: List<String> = emptyList(),
options: Options = Options(),
): String = runCatching {
val uri = URI(url)
if (uri.isOpaque) return@runCatching url
Expand All @@ -60,8 +101,14 @@ public object EmbeddedCheckoutProtocol {
.toMutableList()

queryParameters += "$EC_VERSION_PARAM=$SPEC_VERSION"
if (delegations.isNotEmpty()) {
queryParameters += "$EC_DELEGATE_PARAM=${delegations.joinToString(",").encodeQueryComponent()}"
if (options.delegations.isNotEmpty()) {
queryParameters += "$EC_DELEGATE_PARAM=${options.delegations.joinToString(",") { it.rawValue }.encodeQueryComponent()}"
}
options.auth?.let { auth ->
queryParameters += "$EC_AUTH_PARAM=${auth.encodeQueryComponent()}"
}
options.colorScheme?.let { colorScheme ->
queryParameters += "$EC_COLOR_SCHEME_PARAM=${colorScheme.encodeQueryComponent()}"
}

uri.withRawQuery(queryParameters.joinToString("&"))
Expand Down Expand Up @@ -103,10 +150,17 @@ public object EmbeddedCheckoutProtocol {
)
}

private val PROTOCOL_QUERY_PARAMS: Set<String> = setOf(EC_VERSION_PARAM, EC_DELEGATE_PARAM)
private val PROTOCOL_QUERY_PARAMS: Set<String> = setOf(
EC_VERSION_PARAM,
EC_DELEGATE_PARAM,
EC_AUTH_PARAM,
EC_COLOR_SCHEME_PARAM,
)

private const val EC_VERSION_PARAM: String = "ec_version"
private const val EC_DELEGATE_PARAM: String = "ec_delegate"
private const val EC_AUTH_PARAM: String = "ec_auth"
private const val EC_COLOR_SCHEME_PARAM: String = "ec_color_scheme"

private fun URI.withRawQuery(rawQuery: String): String = buildString {
scheme?.let { append(it).append(":") }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,27 +49,67 @@ class EmbeddedCheckoutProtocolTest {
assertThat(EmbeddedCheckoutProtocol.Event.all).doesNotContain("ep.cart.ready")
}

@Test
fun `delegation catalog exposes embedded checkout delegations`() {
assertThat(EmbeddedCheckoutProtocol.Delegation.all).containsExactlyInAnyOrder(
EmbeddedCheckoutProtocol.Delegation("payment.instruments_change"),
EmbeddedCheckoutProtocol.Delegation("payment.credential"),
EmbeddedCheckoutProtocol.Delegation("fulfillment.address_change"),
EmbeddedCheckoutProtocol.Delegation("window.open"),
)
assertThat(EmbeddedCheckoutProtocol.Delegation.windowOpen.rawValue).isEqualTo("window.open")
}

@Test
fun `delegation can represent unknown values`() {
val delegation = EmbeddedCheckoutProtocol.Delegation("custom.delegation")

assertThat(delegation.rawValue).isEqualTo("custom.delegation")
}

@Test
fun `url appends ec version and omits delegation by default`() {
val result = EmbeddedCheckoutProtocol.url(BASE_URL)
val params = queryParams(result)

assertThat(params["ec_version"]).containsExactly(EmbeddedCheckoutProtocol.SPEC_VERSION)
assertThat(params).doesNotContainKey("ec_delegate")
assertThat(params).doesNotContainKey("ec_auth")
assertThat(params).doesNotContainKey("ec_color_scheme")
}

@Test
fun `url appends supplied delegations`() {
val result = EmbeddedCheckoutProtocol.url(
BASE_URL,
delegations = listOf("window.open", "payment.credential"),
options = EmbeddedCheckoutProtocol.Options(
delegations = listOf(
EmbeddedCheckoutProtocol.Delegation.windowOpen,
EmbeddedCheckoutProtocol.Delegation.paymentCredential,
),
),
)
val params = queryParams(result)

assertThat(params["ec_version"]).containsExactly(EmbeddedCheckoutProtocol.SPEC_VERSION)
assertThat(params["ec_delegate"]).containsExactly("window.open,payment.credential")
}

@Test
fun `url appends supplied auth and color scheme`() {
val result = EmbeddedCheckoutProtocol.url(
BASE_URL,
options = EmbeddedCheckoutProtocol.Options(
auth = "token",
colorScheme = "dark",
),
)
val params = queryParams(result)

assertThat(params["ec_auth"]).containsExactly("token")
assertThat(params["ec_color_scheme"]).containsExactly("dark")
}

@Test
fun `url preserves existing query parameters`() {
val result = EmbeddedCheckoutProtocol.url("$BASE_URL?key=cart_token&utm_source=email")
Expand All @@ -82,22 +122,34 @@ class EmbeddedCheckoutProtocolTest {

@Test
fun `url replaces caller supplied protocol parameters and is idempotent`() {
val callerSupplied = "$BASE_URL?ec_version=override&ec_delegate=custom"
val once = EmbeddedCheckoutProtocol.url(callerSupplied, delegations = listOf("window.open"))
val twice = EmbeddedCheckoutProtocol.url(once, delegations = listOf("window.open"))
val callerSupplied = "$BASE_URL?ec_version=override&ec_delegate=custom&ec_auth=stale&ec_color_scheme=light"
val options = EmbeddedCheckoutProtocol.Options(
delegations = listOf(EmbeddedCheckoutProtocol.Delegation.windowOpen),
auth = "token",
colorScheme = "dark",
)
val once = EmbeddedCheckoutProtocol.url(callerSupplied, options = options)
val twice = EmbeddedCheckoutProtocol.url(once, options = options)
val params = queryParams(twice)

assertThat(params["ec_version"]).containsExactly(EmbeddedCheckoutProtocol.SPEC_VERSION)
assertThat(params["ec_delegate"]).containsExactly("window.open")
assertThat(params["ec_auth"]).containsExactly("token")
assertThat(params["ec_color_scheme"]).containsExactly("dark")
}

@Test
fun `url removes caller supplied delegation when delegations are empty`() {
val result = EmbeddedCheckoutProtocol.url("$BASE_URL?ec_delegate=custom", delegations = emptyList())
fun `url removes caller supplied optional protocol params when options omit them`() {
val result = EmbeddedCheckoutProtocol.url(
"$BASE_URL?ec_delegate=custom&ec_auth=stale&ec_color_scheme=dark",
options = EmbeddedCheckoutProtocol.Options(delegations = emptyList()),
)
val params = queryParams(result)

assertThat(params["ec_version"]).containsExactly(EmbeddedCheckoutProtocol.SPEC_VERSION)
assertThat(params).doesNotContainKey("ec_delegate")
assertThat(params).doesNotContainKey("ec_auth")
assertThat(params).doesNotContainKey("ec_color_scheme")
}

@Test
Expand Down
Loading
Loading