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 @@ -13,19 +13,18 @@ import com.shopify.ucp.embedded.checkout.RequestDescriptor
import com.shopify.ucp.embedded.checkout.WindowOpenRequest
import com.shopify.ucp.embedded.checkout.WindowOpenResult
import com.shopify.ucp.embedded.checkout.decodeProtocolRequest
import com.shopify.ucp.embedded.checkout.encodeJsonRpcError
import com.shopify.ucp.embedded.checkout.encodeJsonRpcResult
import com.shopify.ucp.embedded.checkout.jsonRpcRequestId
import com.shopify.ucp.embedded.checkout.hasValidJsonRpcRequestId
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.JsonElement
import java.util.concurrent.CountDownLatch
import com.shopify.ucp.embedded.checkout.Client as ProtocolClient

/**
* Consumer-facing typed Embedded Checkout Protocol API curated by Checkout Kit.
*
* The lower-level `embedded-checkout-protocol` artifact owns generated models and raw wire
* method names. Checkout Kit decides which of those methods are supported for app
* developers and exposes them through this typed namespace.
* The lower-level `embedded-checkout-protocol` artifact owns generated models, raw wire
* method names, and the generic dispatch [ProtocolClient]. Checkout Kit decides which of
* those methods are supported for app developers and exposes them through this typed
* namespace.
*/
public object CheckoutProtocol {
public const val SPEC_VERSION: String = EmbeddedCheckoutProtocol.SPEC_VERSION
Expand Down Expand Up @@ -95,117 +94,42 @@ public object CheckoutProtocol {
/**
* A typed, fluent client for supported Checkout Kit protocol callbacks.
*
* Each [on] call returns a new [Client] instance, making it safe to share a
* base configuration across multiple checkout presentations.
* Wraps the generic protocol [ProtocolClient], adding Checkout Kit curation (only
* supported descriptors are registered), main-thread delivery of consumer handlers,
* and unconditional logging of decode failures via the kit logger.
*
* Each [on] call returns a new [Client] instance, making it safe to share a base
* configuration across multiple checkout presentations.
*/
public class Client private constructor(
private val handlers: Map<String, NotificationHandler>,
private val delegations: Map<String, Delegation>,
private val delegate: ProtocolClient,
) {
public constructor() : this(emptyMap(), emptyMap())
public constructor() : this(
ProtocolClient().onDecodeError { method, error, params ->
log.e(LOG_TAG, "Failed to decode $method params", error)
log.d(LOG_TAG, "Raw $method params: $params")
},
)

public fun <P : Any> on(
descriptor: NotificationDescriptor<P>,
handler: (P) -> Unit,
): Client {
if (descriptor !in supportedNotificationDescriptors) return this
val entry = NotificationHandler(
decode = { params -> descriptor.decode(params) },
invoke = { payload ->
@Suppress("UNCHECKED_CAST")
(payload as? P)?.let { handler(it) }
},
)
return Client(handlers + (descriptor.method to entry), delegations)
return Client(delegate.on(descriptor) { payload -> onMainThread { handler(payload) } })
}

public fun <P : Any, R : Any> on(
descriptor: RequestDescriptor<P, R>,
handler: (P) -> R,
): Client {
if (descriptor !in supportedRequestDescriptors) return this
return Client(handlers, delegations + (descriptor.method to Delegation.Typed(descriptor, handler)))
}

internal fun process(message: String): String? =
decodeRequest(message)?.let { request ->
val delegation = delegations[request.method]
if (delegation != null) {
jsonRpcRequestId(request.id)?.let { delegation.dispatch(request) }
} else {
dispatchNotification(request)
null
}
}

private fun decodeRequest(message: String): EcpRequest? = try {
decodeProtocolRequest(message)
.takeIf { it.hasValidJsonRpcRequestId() }
} catch (e: SerializationException) {
log.d(LOG_TAG, "Error processing ECP message in typed client: $e")
null
}

private fun dispatchNotification(request: EcpRequest) {
val handler = handlers[request.method]
if (handler == null) {
log.d(LOG_TAG, "No handler registered for method=${request.method}")
return
}
val payload = try {
handler.decode(request.params)
} catch (e: SerializationException) {
val message = "Failed to decode ${request.method} notification params"
val details = if (ShopifyCheckoutKit.configuration.logLevel == LogLevel.DEBUG) {
"$message raw=${request.params}"
} else {
message
}
log.e(LOG_TAG, details, e)
null
}
log.d(
LOG_TAG,
"Decoded payload for method=${request.method}: ${payload ?: "null, skipping"}",
)
payload?.let { onMainThread { handler.invoke(it) } }
return Client(delegate.on(descriptor) { payload -> invokeOnMainThread { handler(payload) } })
}
}

private class NotificationHandler(
val decode: (JsonElement?) -> Any?,
val invoke: (Any) -> Unit,
)

private sealed class Delegation {
abstract fun dispatch(request: EcpRequest): String

class Typed<P : Any, R : Any>(
private val descriptor: RequestDescriptor<P, R>,
private val handler: (P) -> R,
) : Delegation() {
override fun dispatch(request: EcpRequest): String {
val payload = try {
descriptor.decode(request.params)
} catch (e: SerializationException) {
log.e(
LOG_TAG,
"Failed to decode ${request.method} delegation params: raw=${request.params}",
e,
)
null
} ?: return encodeJsonRpcError(
request.id,
CODE_INVALID_PARAMS,
"Invalid params for ${request.method}",
)
val result = invokeOnMainThread { handler(payload) }
return encodeJsonRpcResult(request.id, descriptor.encode(result))
}
}
internal fun process(message: String): String? = delegate.process(message)
}

private const val CODE_INVALID_PARAMS: Int = -32602
private const val LOG_TAG: String = BaseWebView.ECP_LOG_TAG

private fun <R> invokeOnMainThread(block: () -> R): R {
Expand All @@ -220,6 +144,3 @@ public object CheckoutProtocol {
return result!!.getOrThrow()
}
}

internal fun EcpRequest.hasValidJsonRpcRequestId(): Boolean =
id == null || jsonRpcRequestId(id) != null
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,38 @@ class CheckoutProtocolTest {
).isTrue()
}

@Test
fun `process logs raw decode failure params at debug level in debug mode`() {
ShopifyCheckoutKit.configure { it.logLevel = LogLevel.DEBUG }
val client = CheckoutProtocol.Client()
.on(CheckoutProtocol.error) {}

val malformed = """{"jsonrpc":"2.0","method":"ec.error","params":""" +
"""{"error":{"ucp":{"version":"2026-04-08","status":"error"},"messages":"not-an-array"}}}"""
client.process(malformed)

assertThat(
ShadowLog.getLogs().any {
it.type == Log.DEBUG && it.msg.contains("not-an-array")
}
).isTrue()
}

@Test
fun `process omits raw decode failure params when not in debug mode`() {
ShopifyCheckoutKit.configure { it.logLevel = LogLevel.WARN }
val client = CheckoutProtocol.Client()
.on(CheckoutProtocol.error) {}

val malformed = """{"jsonrpc":"2.0","method":"ec.error","params":""" +
"""{"error":{"ucp":{"version":"2026-04-08","status":"error"},"messages":"not-an-array"}}}"""
client.process(malformed)

assertThat(
ShadowLog.getLogs().none { it.msg.contains("not-an-array") }
).isTrue()
}

@Test
fun `message model decodes all message types`() {
val cases = listOf(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,14 @@ public final class com/shopify/ucp/embedded/checkout/CheckoutTotal$Companion {
public final fun serializer ()Lkotlinx/serialization/KSerializer;
}

public final class com/shopify/ucp/embedded/checkout/Client {
public fun <init> ()V
public final fun on (Lcom/shopify/ucp/embedded/checkout/NotificationDescriptor;Lkotlin/jvm/functions/Function1;)Lcom/shopify/ucp/embedded/checkout/Client;
public final fun on (Lcom/shopify/ucp/embedded/checkout/RequestDescriptor;Lkotlin/jvm/functions/Function1;)Lcom/shopify/ucp/embedded/checkout/Client;
public final fun onDecodeError (Lkotlin/jvm/functions/Function3;)Lcom/shopify/ucp/embedded/checkout/Client;
public final fun process (Ljava/lang/String;)Ljava/lang/String;
}

public final class com/shopify/ucp/embedded/checkout/ContentType : java/lang/Enum {
public static final field Companion Lcom/shopify/ucp/embedded/checkout/ContentType$Companion;
public static final field Markdown Lcom/shopify/ucp/embedded/checkout/ContentType;
Expand Down Expand Up @@ -898,6 +906,7 @@ 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;
public static final fun encodeJsonRpcResult (Lkotlinx/serialization/json/JsonElement;Lkotlinx/serialization/json/JsonElement;)Ljava/lang/String;
public static final fun hasValidJsonRpcRequestId (Lcom/shopify/ucp/embedded/checkout/EcpRequest;)Z
public static final fun jsonRpcRequestId (Lkotlinx/serialization/json/JsonElement;)Lkotlinx/serialization/json/JsonElement;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package com.shopify.ucp.embedded.checkout

import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.JsonElement

/**
* A typed, fluent dispatcher for Embedded Checkout Protocol messages.
*
* Host SDKs register handlers against descriptors and feed raw JSON-RPC messages to
* [process]. The client decodes params, routes notifications and requests, and encodes
* responses. It is thread-agnostic: handlers run synchronously on the calling thread, so
* hosts that need to hop threads (for example onto a UI thread) wrap their handlers
* accordingly before registering them.
*
* Each [on] call returns a new [Client] instance, making it safe to share a base
* configuration across multiple presentations.
*/
public class Client private constructor(
private val notificationHandlers: Map<String, NotificationEntry>,
private val requestHandlers: Map<String, RequestEntry>,
private val decodeErrorHandler: DecodeErrorHandler?,
) {
public constructor() : this(emptyMap(), emptyMap(), null)

/**
* Registers a callback invoked whenever a notification or request payload fails to
* decode. The dropped message still short-circuits (notifications are skipped,
* requests answer with an invalid-params error), but the host gets a chance to
* observe and log the failure instead of it being swallowed silently.
*/
public fun onDecodeError(handler: DecodeErrorHandler): Client =
Client(notificationHandlers, requestHandlers, handler)

public fun <P : Any> on(descriptor: NotificationDescriptor<P>, handler: (P) -> Unit): Client {
val entry = NotificationEntry(
decode = { descriptor.decode(it) },
invoke = { payload ->
@Suppress("UNCHECKED_CAST")
handler(payload as P)
},
)
return Client(notificationHandlers + (descriptor.method to entry), requestHandlers, decodeErrorHandler)
}

public fun <P : Any, R : Any> on(descriptor: RequestDescriptor<P, R>, handler: (P) -> R): Client {
val entry = RequestEntry(
decode = { descriptor.decode(it) },
invokeAndEncode = { payload ->
@Suppress("UNCHECKED_CAST")
descriptor.encode(handler(payload as P))
},
)
return Client(notificationHandlers, requestHandlers + (descriptor.method to entry), decodeErrorHandler)
}

public fun process(message: String): String? {
val request = decodeEnvelope(message) ?: return null
val requestEntry = requestHandlers[request.method]
return if (requestEntry != null) {
request.id?.let { jsonRpcRequestId(it) }?.let { dispatchRequest(requestEntry, request) }
} else {
dispatchNotification(request)
null
}
}

private fun decodeEnvelope(message: String): EcpRequest? = try {
decodeProtocolRequest(message).takeIf { it.hasValidJsonRpcRequestId() }
} catch (_: SerializationException) {
null
}

private fun dispatchNotification(request: EcpRequest) {
val entry = notificationHandlers[request.method] ?: return
val payload = try {
entry.decode(request.params)
} catch (e: SerializationException) {
decodeErrorHandler?.invoke(request.method, e, request.params)
return
}
payload?.let { entry.invoke(it) }
}

private fun dispatchRequest(entry: RequestEntry, request: EcpRequest): String {
val payload = try {
entry.decode(request.params)
} catch (e: SerializationException) {
decodeErrorHandler?.invoke(request.method, e, request.params)
null
} ?: return encodeJsonRpcError(request.id, CODE_INVALID_PARAMS, "Invalid params for ${request.method}")
return encodeJsonRpcResult(request.id, entry.invokeAndEncode(payload))
}

private class NotificationEntry(
val decode: (JsonElement?) -> Any?,
val invoke: (Any) -> Unit,
)

private class RequestEntry(
val decode: (JsonElement?) -> Any?,
val invokeAndEncode: (Any) -> JsonElement,
)

private companion object {
private const val CODE_INVALID_PARAMS: Int = -32602
}
}

public typealias DecodeErrorHandler = (method: String, error: Throwable, params: JsonElement?) -> Unit
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ public fun jsonRpcRequestId(id: JsonElement?): JsonElement? =
else -> null
}

public fun EcpRequest.hasValidJsonRpcRequestId(): Boolean =
id == null || jsonRpcRequestId(id) != null

public fun encodeJsonRpcResult(id: JsonElement?, result: JsonElement): String =
embeddedProtocolJson.encodeToString(
JsonObject.serializer(),
Expand Down
Loading
Loading