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 @@ -10,11 +10,10 @@ public interface CheckoutCommunicationClient {
/**
* Process a JSON-RPC 2.0 ECP message from the checkout web page.
*
* Called for EC notifications (ec.start, ec.error, ec.complete, ec.*.change) and
* any unknown methods the kit doesn't handle natively. Delegations such as
* `ec.window.open_request` are handled internally by the kit and are not forwarded
* here. For requests, return a JSON-RPC 2.0 response string; for notifications,
* return null (no response is sent).
* Called for EC notifications (ec.start, ec.error, ec.complete, ec.*.change),
* merchant-overridable delegations such as `ec.window.open_request`, and any
* unknown methods the kit doesn't handle natively. For requests, return a JSON-RPC
* 2.0 response string; for notifications, return null (no response is sent).
*
* @param message JSON-RPC 2.0 encoded message string
* @return JSON-RPC 2.0 encoded response string, or null to send no response
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.shopify.checkoutkit

import kotlinx.serialization.SerializationException

/**
* Composes a merchant-supplied protocol client with kit-owned default handlers.
*
* The default bindings make the dispatch policy explicit in one place:
* request delegations such as [CheckoutProtocol.windowOpen] only fall back to the
* kit default when the merchant does not return a response, while mandatory kit
* notifications such as [CheckoutProtocol.error] always run after the merchant client.
*/
internal class ComposedCheckoutCommunicationClient(
private val merchant: CheckoutCommunicationClient?,
private val defaults: Map<String, DefaultClientBinding>,
) : CheckoutCommunicationClient {
override fun process(message: String): String? {
val method = method(message) ?: return merchant?.process(message)
var response = merchant?.process(message)

defaults[method]?.let { binding ->
when (binding.policy) {
DefaultClientPolicy.AlwaysRunAfterMerchant -> {
val defaultResponse = binding.client.process(message)
response = response ?: defaultResponse
}
DefaultClientPolicy.RunIfUnhandled ->
if (response == null) {
response = binding.client.process(message)
}
}
}

return response
}

private fun method(message: String): String? = try {
CheckoutProtocol.json.decodeFromString<EcpRequest>(message).method
} catch (_: SerializationException) {
null
}
}

internal data class DefaultClientBinding(
val client: CheckoutCommunicationClient,
val policy: DefaultClientPolicy,
)

internal enum class DefaultClientPolicy {
AlwaysRunAfterMerchant,
RunIfUnhandled,
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ internal class EmbeddedCheckoutProtocol(
) {
private val decoder = Json { ignoreUnknownKeys = true }
private val defaultClient: CheckoutProtocol.Client = defaultDelegationClient()
private val defaultClientBindings: Map<String, DefaultClientBinding> = mapOf(
CheckoutProtocol.windowOpen.method to DefaultClientBinding(
client = defaultClient,
policy = DefaultClientPolicy.RunIfUnhandled,
),
CheckoutProtocol.error.method to DefaultClientBinding(
client = defaultClient,
policy = DefaultClientPolicy.AlwaysRunAfterMerchant,
),
)
private val composedClient: CheckoutCommunicationClient
get() = ComposedCheckoutCommunicationClient(
merchant = client,
defaults = defaultClientBindings,
)

internal fun setClient(client: CheckoutCommunicationClient?) {
this.client = client
Expand Down Expand Up @@ -103,14 +118,14 @@ internal class EmbeddedCheckoutProtocol(
log.d(LOG_TAG, "Handling ${CheckoutProtocol.start.method}: hiding progress bar and bubbling up.")
onMainThread {
view.getListener().onCheckoutViewLoadComplete()
client?.process(message)
composedClient.process(message)
}
}

private fun handleComplete(message: String) {
log.d(LOG_TAG, "Handling ${CheckoutProtocol.complete.method}: bubbling up.")
onMainThread {
client?.process(message)
composedClient.process(message)
}
}

Expand All @@ -125,12 +140,7 @@ internal class EmbeddedCheckoutProtocol(
private fun handleWindowOpenRequest(message: String) {
log.d(LOG_TAG, "Handling ${CheckoutProtocol.windowOpen.method}")
onMainThread {
val merchantResponse = client?.process(message)
if (merchantResponse != null) {
sendRaw(merchantResponse)
} else {
defaultClient.process(message)?.let { sendRaw(it) }
}
composedClient.process(message)?.let { sendRaw(it) }
}
}

Expand All @@ -142,14 +152,9 @@ internal class EmbeddedCheckoutProtocol(
private fun handleClientMessage(method: String, message: String) {
log.d(LOG_TAG, "Delegating $method to client.")
onMainThread {
val response = client?.process(message)
val response = composedClient.process(message)
log.d(LOG_TAG, " client response: $response")
response?.let { sendRaw(it) }
if (method == CheckoutProtocol.error.method) {
// Unrecoverable ec.error is kit behavior: emit to the client, then run
// the default handler so checkout is dismissed even if the client responded.
defaultClient.process(message)?.let { sendRaw(it) }
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package com.shopify.checkoutkit

import org.assertj.core.api.Assertions.assertThat
import org.junit.Test

class ComposedCheckoutCommunicationClientTest {
@Test
fun `run if unhandled returns merchant response and skips default`() {
val merchant = RecordingClient(response = MERCHANT_RESPONSE)
val default = RecordingClient(response = DEFAULT_RESPONSE)
val client = ComposedCheckoutCommunicationClient(
merchant = merchant,
defaults = mapOf(
CheckoutProtocol.windowOpen.method to DefaultClientBinding(
client = default,
policy = DefaultClientPolicy.RunIfUnhandled,
),
),
)

val response = client.process(WINDOW_OPEN_REQUEST)

assertThat(response).isEqualTo(MERCHANT_RESPONSE)
assertThat(merchant.messages).containsExactly(WINDOW_OPEN_REQUEST)
assertThat(default.messages).isEmpty()
}

@Test
fun `run if unhandled returns default response when merchant has no response`() {
val merchant = RecordingClient(response = null)
val default = RecordingClient(response = DEFAULT_RESPONSE)
val client = ComposedCheckoutCommunicationClient(
merchant = merchant,
defaults = mapOf(
CheckoutProtocol.windowOpen.method to DefaultClientBinding(
client = default,
policy = DefaultClientPolicy.RunIfUnhandled,
),
),
)

val response = client.process(WINDOW_OPEN_REQUEST)

assertThat(response).isEqualTo(DEFAULT_RESPONSE)
assertThat(merchant.messages).containsExactly(WINDOW_OPEN_REQUEST)
assertThat(default.messages).containsExactly(WINDOW_OPEN_REQUEST)
}

@Test
fun `always run after merchant runs default and keeps merchant response`() {
val merchant = RecordingClient(response = MERCHANT_RESPONSE)
val default = RecordingClient(response = DEFAULT_RESPONSE)
val client = ComposedCheckoutCommunicationClient(
merchant = merchant,
defaults = mapOf(
CheckoutProtocol.error.method to DefaultClientBinding(
client = default,
policy = DefaultClientPolicy.AlwaysRunAfterMerchant,
),
),
)

val response = client.process(ERROR_NOTIFICATION)

assertThat(response).isEqualTo(MERCHANT_RESPONSE)
assertThat(merchant.messages).containsExactly(ERROR_NOTIFICATION)
assertThat(default.messages).containsExactly(ERROR_NOTIFICATION)
}

@Test
fun `always run after merchant returns default response when merchant has no response`() {
val merchant = RecordingClient(response = null)
val default = RecordingClient(response = DEFAULT_RESPONSE)
val client = ComposedCheckoutCommunicationClient(
merchant = merchant,
defaults = mapOf(
CheckoutProtocol.error.method to DefaultClientBinding(
client = default,
policy = DefaultClientPolicy.AlwaysRunAfterMerchant,
),
),
)

val response = client.process(ERROR_NOTIFICATION)

assertThat(response).isEqualTo(DEFAULT_RESPONSE)
assertThat(merchant.messages).containsExactly(ERROR_NOTIFICATION)
assertThat(default.messages).containsExactly(ERROR_NOTIFICATION)
}

@Test
fun `default binding only runs for matching method`() {
val default = RecordingClient(response = DEFAULT_RESPONSE)
val client = ComposedCheckoutCommunicationClient(
merchant = null,
defaults = mapOf(
CheckoutProtocol.windowOpen.method to DefaultClientBinding(
client = default,
policy = DefaultClientPolicy.RunIfUnhandled,
),
),
)

val response = client.process(ERROR_NOTIFICATION)

assertThat(response).isNull()
assertThat(default.messages).isEmpty()
}

private class RecordingClient(
private val response: String?,
) : CheckoutCommunicationClient {
val messages = mutableListOf<String>()

override fun process(message: String): String? {
messages += message
return response
}
}

private companion object {
private const val MERCHANT_RESPONSE = """{"jsonrpc":"2.0","id":"merchant","result":{}}"""
private const val DEFAULT_RESPONSE = """{"jsonrpc":"2.0","id":"default","result":{}}"""
private const val WINDOW_OPEN_REQUEST =
"""{"jsonrpc":"2.0","method":"ec.window.open_request","id":"1","params":{"url":"https://example.com"}}"""
private const val ERROR_NOTIFICATION =
"""{"jsonrpc":"2.0","method":"ec.error","params":{"error":{"messages":[]}}}"""
}
}
39 changes: 18 additions & 21 deletions platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,19 @@ class CheckoutWebView: WKWebView {
)
}

var defaultClientBindings: [String: DefaultClientBinding] {
[
CheckoutProtocol.windowOpen.method: DefaultClientBinding(
client: defaultsClient,
policy: .runIfUnhandled
),
CheckoutProtocol.error.method: DefaultClientBinding(
client: defaultsClient,
policy: .alwaysRunAfterMerchant
)
]
}

static func `for`(checkout url: URL, entryPoint: MetaData.EntryPoint? = nil) -> CheckoutWebView {
OSLogger.shared.debug("Creating webview for URL: \(url.absoluteString)")
return CheckoutWebView(entryPoint: entryPoint)
Expand Down Expand Up @@ -158,31 +171,15 @@ extension CheckoutWebView: WKScriptMessageHandler {
}

Task {
let isErrorNotification = CheckoutWebView.message(body, hasMethod: CheckoutProtocol.error.method)
let clientResponse = await client?.process(body)
if let response = clientResponse {
checkoutBridge.sendResponse(self, messageBody: response)
}

// Defaults are fallback handlers except for ec.error: unrecoverable errors
// must still dismiss checkout after being emitted to the client.
if isErrorNotification || clientResponse == nil,
let response = await defaultsClient.process(body)
{
let composedClient = ComposedCheckoutCommunicationClient(
merchant: client,
defaults: defaultClientBindings
)
if let response = await composedClient.process(body) {
checkoutBridge.sendResponse(self, messageBody: response)
}
}
}

private static func message(_ body: String, hasMethod method: String) -> Bool {
guard
let object = try? JSONSerialization.jsonObject(with: Data(body.utf8)) as? [String: Any],
object["method"] as? String == method
else {
return false
}
return true
}
}

extension CheckoutWebView: WKNavigationDelegate {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#if !COCOAPODS
import ShopifyCheckoutProtocol
#endif
import Foundation

/// Composes a merchant-supplied protocol client with kit-owned default handlers.
///
/// The default bindings make the dispatch policy explicit in one place:
/// request delegations such as `CheckoutProtocol.windowOpen` only fall back to the
/// kit default when the merchant does not return a response, while mandatory kit
/// notifications such as `CheckoutProtocol.error` always run after the merchant client.
struct ComposedCheckoutCommunicationClient: CheckoutCommunicationProtocol {
let merchant: (any CheckoutCommunicationProtocol)?
let defaults: [String: DefaultClientBinding]

func process(_ message: String) async -> String? {
guard let method = Self.method(message) else {
return await merchant?.process(message)
}

var response = await merchant?.process(message)

if let binding = defaults[method] {
switch binding.policy {
case .alwaysRunAfterMerchant:
let defaultResponse = await binding.client.process(message)
response = response ?? defaultResponse

case .runIfUnhandled:
if response == nil {
response = await binding.client.process(message)
}
}
}

return response
}

private static func method(_ message: String) -> String? {
guard
let object = try? JSONSerialization.jsonObject(with: Data(message.utf8)) as? [String: Any]
else {
return nil
}
return object["method"] as? String
}
}

struct DefaultClientBinding {
let client: any CheckoutCommunicationProtocol
let policy: DefaultClientPolicy
}

enum DefaultClientPolicy {
case alwaysRunAfterMerchant
case runIfUnhandled
}
Loading
Loading