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 @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,33 +63,33 @@ 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)
}

@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")
}

Expand All @@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -35,6 +40,33 @@ public object EmbeddedCheckoutProtocol {
public val windowOpen: DelegationDescriptor<WindowOpenRequest, WindowOpenResult>
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<String> = emptyList(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just a heads up that I've changed this to an options struct argument for Swift in this PR to add support for colorScheme and auth. It scales better than adding new arguments for every param added to the spec

): 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"
Expand Down Expand Up @@ -70,4 +102,28 @@ public object EmbeddedCheckoutProtocol {
fulfillmentAddressChangeRequest,
)
}

private val PROTOCOL_QUERY_PARAMS: Set<String> = 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", ",")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Checkout>(EmbeddedCheckoutProtocol.Event.start)
Expand Down Expand Up @@ -208,6 +262,24 @@ class EmbeddedCheckoutProtocolTest {
assertThat(jsonRpcRequestId(JsonPrimitive(true))).isNull()
}

private fun queryParams(url: String): Map<String, List<String>> =
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)

Expand Down
56 changes: 56 additions & 0 deletions protocol/scripts/generate_kotlin_catalog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -125,6 +130,33 @@ public object EmbeddedCheckoutProtocol {
public val windowOpen: DelegationDescriptor<WindowOpenRequest, WindowOpenResult>
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<String> = 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(
Expand All @@ -136,6 +168,30 @@ ${events
${events.map(event => ` ${event.identifier},`).join("\n")}
)
}

private val PROTOCOL_QUERY_PARAMS: Set<String> = 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", ",")
}
`;
}
Expand Down
Loading