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 5ea8a0e70..cca5b5276 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 @@ -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 @@ -95,28 +94,29 @@ 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, - private val delegations: Map, + 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

on( descriptor: NotificationDescriptor

, 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

on( @@ -124,88 +124,12 @@ public object CheckoutProtocol { 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

( - private val descriptor: RequestDescriptor, - 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 invokeOnMainThread(block: () -> R): R { @@ -220,6 +144,3 @@ public object CheckoutProtocol { return result!!.getOrThrow() } } - -internal fun EcpRequest.hasValidJsonRpcRequestId(): Boolean = - id == null || jsonRpcRequestId(id) != null diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutProtocolTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutProtocolTest.kt index 679731904..e279bd3a2 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutProtocolTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutProtocolTest.kt @@ -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( 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 750a66b07..def921f26 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 @@ -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 ()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; @@ -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; } diff --git a/protocol/languages/kotlin/embedded-checkout-protocol/src/main/java/com/shopify/ucp/embedded/checkout/Client.kt b/protocol/languages/kotlin/embedded-checkout-protocol/src/main/java/com/shopify/ucp/embedded/checkout/Client.kt new file mode 100644 index 000000000..6cbdf0c91 --- /dev/null +++ b/protocol/languages/kotlin/embedded-checkout-protocol/src/main/java/com/shopify/ucp/embedded/checkout/Client.kt @@ -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, + private val requestHandlers: Map, + 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

on(descriptor: NotificationDescriptor

, 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

on(descriptor: RequestDescriptor, 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 diff --git a/protocol/languages/kotlin/embedded-checkout-protocol/src/main/java/com/shopify/ucp/embedded/checkout/ProtocolCodec.kt b/protocol/languages/kotlin/embedded-checkout-protocol/src/main/java/com/shopify/ucp/embedded/checkout/ProtocolCodec.kt index 56219b1e2..32b00b2f3 100644 --- a/protocol/languages/kotlin/embedded-checkout-protocol/src/main/java/com/shopify/ucp/embedded/checkout/ProtocolCodec.kt +++ b/protocol/languages/kotlin/embedded-checkout-protocol/src/main/java/com/shopify/ucp/embedded/checkout/ProtocolCodec.kt @@ -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(), diff --git a/protocol/languages/kotlin/embedded-checkout-protocol/src/test/java/com/shopify/ucp/embedded/checkout/ClientTest.kt b/protocol/languages/kotlin/embedded-checkout-protocol/src/test/java/com/shopify/ucp/embedded/checkout/ClientTest.kt new file mode 100644 index 000000000..ef573a6e8 --- /dev/null +++ b/protocol/languages/kotlin/embedded-checkout-protocol/src/test/java/com/shopify/ucp/embedded/checkout/ClientTest.kt @@ -0,0 +1,106 @@ +package com.shopify.ucp.embedded.checkout + +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.buildJsonObject +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class ClientTest { + @Test + fun `process dispatches decoded notification payload to handler`() { + var received: Ping? = null + val client = Client().on(ping) { received = it } + + val response = client.process( + """{"jsonrpc":"2.0","method":"ec.test.ping","params":{"value":"hi"}}""", + ) + + assertThat(received).isEqualTo(Ping("hi")) + assertThat(response).isNull() + } + + @Test + fun `process returns encoded result for request handler`() { + val client = Client().on(echo) { true } + + val response = client.process( + """{"jsonrpc":"2.0","id":"1","method":"ec.test.echo","params":{"value":"hi"}}""", + ) + + assertThat(response).contains("\"result\"").contains("\"ok\":true") + } + + @Test + fun `process invokes onDecodeError for malformed notification params`() { + var captured: Triple? = null + val client = Client() + .onDecodeError { method, error, params -> captured = Triple(method, error, params) } + .on(ping) { throw AssertionError("handler should not run for undecodable params") } + + val response = client.process( + """{"jsonrpc":"2.0","method":"ec.test.ping","params":{}}""", + ) + + assertThat(response).isNull() + assertThat(captured?.first).isEqualTo("ec.test.ping") + assertThat(captured?.second).isInstanceOf(SerializationException::class.java) + assertThat(captured?.third).isEqualTo(buildJsonObject {}) + } + + @Test + fun `process invokes onDecodeError and returns invalid params error for malformed request params`() { + var captured: Triple? = null + val client = Client() + .onDecodeError { method, error, params -> captured = Triple(method, error, params) } + .on(echo) { throw AssertionError("handler should not run for undecodable params") } + + val response = client.process( + """{"jsonrpc":"2.0","id":"1","method":"ec.test.echo","params":{}}""", + ) + + assertThat(captured?.first).isEqualTo("ec.test.echo") + assertThat(captured?.second).isInstanceOf(SerializationException::class.java) + assertThat(response).contains("-32602") + } + + @Test + fun `process returns null for unregistered method`() { + val response = Client().process( + """{"jsonrpc":"2.0","method":"ec.unregistered","params":{}}""", + ) + + assertThat(response).isNull() + } + + @Test + fun `process returns null for malformed envelope`() { + val response = Client().on(ping) { throw AssertionError("should not run") }.process("not json") + + assertThat(response).isNull() + } + + private companion object { + private val ping: NotificationDescriptor = notificationDescriptor( + method = "ec.test.ping", + paramsSerializer = Ping.serializer(), + decode = { it }, + ) + + private val echo: RequestDescriptor = requestDescriptor( + method = "ec.test.echo", + delegation = "test.echo", + requestSerializer = Ping.serializer(), + responseSerializer = Pong.serializer(), + decode = { it }, + encode = { Pong(ok = it) }, + ) + } + + @Serializable + private data class Ping(val value: String) + + @Serializable + private data class Pong(val ok: Boolean) +}