diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebView.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebView.kt index 9c6349c22..4c7d365c6 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebView.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebView.kt @@ -68,7 +68,7 @@ internal class CheckoutWebView(context: Context, attributeSet: AttributeSet? = n loadComplete = false isPreloadRequest = isPreload Handler(Looper.getMainLooper()).post { - val ecpUrl = url.appendEcpParams(specVersion = CheckoutProtocol.SPEC_VERSION) + val ecpUrl = url.appendEcpParams() val headers = if (isPreload) { mutableMapOf(SHOPIFY_PURPOSE_HEADER to PREFETCH_PURPOSE) } else { diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/PreloadCache.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/PreloadCache.kt index ad67c0740..1b5f7df77 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/PreloadCache.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/PreloadCache.kt @@ -3,7 +3,7 @@ package com.shopify.checkoutkit internal data class PreloadKey(val url: String) { companion object { fun forUrl(url: String): PreloadKey { - return PreloadKey(url.appendEcpParams(specVersion = CheckoutProtocol.SPEC_VERSION)) + return PreloadKey(url.appendEcpParams()) } } } 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 0f4829df3..a75e51c2a 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 @@ -2,6 +2,7 @@ package com.shopify.checkoutkit import android.net.Uri import androidx.core.net.toUri +import com.shopify.ucp.embedded.checkout.EmbeddedCheckoutProtocol internal fun Uri?.isWebLink(): Boolean = setOf(Scheme.HTTP, Scheme.HTTPS).contains(this?.scheme) internal fun Uri?.isMailtoLink(): Boolean = this?.scheme == Scheme.MAILTO @@ -26,34 +27,13 @@ internal fun Uri?.redactedForLogging(): String? = when { internal fun String.redactedUrlForLogging(): String = toUri().redactedForLogging().orEmpty() /** - * Applies Embedded Checkout Protocol query parameters to a checkout URL, replacing - * any existing SDK-owned protocol params. Idempotent on re-call. - * - * - `ec_version` — the ECP spec version the SDK speaks - * - `ec_delegate` — fixed to the supported window-open delegation so checkout delegates link opens to the bridge + * Applies Checkout Kit's curated Embedded Checkout Protocol query parameters. */ -internal fun String.appendEcpParams(specVersion: String): String { - val uri = this.toUri() - val builder = uri.buildUpon().clearQuery() - - uri.queryParameterNames - .filterNot { it == EC_VERSION_PARAM || it == EC_DELEGATE_PARAM } - .forEach { name -> - uri.getQueryParameters(name).forEach { value -> - builder.appendQueryParameter(name, value) - } - } - - builder.appendQueryParameter(EC_VERSION_PARAM, specVersion) - builder.appendQueryParameter(EC_DELEGATE_PARAM, CheckoutProtocol.windowOpen.delegation) - - return builder.build().toString() -} +internal fun String.appendEcpParams(): String = + EmbeddedCheckoutProtocol.url(this, delegations = listOf(CheckoutProtocol.windowOpen.delegation)) private val CONFIRMATION_PATH_REGEX = Regex(pattern = "^(thank[-_]+you)$", option = RegexOption.IGNORE_CASE) -private const val EC_VERSION_PARAM = "ec_version" -private const val EC_DELEGATE_PARAM = "ec_delegate" private const val REDACTED_QUERY_VALUE = "[REDACTED]" internal object Scheme { 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 16b9cd6c5..07c003033 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 @@ -63,15 +63,15 @@ class UriExtensionsTest { @Test fun `appendEcpParams adds ec_version and ec_delegate`() { - val result = BASE_URL.appendEcpParams(SPEC_VERSION).toUri() - assertThat(result.getQueryParameter("ec_version")).isEqualTo(SPEC_VERSION) + val result = BASE_URL.appendEcpParams().toUri() + assertThat(result.getQueryParameter("ec_version")).isEqualTo(CheckoutProtocol.SPEC_VERSION) assertThat(result.getQueryParameter("ec_delegate")).isEqualTo("window.open") } @Test fun `appendEcpParams is idempotent on re-call`() { - val once = BASE_URL.appendEcpParams(SPEC_VERSION) - val twice = once.appendEcpParams(SPEC_VERSION).toUri() + val once = BASE_URL.appendEcpParams() + val twice = once.appendEcpParams().toUri() assertThat(twice.getQueryParameters("ec_version")).hasSize(1) assertThat(twice.getQueryParameters("ec_delegate")).hasSize(1) } @@ -79,17 +79,17 @@ class UriExtensionsTest { @Test fun `appendEcpParams preserves existing query parameters`() { val url = "$BASE_URL?key=cart_token&utm_source=email" - val result = url.appendEcpParams(SPEC_VERSION).toUri() + val result = url.appendEcpParams().toUri() assertThat(result.getQueryParameter("key")).isEqualTo("cart_token") assertThat(result.getQueryParameter("utm_source")).isEqualTo("email") - assertThat(result.getQueryParameter("ec_version")).isEqualTo(SPEC_VERSION) + assertThat(result.getQueryParameter("ec_version")).isEqualTo(CheckoutProtocol.SPEC_VERSION) } @Test fun `appendEcpParams replaces caller-supplied ECP params`() { val url = "$BASE_URL?ec_version=override&ec_delegate=custom" - val result = url.appendEcpParams(SPEC_VERSION).toUri() - assertThat(result.getQueryParameters("ec_version")).containsExactly(SPEC_VERSION) + val result = url.appendEcpParams().toUri() + assertThat(result.getQueryParameters("ec_version")).containsExactly(CheckoutProtocol.SPEC_VERSION) assertThat(result.getQueryParameters("ec_delegate")).containsExactly("window.open") } @@ -113,6 +113,5 @@ class UriExtensionsTest { private companion object { private const val BASE_URL = "https://shop.com/cart/c/abc" - private const val SPEC_VERSION = "2026-04-08" } } 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 a44931af8..23029c62d 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,6 +632,8 @@ 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 class com/shopify/ucp/embedded/checkout/EmbeddedCheckoutProtocol$Event { 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 d1ee19175..d54af7246 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 @@ -3,6 +3,11 @@ package com.shopify.ucp.embedded.checkout +import java.net.URI +import java.net.URLDecoder +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + /** * Low-level Embedded Checkout Protocol constants, raw method catalog, and * typed spec descriptors. @@ -35,6 +40,33 @@ public object EmbeddedCheckoutProtocol { public val windowOpen: DelegationDescriptor get() = embeddedCheckoutWindowOpenDescriptor + /** + * 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. + */ + public fun url( + url: String, + delegations: List = emptyList(), + ): String = runCatching { + val uri = URI(url) + if (uri.isOpaque) return@runCatching url + + val queryParameters = uri.rawQuery + ?.split("&") + ?.filter { it.isNotEmpty() } + ?.filterNot { it.queryParameterName() in PROTOCOL_QUERY_PARAMS } + .orEmpty() + .toMutableList() + + queryParameters += "$EC_VERSION_PARAM=$SPEC_VERSION" + if (delegations.isNotEmpty()) { + queryParameters += "$EC_DELEGATE_PARAM=${delegations.joinToString(",").encodeQueryComponent()}" + } + + uri.withRawQuery(queryParameters.joinToString("&")) + }.getOrElse { url } + public object Event { public const val ready: String = "ec.ready" public const val auth: String = "ec.auth" @@ -70,4 +102,28 @@ public object EmbeddedCheckoutProtocol { fulfillmentAddressChangeRequest, ) } + + private val PROTOCOL_QUERY_PARAMS: Set = setOf(EC_VERSION_PARAM, EC_DELEGATE_PARAM) + + private const val EC_VERSION_PARAM: String = "ec_version" + private const val EC_DELEGATE_PARAM: String = "ec_delegate" + + private fun URI.withRawQuery(rawQuery: String): String = buildString { + scheme?.let { append(it).append(":") } + rawAuthority?.let { append("//").append(it) } + append(rawPath.orEmpty()) + if (rawQuery.isNotEmpty()) append("?").append(rawQuery) + rawFragment?.let { append("#").append(it) } + } + + private fun String.queryParameterName(): String = + substringBefore("=").decodeQueryComponent() + + private fun String.decodeQueryComponent(): String = + runCatching { URLDecoder.decode(this, StandardCharsets.UTF_8.name()) }.getOrDefault(this) + + private fun String.encodeQueryComponent(): String = + URLEncoder.encode(this, StandardCharsets.UTF_8.name()) + .replace("+", "%20") + .replace("%2C", ",") } 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 f613a1f56..c446b107a 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 @@ -8,6 +8,9 @@ import kotlinx.serialization.json.JsonPrimitive import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.Test +import java.net.URI +import java.net.URLDecoder +import java.nio.charset.StandardCharsets class EmbeddedCheckoutProtocolTest { @Test @@ -46,6 +49,57 @@ class EmbeddedCheckoutProtocolTest { assertThat(EmbeddedCheckoutProtocol.Event.all).doesNotContain("ep.cart.ready") } + @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") + } + + @Test + fun `url appends supplied delegations`() { + val result = EmbeddedCheckoutProtocol.url( + BASE_URL, + delegations = listOf("window.open", "payment.credential"), + ) + val params = queryParams(result) + + assertThat(params["ec_version"]).containsExactly(EmbeddedCheckoutProtocol.SPEC_VERSION) + assertThat(params["ec_delegate"]).containsExactly("window.open,payment.credential") + } + + @Test + fun `url preserves existing query parameters`() { + val result = EmbeddedCheckoutProtocol.url("$BASE_URL?key=cart_token&utm_source=email") + val params = queryParams(result) + + assertThat(params["key"]).containsExactly("cart_token") + assertThat(params["utm_source"]).containsExactly("email") + assertThat(params["ec_version"]).containsExactly(EmbeddedCheckoutProtocol.SPEC_VERSION) + } + + @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 params = queryParams(twice) + + assertThat(params["ec_version"]).containsExactly(EmbeddedCheckoutProtocol.SPEC_VERSION) + assertThat(params["ec_delegate"]).containsExactly("window.open") + } + + @Test + fun `url removes caller supplied delegation when delegations are empty`() { + val result = EmbeddedCheckoutProtocol.url("$BASE_URL?ec_delegate=custom", delegations = emptyList()) + val params = queryParams(result) + + assertThat(params["ec_version"]).containsExactly(EmbeddedCheckoutProtocol.SPEC_VERSION) + assertThat(params).doesNotContainKey("ec_delegate") + } + @Test fun `notification descriptor exposes method identity`() { val descriptor = NotificationDescriptor(EmbeddedCheckoutProtocol.Event.start) @@ -208,6 +262,24 @@ class EmbeddedCheckoutProtocolTest { assertThat(jsonRpcRequestId(JsonPrimitive(true))).isNull() } + private fun queryParams(url: String): Map> = + URI(url).rawQuery + ?.split("&") + ?.filter { it.isNotEmpty() } + ?.map { it.substringBefore("=") to it.substringAfter("=", "") } + ?.groupBy( + keySelector = { it.first.decodeQueryComponent() }, + valueTransform = { it.second.decodeQueryComponent() }, + ) + .orEmpty() + + private fun String.decodeQueryComponent(): String = + URLDecoder.decode(this, StandardCharsets.UTF_8.name()) + + private companion object { + private const val BASE_URL = "https://shop.com/cart/c/abc" + } + @Serializable private data class TestRequestParams(val url: String) diff --git a/protocol/scripts/generate_kotlin_catalog.mjs b/protocol/scripts/generate_kotlin_catalog.mjs index cb8934462..10542a7ad 100644 --- a/protocol/scripts/generate_kotlin_catalog.mjs +++ b/protocol/scripts/generate_kotlin_catalog.mjs @@ -93,6 +93,11 @@ function renderModule(events) { package com.shopify.ucp.embedded.checkout +import java.net.URI +import java.net.URLDecoder +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + /** * Low-level Embedded Checkout Protocol constants, raw method catalog, and * typed spec descriptors. @@ -125,6 +130,33 @@ public object EmbeddedCheckoutProtocol { public val windowOpen: DelegationDescriptor get() = embeddedCheckoutWindowOpenDescriptor + /** + * 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. + */ + public fun url( + url: String, + delegations: List = emptyList(), + ): String = runCatching { + val uri = URI(url) + if (uri.isOpaque) return@runCatching url + + val queryParameters = uri.rawQuery + ?.split("&") + ?.filter { it.isNotEmpty() } + ?.filterNot { it.queryParameterName() in PROTOCOL_QUERY_PARAMS } + .orEmpty() + .toMutableList() + + queryParameters += "$EC_VERSION_PARAM=$SPEC_VERSION" + if (delegations.isNotEmpty()) { + queryParameters += "$EC_DELEGATE_PARAM=\${delegations.joinToString(",").encodeQueryComponent()}" + } + + uri.withRawQuery(queryParameters.joinToString("&")) + }.getOrElse { url } + public object Event { ${events .map( @@ -136,6 +168,30 @@ ${events ${events.map(event => ` ${event.identifier},`).join("\n")} ) } + + private val PROTOCOL_QUERY_PARAMS: Set = setOf(EC_VERSION_PARAM, EC_DELEGATE_PARAM) + + private const val EC_VERSION_PARAM: String = "ec_version" + private const val EC_DELEGATE_PARAM: String = "ec_delegate" + + private fun URI.withRawQuery(rawQuery: String): String = buildString { + scheme?.let { append(it).append(":") } + rawAuthority?.let { append("//").append(it) } + append(rawPath.orEmpty()) + if (rawQuery.isNotEmpty()) append("?").append(rawQuery) + rawFragment?.let { append("#").append(it) } + } + + private fun String.queryParameterName(): String = + substringBefore("=").decodeQueryComponent() + + private fun String.decodeQueryComponent(): String = + runCatching { URLDecoder.decode(this, StandardCharsets.UTF_8.name()) }.getOrDefault(this) + + private fun String.encodeQueryComponent(): String = + URLEncoder.encode(this, StandardCharsets.UTF_8.name()) + .replace("+", "%20") + .replace("%2C", ",") } `; }