diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutCommunicationClient.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutCommunicationClient.kt index 9703a402..aba87754 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutCommunicationClient.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutCommunicationClient.kt @@ -10,10 +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), - * 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). + * Called for supported EC notifications (ec.start, ec.error, ec.complete, + * ec.*.change) and merchant-overridable delegations such as + * `ec.window.open_request`. 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 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 6617ef98..b61531ad 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 @@ -38,6 +38,7 @@ import java.util.concurrent.CountDownLatch public object CheckoutProtocol { public const val SPEC_VERSION: String = "2026-04-08" + internal const val READY_METHOD: String = "ec.ready" // Notifications — checkout carries the full current state public val start: NotificationDescriptor = checkoutDescriptor("ec.start") @@ -74,6 +75,31 @@ public object CheckoutProtocol { encode = { result -> encodeWindowOpenResult(result) }, ) + internal val supportedProtocolMethods: Set = setOf( + READY_METHOD, + start.method, + complete.method, + error.method, + lineItemsChange.method, + messagesChange.method, + totalsChange.method, + windowOpen.method, + ) + + internal fun supportedProtocolMethod(message: String): String? = + decodeProtocolRequest(message)?.let(::supportedProtocolMethod) + + internal fun supportedProtocolMethod(request: EcpRequest): String? = + request.method.takeIf { + request.jsonrpc == "2.0" && request.method in supportedProtocolMethods + } + + private fun decodeProtocolRequest(message: String): EcpRequest? = try { + json.decodeFromString(message) + } catch (_: SerializationException) { + null + } + private fun checkoutDescriptor(method: String): NotificationDescriptor = NotificationDescriptor( method = method, @@ -292,6 +318,14 @@ public class DelegationDescriptor

internal constructor( internal val encode: (R) -> JsonElement, ) +@Serializable +internal data class EcpRequest( + val jsonrpc: String = "2.0", + val method: String, + val id: JsonElement? = null, + val params: JsonElement? = null, +) + /** Payload delivered with the [CheckoutProtocol.windowOpen] delegation. */ @ConsistentCopyVisibility public data class WindowOpenRequest internal constructor(public val url: Uri) diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocol.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocol.kt index 99e2db32..e3d35d06 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocol.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocol.kt @@ -2,7 +2,6 @@ package com.shopify.checkoutkit import android.webkit.JavascriptInterface import com.shopify.checkoutkit.ShopifyCheckoutKit.log -import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray @@ -57,19 +56,15 @@ internal class EmbeddedCheckoutProtocol( fun postMessage(message: String) { try { val request = decoder.decodeFromString(message) + val method = CheckoutProtocol.supportedProtocolMethod(request) log.d(LOG_TAG, "Received bridge message: method=${request.method} id=${request.id}") - when { - request.method == METHOD_READY -> handleReady(request) - // Respond with explicit "not supported" so web-side promises don't hang - request.method in UNSUPPORTED_METHODS -> - sendError(request.id, CODE_METHOD_NOT_SUPPORTED, "Method not supported by this SDK") - // ep.cart.* is out of scope for the checkout bridge - request.method.startsWith("ep.") -> - log.d(LOG_TAG, "Ignoring out-of-scope ep method: ${request.method}.") - request.method == CheckoutProtocol.windowOpen.method -> handleWindowOpenRequest(message) - request.method == CheckoutProtocol.start.method -> handleStart(message) - request.method == CheckoutProtocol.complete.method -> handleComplete(message) - else -> handleClientMessage(request.method, message) + when (method) { + CheckoutProtocol.READY_METHOD -> handleReady(request) + CheckoutProtocol.windowOpen.method -> handleWindowOpenRequest(message) + CheckoutProtocol.start.method -> handleStart(message) + CheckoutProtocol.complete.method -> handleComplete(message) + null -> log.d(LOG_TAG, "Ignoring unsupported ECP method: ${request.method}.") + else -> handleClientMessage(method, message) } } catch (e: SerializationException) { log.d(LOG_TAG, "Failed to decode ECP message: $e raw=$message") @@ -82,7 +77,7 @@ internal class EmbeddedCheckoutProtocol( val negotiatedDelegations = checkoutAcceptedDelegations.filter { it in KIT_SUPPORTED_DELEGATIONS } log.d( LOG_TAG, - "Handling $METHOD_READY, " + + "Handling ${CheckoutProtocol.READY_METHOD}, " + "isPreload=${view.isPreloadRequest} " + "checkoutAcceptedDelegations=$checkoutAcceptedDelegations " + "checkoutKitSupportedDelegations=$KIT_SUPPORTED_DELEGATIONS " + @@ -93,12 +88,13 @@ internal class EmbeddedCheckoutProtocol( private fun checkoutAcceptedDelegations(params: JsonElement?): List = when (params) { null -> emptyList() - !is JsonObject -> throw SerializationException("$METHOD_READY params must be an object") + !is JsonObject -> throw SerializationException("${CheckoutProtocol.READY_METHOD} params must be an object") else -> params["delegate"]?.let(::delegationStrings) ?: emptyList() } private fun delegationStrings(delegate: JsonElement): List { - val delegateArray = delegate as? JsonArray ?: throw SerializationException("$METHOD_READY delegate must be an array") + val delegateArray = delegate as? JsonArray + ?: throw SerializationException("${CheckoutProtocol.READY_METHOD} delegate must be an array") return delegateArray.mapNotNull(::delegationStringOrNull) } @@ -150,9 +146,10 @@ internal class EmbeddedCheckoutProtocol( } /** - * Dispatch a message through the consumer client. `ec.error` also runs through the - * kit-owned [defaultClient] regardless of the consumer response so unrecoverable - * session errors always close checkout while still reaching `CheckoutProtocol.error`. + * Dispatch a supported protocol message through the consumer client. `ec.error` also + * runs through the kit-owned [defaultClient] regardless of the consumer response so + * unrecoverable session errors always close checkout while still reaching + * `CheckoutProtocol.error`. */ private fun handleClientMessage(method: String, message: String) { log.d(LOG_TAG, "Delegating $method to client.") @@ -233,31 +230,11 @@ internal class EmbeddedCheckoutProtocol( /** Global JS object the checkout uses to receive responses. */ private const val ECP_RESPONSE_GLOBAL = "EmbeddedCheckoutProtocol" - internal const val METHOD_READY = "ec.ready" - // Delegations this SDK supports. Echoed back in the ec.ready response as the // intersection of checkout-accepted ∩ kit-supported. Must align with the // `ec_delegate` URL param emitted from [UriExtensions.appendEcpParams]. private val KIT_SUPPORTED_DELEGATIONS = setOf("window.open") - // Requests the SDK explicitly does not support — send a protocol-level error so the - // web-side promise resolves rather than hanging indefinitely. - private val UNSUPPORTED_METHODS = setOf( - "ec.auth", - "ec.payment.instruments_change_request", - "ec.payment.credential_request", - "ec.fulfillment.address_change_request", - ) - private const val CODE_PARSE_ERROR = -32700 - private const val CODE_METHOD_NOT_SUPPORTED = -32601 } } - -@Serializable -internal data class EcpRequest( - val jsonrpc: String = "2.0", - val method: String, - val id: JsonElement? = null, - val params: JsonElement? = null, -) diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckoutKit.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckoutKit.kt index a6357dac..c82fc5b1 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckoutKit.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckoutKit.kt @@ -117,10 +117,10 @@ public object ShopifyCheckoutKit { * @param context The context the checkout is being presented from * @param checkoutListener provides callbacks to allow clients to listen for and respond to checkout lifecycle events * (failure, cancellation, permission prompts, file chooser). - * @param communicationClient optional handler for Embedded Checkout Protocol (ECP) messages. - * Implement [CheckoutCommunicationClient] to intercept arbitrary ECP messages from the checkout - * web page. Built-in messages ([ec.ready][EmbeddedCheckoutProtocol.METHOD_READY] and - * [ec.start][CheckoutProtocol.start]) are handled automatically by the SDK. + * @param communicationClient optional handler for supported Embedded Checkout Protocol (ECP) + * messages from the checkout web page. Built-in messages + * (`ec.ready` and [ec.start][CheckoutProtocol.start]) + * are handled automatically by the SDK. * @return An instance of [CheckoutKitDialog] if the dialog was successfully created and displayed. */ @JvmOverloads diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutPresentationTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutPresentationTest.kt index d042c141..29a4f3d7 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutPresentationTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutPresentationTest.kt @@ -80,7 +80,7 @@ class CheckoutPresentationTest { @Test fun `present builder forwards connected client to embedded checkout protocol`() { - val rawMessage = """{"jsonrpc":"2.0","method":"customMethod","id":"1"}""" + val rawMessage = """{"jsonrpc":"2.0","method":"ec.messages.change","params":{"checkout":{}}}""" val client = mock() whenever(client.process(rawMessage)).thenReturn(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 53853cc4..922b9b74 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 @@ -46,6 +46,47 @@ class CheckoutProtocolTest { // endregion + // region supported protocol methods + + @Test + fun `supported protocol methods include ready notifications and delegations`() { + assertThat(CheckoutProtocol.supportedProtocolMethods).containsExactlyInAnyOrder( + CheckoutProtocol.READY_METHOD, + CheckoutProtocol.start.method, + CheckoutProtocol.complete.method, + CheckoutProtocol.error.method, + CheckoutProtocol.lineItemsChange.method, + CheckoutProtocol.messagesChange.method, + CheckoutProtocol.totalsChange.method, + CheckoutProtocol.windowOpen.method, + ) + } + + @Test + fun `supported protocol methods exclude internal or unsupported methods`() { + assertThat(CheckoutProtocol.supportedProtocolMethods).doesNotContain( + CheckoutProtocol.buyerChange.method, + "ec.payment.credential_request", + "ep.cart.ready", + ) + } + + @Test + fun `supported protocol method parses valid supported message`() { + val message = """{"jsonrpc":"2.0","method":"ec.start","params":{"checkout":{}}}""" + + assertThat(CheckoutProtocol.supportedProtocolMethod(message)).isEqualTo(CheckoutProtocol.start.method) + } + + @Test + fun `supported protocol method rejects unsupported or invalid message`() { + assertThat(CheckoutProtocol.supportedProtocolMethod("""{"jsonrpc":"2.0","method":"custom"}""")).isNull() + assertThat(CheckoutProtocol.supportedProtocolMethod("""{"jsonrpc":"1.0","method":"ec.start"}""")).isNull() + assertThat(CheckoutProtocol.supportedProtocolMethod("not json")).isNull() + } + + // endregion + // region process — notification dispatch @Test diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolTest.kt index eeca0512..b245b3b6 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolTest.kt @@ -150,68 +150,42 @@ class EmbeddedCheckoutProtocolTest { // endregion - // region unsupported methods — explicit error response + // region unsupported methods — silently ignored @Test - fun `ec auth sends method not supported error`() { - val js = captureEvaluatedJs { - ecp.postMessage("""{"jsonrpc":"2.0","method":"ec.auth","id":"1","params":{"type":"oauth"}}""") - } - assertThat(js).contains("\"error\"") - assertThat(js).contains("-32601") + fun `ec auth is silently ignored and not delegated to client`() { + assertIgnoredByBridge("""{"jsonrpc":"2.0","method":"ec.auth","id":"1","params":{"type":"oauth"}}""") } @Test - fun `ec auth does not invoke client`() { - val client = mock() - ecp.setClient(client) - ecp.postMessage("""{"jsonrpc":"2.0","method":"ec.auth","id":"1","params":{"type":"oauth"}}""") - verify(client, never()).process(any()) + fun `ec payment instruments change request is silently ignored and not delegated to client`() { + assertIgnoredByBridge( + """{"jsonrpc":"2.0","method":"ec.payment.instruments_change_request","id":"2","params":{}}""" + ) } @Test - fun `ec payment instruments change request sends method not supported error`() { - val js = captureEvaluatedJs { - ecp.postMessage( - """{"jsonrpc":"2.0","method":"ec.payment.instruments_change_request","id":"2","params":{}}""" - ) - } - assertThat(js).contains("\"error\"") - assertThat(js).contains("-32601") + fun `ec payment credential request is silently ignored and not delegated to client`() { + assertIgnoredByBridge( + """{"jsonrpc":"2.0","method":"ec.payment.credential_request","id":"3","params":{}}""" + ) } @Test - fun `ec payment credential request sends method not supported error`() { - val js = captureEvaluatedJs { - ecp.postMessage( - """{"jsonrpc":"2.0","method":"ec.payment.credential_request","id":"3","params":{}}""" - ) - } - assertThat(js).contains("\"error\"") - assertThat(js).contains("-32601") + fun `ec fulfillment address change request is silently ignored and not delegated to client`() { + assertIgnoredByBridge( + """{"jsonrpc":"2.0","method":"ec.fulfillment.address_change_request","id":"4","params":{}}""" + ) } @Test - fun `ec fulfillment address change request sends method not supported error`() { - val js = captureEvaluatedJs { - ecp.postMessage( - """{"jsonrpc":"2.0","method":"ec.fulfillment.address_change_request","id":"4","params":{}}""" - ) - } - assertThat(js).contains("\"error\"") - assertThat(js).contains("-32601") + fun `ec buyer change is silently ignored and not delegated to client`() { + assertIgnoredByBridge("""{"jsonrpc":"2.0","method":"ec.buyer.change","params":{"checkout":{}}}""") } @Test - fun `ep cart methods are silently ignored and not delegated to client`() { - val client = mock() - ecp.setClient(client) - - ecp.postMessage("""{"jsonrpc":"2.0","method":"ep.cart.ready","id":"5","params":{}}""") - shadowOf(Looper.getMainLooper()).runToEndOfTasks() - - verify(viewSpy, never()).evaluateJavascript(any(), any()) - verify(client, never()).process(any()) + fun `ep cart methods fall through as unsupported and are not delegated to client`() { + assertIgnoredByBridge("""{"jsonrpc":"2.0","method":"ep.cart.ready","id":"5","params":{}}""") } // endregion @@ -522,21 +496,14 @@ class EmbeddedCheckoutProtocolTest { // region client delegation — requests @Test - fun `unknown method is delegated to client`() { + fun `unknown method is silently ignored and not delegated to client`() { val rawMessage = """{"jsonrpc":"2.0","method":"customMethod","id":"8","params":{}}""" - val client = mock() - whenever(client.process(rawMessage)).thenReturn(null) - ecp.setClient(client) - - ecp.postMessage(rawMessage) - shadowOf(Looper.getMainLooper()).runToEndOfTasks() - - verify(client).process(rawMessage) + assertIgnoredByBridge(rawMessage) } @Test - fun `non-null client response is sent back to checkout`() { - val rawMessage = """{"jsonrpc":"2.0","method":"customMethod","id":"9"}""" + fun `non-null client response for supported request is sent back to checkout`() { + val rawMessage = windowOpenRequest(id = "\"9\"", url = "https://example.com") val clientResponse = """{"jsonrpc":"2.0","id":"9","result":{"data":"ok"}}""" val client = mock() whenever(client.process(rawMessage)).thenReturn(clientResponse) @@ -550,8 +517,8 @@ class EmbeddedCheckoutProtocolTest { } @Test - fun `null client response sends nothing to checkout`() { - val rawMessage = """{"jsonrpc":"2.0","method":"customMethod","id":"10"}""" + fun `null client response for supported notification sends nothing to checkout`() { + val rawMessage = """{"jsonrpc":"2.0","method":"ec.messages.change","params":{"checkout":{}}}""" val client = mock() whenever(client.process(rawMessage)).thenReturn(null) ecp.setClient(client) @@ -622,6 +589,17 @@ class EmbeddedCheckoutProtocolTest { private fun windowOpenRequest(id: String, url: String): String = """{"jsonrpc":"2.0","method":"ec.window.open_request","id":$id,"params":{"url":"$url"}}""" + private fun assertIgnoredByBridge(rawMessage: String) { + val client = mock() + ecp.setClient(client) + + ecp.postMessage(rawMessage) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + verify(viewSpy, never()).evaluateJavascript(any(), any()) + verify(client, never()).process(any()) + } + private fun ecErrorMessage(severity: String): String { val messages = """[{"type":"error","code":"session_failed","content":"Session failed","severity":"$severity"}]""" diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index 956027b4..df180416 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -181,7 +181,11 @@ extension CheckoutWebView: WKScriptMessageHandler { return } - if let response = CheckoutProtocol.acknowledgeReady(body) { + guard let method = CheckoutProtocol.supportedProtocolMethod(body) else { + return + } + + if method == CheckoutProtocol.readyMethod, let response = CheckoutProtocol.acknowledgeReady(body) { Task { @MainActor in await checkoutBridge.sendResponse(self, messageBody: response) } diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index 42d0c5e8..b4574222 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -333,6 +333,29 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertFalse(MockCheckoutBridge.sendResponseCalled) } + @MainActor + func testUnsupportedProtocolMethodsDoNotInvokeClient() async { + let client = RecordingBridgeClient(response: #"{"jsonrpc":"2.0","id":"raw","result":{}}"#) + view.client = client + let notSent = expectation(description: "sendResponse must not fire") + notSent.isInverted = true + MockCheckoutBridge.sendResponseExpectation = notSent + let messages = [ + #"{"jsonrpc":"2.0","method":"ec.payment.credential_request","id":"unsupported","params":{}}"#, + #"{"jsonrpc":"2.0","method":"ep.cart.ready","id":"ep","params":{}}"#, + #"{"jsonrpc":"2.0","method":"customMethod","id":"custom","params":{}}"# + ] + + for body in messages { + view.userContentController(WKUserContentController(), didReceive: MockScriptMessage(body: body)) + } + + await fulfillment(of: [notSent], timeout: 1.0) + XCTAssertFalse(MockCheckoutBridge.sendResponseCalled) + let receivedMessages = await client.messages() + XCTAssertEqual(receivedMessages, []) + } + @MainActor func testReadyAckFiresWhenNoClientIsAttached() async { view.client = nil @@ -348,6 +371,24 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertTrue(MockCheckoutBridge.sendResponseCalled) } + @MainActor + func testSupportedRequestUsesRawClientResponse() async { + let id = "req-window-raw" + let body = #"{"jsonrpc":"2.0","method":"ec.window.open_request","id":"\#(id)","params":{"url":"https://example.com/terms"}}"# + let rawResponse = #"{"jsonrpc":"2.0","id":"\#(id)","result":{"data":"ok"}}"# + let responseSent = expectation(description: "response sent") + MockCheckoutBridge.sendResponseExpectation = responseSent + let client = RecordingBridgeClient(response: rawResponse) + view.client = client + + view.userContentController(WKUserContentController(), didReceive: MockScriptMessage(body: body)) + + await fulfillment(of: [responseSent], timeout: 5.0) + XCTAssertEqual(MockCheckoutBridge.lastResponseBody, rawResponse) + let receivedMessages = await client.messages() + XCTAssertEqual(receivedMessages, [body]) + } + @MainActor func testWindowOpenRequestUsesConsumerOverride() async throws { let id = "req-window-1" @@ -514,6 +555,24 @@ class CheckoutWebViewTests: XCTestCase { } } +private actor RecordingBridgeClient: CheckoutCommunicationProtocol { + let response: String? + private var receivedMessages: [String] = [] + + init(response: String? = nil) { + self.response = response + } + + func messages() -> [String] { + receivedMessages + } + + func process(_ message: String) async -> String? { + receivedMessages.append(message) + return response + } +} + @MainActor class MockCheckoutBridge: CheckoutBridgeProtocol { static var sendResponseCalled = false diff --git a/platforms/swift/api/ShopifyCheckoutProtocol.json b/platforms/swift/api/ShopifyCheckoutProtocol.json index 33a1642b..006c63aa 100644 --- a/platforms/swift/api/ShopifyCheckoutProtocol.json +++ b/platforms/swift/api/ShopifyCheckoutProtocol.json @@ -158,6 +158,54 @@ } ] }, + { + "kind": "Var", + "name": "readyMethod", + "printedName": "readyMethod", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Var", + "usr": "s:23ShopifyCheckoutProtocol0bC0O11readyMethodSSvpZ", + "mangledName": "$s23ShopifyCheckoutProtocol0bC0O11readyMethodSSvpZ", + "moduleName": "ShopifyCheckoutProtocol", + "static": true, + "isInternal": true, + "declAttributes": [ + "HasInitialValue", + "HasStorage" + ], + "isLet": true, + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Accessor", + "usr": "s:23ShopifyCheckoutProtocol0bC0O11readyMethodSSvgZ", + "mangledName": "$s23ShopifyCheckoutProtocol0bC0O11readyMethodSSvgZ", + "moduleName": "ShopifyCheckoutProtocol", + "static": true, + "implicit": true, + "isInternal": true, + "accessorKind": "get" + } + ] + }, { "kind": "Var", "name": "complete", @@ -548,6 +596,104 @@ } ] }, + { + "kind": "Var", + "name": "supportedProtocolMethods", + "printedName": "supportedProtocolMethods", + "children": [ + { + "kind": "TypeNominal", + "name": "Set", + "printedName": "Swift.Set", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "usr": "s:Sh" + } + ], + "declKind": "Var", + "usr": "s:23ShopifyCheckoutProtocol0bC0O09supportedC7MethodsShySSGvpZ", + "mangledName": "$s23ShopifyCheckoutProtocol0bC0O09supportedC7MethodsShySSGvpZ", + "moduleName": "ShopifyCheckoutProtocol", + "static": true, + "isInternal": true, + "declAttributes": [ + "HasInitialValue", + "HasStorage" + ], + "isLet": true, + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Set", + "printedName": "Swift.Set", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "usr": "s:Sh" + } + ], + "declKind": "Accessor", + "usr": "s:23ShopifyCheckoutProtocol0bC0O09supportedC7MethodsShySSGvgZ", + "mangledName": "$s23ShopifyCheckoutProtocol0bC0O09supportedC7MethodsShySSGvgZ", + "moduleName": "ShopifyCheckoutProtocol", + "static": true, + "implicit": true, + "isInternal": true, + "accessorKind": "get" + } + ] + }, + { + "kind": "Function", + "name": "supportedProtocolMethod", + "printedName": "supportedProtocolMethod(_:)", + "children": [ + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Swift.String?", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "usr": "s:Sq" + }, + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Func", + "usr": "s:23ShopifyCheckoutProtocol0bC0O09supportedC6MethodySSSgSSFZ", + "mangledName": "$s23ShopifyCheckoutProtocol0bC0O09supportedC6MethodySSSgSSFZ", + "moduleName": "ShopifyCheckoutProtocol", + "static": true, + "isInternal": true, + "funcSelfKind": "NonMutating" + }, { "kind": "Function", "name": "url", diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol.swift index 15569753..10d27e9e 100644 --- a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol.swift +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol.swift @@ -1,8 +1,12 @@ +import Foundation + public enum CheckoutProtocol { public static let specVersion = "2026-04-08" public static let defaultDelegations: [String] = ["window.open"] + package static let readyMethod = "ec.ready" + static let buyerChange = NotificationDescriptor(method: "ec.buyer.change") public static let complete = NotificationDescriptor(method: "ec.complete") public static let error = NotificationDescriptor(method: "ec.error") @@ -14,4 +18,28 @@ public enum CheckoutProtocol { ) public static let start = NotificationDescriptor(method: "ec.start") public static let totalsChange = NotificationDescriptor(method: "ec.totals.change") + + package static let supportedProtocolMethods: Set = [ + readyMethod, + start.method, + complete.method, + error.method, + lineItemsChange.method, + messagesChange.method, + totalsChange.method, + windowOpen.method + ] + + package static func supportedProtocolMethod(_ message: String) -> String? { + guard + let object = try? JSONSerialization.jsonObject(with: Data(message.utf8)) as? [String: Any], + object["jsonrpc"] as? String == "2.0", + let method = object["method"] as? String, + supportedProtocolMethods.contains(method) + else { + return nil + } + + return method + } } diff --git a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/DescriptorTests.swift b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/DescriptorTests.swift index 01ba7586..024a169c 100644 --- a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/DescriptorTests.swift +++ b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/DescriptorTests.swift @@ -40,4 +40,38 @@ struct DescriptorTests { #expect(CheckoutProtocol.error.method == "ec.error") } } + + @Suite("Supported Protocol Methods") + struct SupportedProtocolMethods { + @Test func includesReadyNotificationsAndDelegations() { + #expect(CheckoutProtocol.supportedProtocolMethods == [ + CheckoutProtocol.readyMethod, + CheckoutProtocol.start.method, + CheckoutProtocol.complete.method, + CheckoutProtocol.error.method, + CheckoutProtocol.lineItemsChange.method, + CheckoutProtocol.messagesChange.method, + CheckoutProtocol.totalsChange.method, + CheckoutProtocol.windowOpen.method + ]) + } + + @Test func excludesInternalOrUnsupportedMethods() { + #expect(!CheckoutProtocol.supportedProtocolMethods.contains(CheckoutProtocol.buyerChange.method)) + #expect(!CheckoutProtocol.supportedProtocolMethods.contains("ec.payment.credential_request")) + #expect(!CheckoutProtocol.supportedProtocolMethods.contains("ep.cart.ready")) + } + + @Test func supportedProtocolMethodParsesValidSupportedMessage() { + let message = #"{"jsonrpc":"2.0","method":"ec.start","params":{"checkout":{}}}"# + + #expect(CheckoutProtocol.supportedProtocolMethod(message) == CheckoutProtocol.start.method) + } + + @Test func supportedProtocolMethodRejectsUnsupportedOrInvalidMessage() { + #expect(CheckoutProtocol.supportedProtocolMethod(#"{"jsonrpc":"2.0","method":"custom"}"#) == nil) + #expect(CheckoutProtocol.supportedProtocolMethod(#"{"jsonrpc":"1.0","method":"ec.start"}"#) == nil) + #expect(CheckoutProtocol.supportedProtocolMethod("not json") == nil) + } + } }