diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutProtocol.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutProtocol.kt index b6d05896..cfa386a6 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutProtocol.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutProtocol.kt @@ -41,6 +41,10 @@ public object CheckoutProtocol { encode = ::encodeWindowOpenResult, ) + internal val defaultDelegations: List = listOf( + EmbeddedCheckoutProtocol.Delegation(windowOpen.delegation), + ) + internal val supportedProtocolMethods: Set = setOf( EmbeddedCheckoutProtocol.Event.ready, start.method, diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/UriExtensions.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/UriExtensions.kt index a75e51c2..3060b54b 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/UriExtensions.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/UriExtensions.kt @@ -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) diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewTest.kt index 8c1cbe57..2953d850 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewTest.kt @@ -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 diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/UriExtensionsTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/UriExtensionsTest.kt index 07c00303..6421a957 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/UriExtensionsTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/UriExtensionsTest.kt @@ -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 diff --git a/protocol/languages/kotlin/embedded-checkout-protocol/api/embedded-checkout-protocol.api b/protocol/languages/kotlin/embedded-checkout-protocol/api/embedded-checkout-protocol.api index 23029c62..811f578c 100644 --- a/protocol/languages/kotlin/embedded-checkout-protocol/api/embedded-checkout-protocol.api +++ b/protocol/languages/kotlin/embedded-checkout-protocol/api/embedded-checkout-protocol.api @@ -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 (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 { @@ -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 ()V + public fun (Ljava/util/List;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (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; diff --git a/protocol/languages/kotlin/embedded-checkout-protocol/src/main/java/com/shopify/ucp/embedded/checkout/EmbeddedCheckoutProtocol.kt b/protocol/languages/kotlin/embedded-checkout-protocol/src/main/java/com/shopify/ucp/embedded/checkout/EmbeddedCheckoutProtocol.kt index d54af724..6e8e4db8 100644 --- a/protocol/languages/kotlin/embedded-checkout-protocol/src/main/java/com/shopify/ucp/embedded/checkout/EmbeddedCheckoutProtocol.kt +++ b/protocol/languages/kotlin/embedded-checkout-protocol/src/main/java/com/shopify/ucp/embedded/checkout/EmbeddedCheckoutProtocol.kt @@ -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 = 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 = listOf( + paymentInstrumentsChange, + paymentCredential, + fulfillmentAddressChange, + windowOpen, + ) + } + } + public val start: NotificationDescriptor get() = embeddedCheckoutStartDescriptor @@ -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 = emptyList(), + options: Options = Options(), ): String = runCatching { val uri = URI(url) if (uri.isOpaque) return@runCatching url @@ -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("&")) @@ -103,10 +150,17 @@ public object EmbeddedCheckoutProtocol { ) } - private val PROTOCOL_QUERY_PARAMS: Set = setOf(EC_VERSION_PARAM, EC_DELEGATE_PARAM) + private val PROTOCOL_QUERY_PARAMS: Set = 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(":") } diff --git a/protocol/languages/kotlin/embedded-checkout-protocol/src/test/java/com/shopify/ucp/embedded/checkout/EmbeddedCheckoutProtocolTest.kt b/protocol/languages/kotlin/embedded-checkout-protocol/src/test/java/com/shopify/ucp/embedded/checkout/EmbeddedCheckoutProtocolTest.kt index c446b107..acfb3de1 100644 --- a/protocol/languages/kotlin/embedded-checkout-protocol/src/test/java/com/shopify/ucp/embedded/checkout/EmbeddedCheckoutProtocolTest.kt +++ b/protocol/languages/kotlin/embedded-checkout-protocol/src/test/java/com/shopify/ucp/embedded/checkout/EmbeddedCheckoutProtocolTest.kt @@ -49,6 +49,24 @@ 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) @@ -56,13 +74,20 @@ class EmbeddedCheckoutProtocolTest { 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) @@ -70,6 +95,21 @@ class EmbeddedCheckoutProtocolTest { 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") @@ -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 diff --git a/protocol/scripts/generate_kotlin_catalog.mjs b/protocol/scripts/generate_kotlin_catalog.mjs index 10542a7a..810c2944 100644 --- a/protocol/scripts/generate_kotlin_catalog.mjs +++ b/protocol/scripts/generate_kotlin_catalog.mjs @@ -55,6 +55,16 @@ function methodNameToIdentifier(methodName) { .join(""); } +function delegationToIdentifier(delegation) { + return delegation + .split(/[._]/g) + .filter(Boolean) + .map((part, index) => + index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1), + ) + .join(""); +} + function collectEvents(openRpc, openRpcDir) { const events = []; @@ -87,7 +97,24 @@ function collectEvents(openRpc, openRpcDir) { return events; } -function renderModule(events) { +function collectDelegations(openRpc) { + const delegations = (openRpc["x-delegations"] ?? []).map(delegation => ({ + identifier: delegationToIdentifier(delegation), + value: delegation, + })); + + const seen = new Set(); + for (const delegation of delegations) { + if (seen.has(delegation.identifier)) { + throw new Error(`Duplicate delegation identifier: ${delegation.identifier}`); + } + seen.add(delegation.identifier); + } + + return delegations; +} + +function renderModule(events, delegations) { return `// This file is generated by protocol/scripts/generate_kotlin_catalog.mjs. // Do not edit directly. @@ -109,6 +136,45 @@ import java.nio.charset.StandardCharsets public object EmbeddedCheckoutProtocol { public const val SPEC_VERSION: String = "${specVersion}" + /** + * Options controlling the query parameters appended to a checkout URL when + * initiating the Embedded Checkout Protocol handshake. + */ + public class Options( + public val delegations: List = 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 { +${delegations + .map( + delegation => ` public val ${delegation.identifier}: Delegation = Delegation("${delegation.value}")`, + ) + .join("\n")} + + public val all: List = listOf( +${delegations.map(delegation => ` ${delegation.identifier},`).join("\n")} + ) + } + } + public val start: NotificationDescriptor get() = embeddedCheckoutStartDescriptor @@ -132,12 +198,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 = emptyList(), + options: Options = Options(), ): String = runCatching { val uri = URI(url) if (uri.isOpaque) return@runCatching url @@ -150,8 +216,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("&")) @@ -169,10 +241,17 @@ ${events.map(event => ` ${event.identifier},`).join("\n")} ) } - private val PROTOCOL_QUERY_PARAMS: Set = setOf(EC_VERSION_PARAM, EC_DELEGATE_PARAM) + private val PROTOCOL_QUERY_PARAMS: Set = 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(":") } @@ -199,7 +278,8 @@ ${events.map(event => ` ${event.identifier},`).join("\n")} function main() { const openRpc = JSON.parse(fs.readFileSync(openRpcPath, "utf8")); const events = collectEvents(openRpc, path.dirname(openRpcPath)); - const generated = renderModule(events); + const delegations = collectDelegations(openRpc); + const generated = renderModule(events, delegations); fs.mkdirSync(path.dirname(outputPath), {recursive: true}); fs.writeFileSync(outputPath, generated); console.log(`Generated ${outputPath}`);