Skip to content
Draft
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 @@ -15,6 +15,8 @@ public enum CheckoutProtocol {
static let methodNotFoundCode = -32601
static let methodNotFoundMessage = "Method not found"

static let ready = EmbeddedCheckoutProtocol.ready

public static let complete = EmbeddedCheckoutProtocol.Event.complete
public static let error = EmbeddedCheckoutProtocol.Event.error
public static let fulfillmentChange = EmbeddedCheckoutProtocol.Event.fulfillmentChange
Expand All @@ -24,7 +26,7 @@ public enum CheckoutProtocol {
public static let totalsChange = EmbeddedCheckoutProtocol.Event.totalsChange

static let supportedProtocolMethods: Set<String> = [
EmbeddedCheckoutProtocol.readyMethod,
ready.method,
start.method,
complete.method,
error.method,
Expand Down
24 changes: 13 additions & 11 deletions platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -224,13 +224,21 @@ class CheckoutWebView: WKWebView {
var openExternalURL: (URL) -> Void = { UIApplication.shared.open($0) }

/// Kit-owned client that handles delegations and kit-mandated notifications. Currently:
/// - `ec.ready` - kit-owned handshake. Supported delegations are announced up
/// front via the `ec_delegate` URL query param; acceptance is implicit, so the
/// ready result carries only the UCP envelope and the kit simply answers the
/// delegated calls it supports. It is abstracted from consumers and cannot be
/// overridden by a merchant-supplied client.
/// - `window.open` - falls back to `UIApplication.shared.open(...)` after a
/// `canOpenURL` check (consumers may still override via their own client).
/// - `ec.error` - when the payload carries `severity: "unrecoverable"`, dismiss
/// the kit via `viewDelegate`. Per UCP spec, `unrecoverable` means no valid
/// resource exists to act on, so consumers don't have to wire dismissal in
/// every error handler.
lazy var defaultsClient: CheckoutProtocol.Client = .init()
.on(CheckoutProtocol.ready) { _ in
ReadyResult(checkout: nil, credential: nil, ucp: .success(), upgrade: nil, continueURL: nil, messages: nil)
}
.on(CheckoutProtocol.complete) { _ in
CheckoutWebView.invalidate(disconnect: false)
}
Expand All @@ -254,6 +262,10 @@ class CheckoutWebView: WKWebView {

var defaultClientBindings: [String: DefaultClientBinding] {
[
CheckoutProtocol.ready.method: DefaultClientBinding(
client: defaultsClient,
policy: .kitOwned
),
CheckoutProtocol.complete.method: DefaultClientBinding(
client: defaultsClient,
policy: .alwaysRunAfterMerchant
Expand Down Expand Up @@ -449,7 +461,7 @@ extension CheckoutWebView: WKScriptMessageHandler {
return
}

guard let method = CheckoutProtocol.supportedProtocolMethod(body) else {
guard CheckoutProtocol.supportedProtocolMethod(body) != nil else {
if let response = CheckoutProtocol.methodNotFoundResponse(forUnsupportedProtocolRequest: body) {
Task { @MainActor in
await checkoutBridge.sendResponse(self, messageBody: response)
Expand All @@ -458,16 +470,6 @@ extension CheckoutWebView: WKScriptMessageHandler {
return
}

if method == EmbeddedCheckoutProtocol.readyMethod,
let response = EmbeddedCheckoutProtocol.acknowledgeReady(body, supportedDelegations: CheckoutProtocol.defaultDelegations.map(\.rawValue))
{
OSLogger.shared.debug("Handling ec.ready: sending UCP ready acknowledgement, isPreload: \(isPreloadRequest)")
Task { @MainActor in
await checkoutBridge.sendResponse(self, messageBody: response)
}
return
}

Task { @MainActor in
let composedClient = ComposedCheckoutCommunicationClient(
merchant: client,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,34 +6,35 @@ 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.
/// kit-owned requests such as `CheckoutProtocol.ready` are answered solely by the
/// kit default and never reach the merchant client; request delegations such as
/// `CheckoutProtocol.windowOpen` only fall back to the kit default when the merchant
/// does not return a response; and 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 {
guard let method = Self.method(message), let binding = defaults[method] else {
return await merchant?.process(message)
}

var response = await merchant?.process(message)
switch binding.policy {
case .kitOwned:
return await binding.client.process(message)

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

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

return response
}

private static func method(_ message: String) -> String? {
Expand All @@ -52,6 +53,7 @@ struct DefaultClientBinding {
}

enum DefaultClientPolicy {
case kitOwned
case alwaysRunAfterMerchant
case runIfUnhandled
}
4 changes: 2 additions & 2 deletions platforms/swift/Sources/ShopifyCheckoutKit/WindowOpen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ public enum WindowOpenResult: ResponsePayload {
}

extension CheckoutProtocol {
public static let windowOpen = DelegationDescriptor<WindowOpenRequest, WindowOpenResult>(
method: EmbeddedCheckoutProtocol.Event.windowOpenRequest.method,
public static let windowOpen = RequestDescriptor<WindowOpenRequest, WindowOpenResult>(
method: EmbeddedCheckoutProtocol.Event.windowOpenRequest,
delegation: "window.open",
decode: { params in
try? JSONDecoder().decode(WindowOpenRequest.self, from: params)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ struct CheckoutProtocolTests {

@Test func supportedProtocolMethodsCoverReadyCuratedNotificationsAndWindowOpen() {
#expect(CheckoutProtocol.supportedProtocolMethods == [
EmbeddedCheckoutProtocol.readyMethod,
EmbeddedCheckoutProtocol.Event.ready,
"ec.start",
"ec.complete",
"ec.error",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -523,9 +523,10 @@ class CheckoutWebViewTests: XCTestCase {
// MARK: - ec.ready handshake

@MainActor
func testAcknowledgeReadyRespondsToReadyRequest() async throws {
func testReadyHandshakeRespondsWithUCPEnvelope() async throws {
let id = "req-ready-1"
let body = #"{"jsonrpc":"2.0","method":"ec.ready","id":"\#(id)","params":{"delegate":[]}}"#
let body =
#"{"jsonrpc":"2.0","method":"ec.ready","id":"\#(id)","params":{"delegate":["window.open","payment.credential"]}}"#
let responseSent = expectation(description: "response sent")
MockCheckoutBridge.sendResponseExpectation = responseSent
let message = MockScriptMessage(body: body)
Expand All @@ -545,31 +546,14 @@ class CheckoutWebViewTests: XCTestCase {
let ucp = try XCTUnwrap(result["ucp"] as? [String: Any])
XCTAssertEqual(ucp["status"] as? String, "success")
XCTAssertEqual(ucp["version"] as? String, EmbeddedCheckoutProtocol.specVersion)
XCTAssertNil(result["delegate"], "Ready response no longer echoes a delegate list; delegations are announced via the ec_delegate URL param")
}

@MainActor
func testAcknowledgeReadyLogsPreloadState() {
let originalLogger = OSLogger.shared
let logger = TestableOSLogger(prefix: "ShopifyCheckoutKit", logLevel: .all)
OSLogger.shared = logger.logger
defer { OSLogger.shared = originalLogger }

view.load(checkout: url, isPreload: true)
let body = #"{"jsonrpc":"2.0","method":"ec.ready","id":"r1","params":{"delegate":[]}}"#
let message = MockScriptMessage(body: body)

view.userContentController(WKUserContentController(), didReceive: message)

XCTAssertTrue(
logger.capturedMessages.contains { captured in
captured.message.contains("Handling ec.ready: sending UCP ready acknowledgement, isPreload: true")
}
func testReadyIsNotOverridableByMerchantClient() async throws {
view.client = MockBridgeClient(
responseMessage: #"{"jsonrpc":"2.0","id":"r1","result":{"hijacked":true}}"#
)
}

@MainActor
func testAcknowledgeReadyDoesNotInvokeClient() async {
view.client = MockBridgeClient(responseMessage: "client-response")
let body = #"{"jsonrpc":"2.0","method":"ec.ready","id":"r1","params":{"delegate":[]}}"#
let responseSent = expectation(description: "response sent")
MockCheckoutBridge.sendResponseExpectation = responseSent
Expand All @@ -579,8 +563,13 @@ class CheckoutWebViewTests: XCTestCase {

await fulfillment(of: [responseSent], timeout: 5.0)

let response = try? XCTUnwrap(MockCheckoutBridge.lastResponseBody)
XCTAssertNotEqual(response, "client-response")
let response = try XCTUnwrap(MockCheckoutBridge.lastResponseBody)
let parsed = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any])
XCTAssertEqual(parsed["id"] as? String, "r1")
let result = try XCTUnwrap(parsed["result"] as? [String: Any])
XCTAssertNil(result["hijacked"], "Merchant client must not be able to answer ec.ready")
let ucp = try XCTUnwrap(result["ucp"] as? [String: Any])
XCTAssertEqual(ucp["status"] as? String, "success")
}

func testNonReadyMessageDoesNotTriggerReadyAck() {
Expand Down Expand Up @@ -671,8 +660,8 @@ class CheckoutWebViewTests: XCTestCase {
}

@MainActor
func testMalformedReadyParamsReturnParseError() async throws {
view.client = MockBridgeClient(responseMessage: "client-response")
func testMalformedReadyParamsReturnInvalidParams() async throws {
view.client = MockBridgeClient()
let body = #"{"jsonrpc":"2.0","method":"ec.ready","id":"ready-bad","params":{"delegate":[null]}}"#
let responseSent = expectation(description: "response sent")
MockCheckoutBridge.sendResponseExpectation = responseSent
Expand All @@ -683,12 +672,11 @@ class CheckoutWebViewTests: XCTestCase {
await fulfillment(of: [responseSent], timeout: 5.0)

let response = try XCTUnwrap(MockCheckoutBridge.lastResponseBody)
XCTAssertNotEqual(response, "client-response")
let parsed = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any])
XCTAssertEqual(parsed["id"] as? String, "ready-bad")
let error = try XCTUnwrap(parsed["error"] as? [String: Any])
XCTAssertEqual(error["code"] as? Int, -32700)
XCTAssertEqual(error["message"] as? String, "Parse error")
XCTAssertEqual(error["code"] as? Int, -32602)
XCTAssertEqual(error["message"] as? String, "Invalid params")
}

@MainActor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,28 @@ final class ComposedCheckoutCommunicationClientTests: XCTestCase {
XCTAssertEqual(defaultMessages, [Self.errorNotification])
}

func testKitOwnedAnswersWithoutConsultingMerchant() async {
let merchant = RecordingClient(response: Self.merchantResponse)
let defaultClient = RecordingClient(response: Self.defaultResponse)
let client = ComposedCheckoutCommunicationClient(
merchant: merchant,
defaults: [
"ec.ready": DefaultClientBinding(
client: defaultClient,
policy: .kitOwned
)
]
)

let response = await client.process(Self.readyRequest)
let merchantMessages = await merchant.messages
let defaultMessages = await defaultClient.messages

XCTAssertEqual(response, Self.defaultResponse)
XCTAssertEqual(merchantMessages, [], "kit-owned methods must never reach the merchant client")
XCTAssertEqual(defaultMessages, [Self.readyRequest])
}

func testDefaultBindingOnlyRunsForMatchingMethod() async {
let defaultClient = RecordingClient(response: Self.defaultResponse)
let client = ComposedCheckoutCommunicationClient(
Expand All @@ -113,6 +135,8 @@ final class ComposedCheckoutCommunicationClientTests: XCTestCase {
private static let defaultResponse = #"{"jsonrpc":"2.0","id":"default","result":{}}"#
private static let windowOpenRequest =
#"{"jsonrpc":"2.0","method":"ec.window.open_request","id":"1","params":{"url":"https://example.com"}}"#
private static let readyRequest =
#"{"jsonrpc":"2.0","method":"ec.ready","id":"1","params":{"delegate":[]}}"#
private static let errorNotification =
#"{"jsonrpc":"2.0","method":"ec.error","params":{"error":{"messages":[]}}}"#
}
Expand Down
20 changes: 10 additions & 10 deletions platforms/swift/api/ShopifyCheckoutKit.json
Original file line number Diff line number Diff line change
Expand Up @@ -1571,8 +1571,8 @@
"children": [
{
"kind": "TypeNominal",
"name": "DelegationDescriptor",
"printedName": "ShopifyCheckoutProtocol.DelegationDescriptor<ShopifyCheckoutKit.WindowOpenRequest, ShopifyCheckoutKit.WindowOpenResult>",
"name": "RequestDescriptor",
"printedName": "ShopifyCheckoutProtocol.RequestDescriptor<ShopifyCheckoutKit.WindowOpenRequest, ShopifyCheckoutKit.WindowOpenResult>",
"children": [
{
"kind": "TypeNominal",
Expand All @@ -1587,12 +1587,12 @@
"usr": "s:18ShopifyCheckoutKit16WindowOpenResultO"
}
],
"usr": "s:23ShopifyCheckoutProtocol20DelegationDescriptorV"
"usr": "s:23ShopifyCheckoutProtocol17RequestDescriptorV"
}
],
"declKind": "Var",
"usr": "s:18ShopifyCheckoutKit0B8ProtocolO10windowOpen0abD020DelegationDescriptorVyAA06WindowF7RequestVAA0iF6ResultOGvpZ",
"mangledName": "$s18ShopifyCheckoutKit0B8ProtocolO10windowOpen0abD020DelegationDescriptorVyAA06WindowF7RequestVAA0iF6ResultOGvpZ",
"usr": "s:18ShopifyCheckoutKit0B8ProtocolO10windowOpen0abD017RequestDescriptorVyAA06WindowfG0VAA0iF6ResultOGvpZ",
"mangledName": "$s18ShopifyCheckoutKit0B8ProtocolO10windowOpen0abD017RequestDescriptorVyAA06WindowfG0VAA0iF6ResultOGvpZ",
"moduleName": "ShopifyCheckoutKit",
"static": true,
"declAttributes": [
Expand All @@ -1610,8 +1610,8 @@
"children": [
{
"kind": "TypeNominal",
"name": "DelegationDescriptor",
"printedName": "ShopifyCheckoutProtocol.DelegationDescriptor<ShopifyCheckoutKit.WindowOpenRequest, ShopifyCheckoutKit.WindowOpenResult>",
"name": "RequestDescriptor",
"printedName": "ShopifyCheckoutProtocol.RequestDescriptor<ShopifyCheckoutKit.WindowOpenRequest, ShopifyCheckoutKit.WindowOpenResult>",
"children": [
{
"kind": "TypeNominal",
Expand All @@ -1626,12 +1626,12 @@
"usr": "s:18ShopifyCheckoutKit16WindowOpenResultO"
}
],
"usr": "s:23ShopifyCheckoutProtocol20DelegationDescriptorV"
"usr": "s:23ShopifyCheckoutProtocol17RequestDescriptorV"
}
],
"declKind": "Accessor",
"usr": "s:18ShopifyCheckoutKit0B8ProtocolO10windowOpen0abD020DelegationDescriptorVyAA06WindowF7RequestVAA0iF6ResultOGvgZ",
"mangledName": "$s18ShopifyCheckoutKit0B8ProtocolO10windowOpen0abD020DelegationDescriptorVyAA06WindowF7RequestVAA0iF6ResultOGvgZ",
"usr": "s:18ShopifyCheckoutKit0B8ProtocolO10windowOpen0abD017RequestDescriptorVyAA06WindowfG0VAA0iF6ResultOGvgZ",
"mangledName": "$s18ShopifyCheckoutKit0B8ProtocolO10windowOpen0abD017RequestDescriptorVyAA06WindowfG0VAA0iF6ResultOGvgZ",
"moduleName": "ShopifyCheckoutKit",
"static": true,
"implicit": true,
Expand Down
Loading
Loading