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 @@ -2,58 +2,76 @@ import Foundation
#if COCOAPODS
import ShopifyCheckoutKit

extension CheckoutProtocol.Client: @retroactive CheckoutCommunicationProtocol {}
extension CheckoutTransport.Client: @retroactive CheckoutCommunicationProtocol {}

private enum RelayCuration {
static let complete = CheckoutProtocol.complete
static let error = CheckoutProtocol.error
static let lineItemsChange = CheckoutProtocol.lineItemsChange
static let messagesChange = CheckoutProtocol.messagesChange
static let start = CheckoutProtocol.start
static let totalsChange = CheckoutProtocol.totalsChange
}
#else
import ShopifyCheckoutProtocol

private enum RelayCuration {
static let complete = GeneratedProtocolCatalog.ecComplete
static let error = GeneratedProtocolCatalog.ecError
static let lineItemsChange = GeneratedProtocolCatalog.ecLineItemsChange
static let messagesChange = GeneratedProtocolCatalog.ecMessagesChange
static let start = GeneratedProtocolCatalog.ecStart
static let totalsChange = GeneratedProtocolCatalog.ecTotalsChange
}
#endif
Comment on lines 2 to 26

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: figure out if we can remove this mapping


struct DispatchEnvelope<Payload: Encodable>: Encodable {
let type: String
let payload: Payload
}

/// Bridges native CheckoutProtocol notifications to the React Native onDispatch
/// Bridges native CheckoutTransport notifications to the React Native onDispatch
/// event stream. Payloads are emitted in protocol wire casing; JS performs the
/// schema-aware conversion to the public camelCase shape with QuickType.
let supportedProtocolRelayMethods = [
CheckoutProtocol.complete.method,
CheckoutProtocol.error.method,
CheckoutProtocol.lineItemsChange.method,
CheckoutProtocol.messagesChange.method,
CheckoutProtocol.start.method,
CheckoutProtocol.totalsChange.method
RelayCuration.complete.method,
RelayCuration.error.method,
RelayCuration.lineItemsChange.method,
RelayCuration.messagesChange.method,
RelayCuration.start.method,
RelayCuration.totalsChange.method
]

func makeRelayClient(
subscribedMethods: [String],
dispatch: @escaping @MainActor @Sendable (String) -> Void
) -> CheckoutProtocol.Client {
var client = CheckoutProtocol.Client()
) -> CheckoutTransport.Client {
var client = CheckoutTransport.Client()

for method in subscribedMethods {
switch method {
case CheckoutProtocol.complete.method:
client = client.on(CheckoutProtocol.complete) { checkout in
case RelayCuration.complete.method:
client = client.on(RelayCuration.complete) { checkout in
forwardEnvelope(type: method, payload: checkout, dispatch: dispatch)
}
case CheckoutProtocol.error.method:
client = client.on(CheckoutProtocol.error) { error in
case RelayCuration.error.method:
client = client.on(RelayCuration.error) { error in
forwardEnvelope(type: method, payload: error, dispatch: dispatch)
}
case CheckoutProtocol.lineItemsChange.method:
client = client.on(CheckoutProtocol.lineItemsChange) { checkout in
case RelayCuration.lineItemsChange.method:
client = client.on(RelayCuration.lineItemsChange) { checkout in
forwardEnvelope(type: method, payload: checkout, dispatch: dispatch)
}
case CheckoutProtocol.messagesChange.method:
client = client.on(CheckoutProtocol.messagesChange) { checkout in
case RelayCuration.messagesChange.method:
client = client.on(RelayCuration.messagesChange) { checkout in
forwardEnvelope(type: method, payload: checkout, dispatch: dispatch)
}
case CheckoutProtocol.start.method:
client = client.on(CheckoutProtocol.start) { checkout in
case RelayCuration.start.method:
client = client.on(RelayCuration.start) { checkout in
forwardEnvelope(type: method, payload: checkout, dispatch: dispatch)
}
case CheckoutProtocol.totalsChange.method:
client = client.on(CheckoutProtocol.totalsChange) { checkout in
case RelayCuration.totalsChange.method:
client = client.on(RelayCuration.totalsChange) { checkout in
forwardEnvelope(type: method, payload: checkout, dispatch: dispatch)
}
default:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,80 @@
import type {CheckoutProtocolPayloads} from '@shopify/checkout-kit-protocol';

export {
CheckoutProtocol,
decodeCheckoutProtocolPayload as decodeProtocolPayload,
import {
generatedCheckoutProtocol,
generatedCheckoutProtocolPayloadDecoders,
type GeneratedCheckoutProtocolPayloads,
} from '@shopify/checkout-kit-protocol';

export type {
Checkout,
CheckoutProtocolPayloads,
ErrorResponse,
} from '@shopify/checkout-kit-protocol';
export type {Checkout, ErrorResponse} from '@shopify/checkout-kit-protocol';

type PublicCheckoutProtocolKey =
| 'complete'
| 'error'
| 'lineItemsChange'
| 'messagesChange'
| 'start'
| 'totalsChange';

export const CheckoutProtocol = {
complete: generatedCheckoutProtocol.complete,
error: generatedCheckoutProtocol.error,
lineItemsChange: generatedCheckoutProtocol.lineItemsChange,
messagesChange: generatedCheckoutProtocol.messagesChange,
start: generatedCheckoutProtocol.start,
totalsChange: generatedCheckoutProtocol.totalsChange,
} as const satisfies Pick<
typeof generatedCheckoutProtocol,
PublicCheckoutProtocolKey
>;

export type CheckoutProtocolMethod =
(typeof CheckoutProtocol)[keyof typeof CheckoutProtocol];

export type CheckoutProtocolPayloads = Pick<
GeneratedCheckoutProtocolPayloads,
CheckoutProtocolMethod
>;

type CheckoutProtocolPayloadDecoder<K extends keyof CheckoutProtocolPayloads> =
(payload: unknown) => CheckoutProtocolPayloads[K];

const checkoutProtocolPayloadDecoders = {
[CheckoutProtocol.complete]:
generatedCheckoutProtocolPayloadDecoders[CheckoutProtocol.complete],
[CheckoutProtocol.error]:
generatedCheckoutProtocolPayloadDecoders[CheckoutProtocol.error],
[CheckoutProtocol.lineItemsChange]:
generatedCheckoutProtocolPayloadDecoders[CheckoutProtocol.lineItemsChange],
[CheckoutProtocol.messagesChange]:
generatedCheckoutProtocolPayloadDecoders[CheckoutProtocol.messagesChange],
[CheckoutProtocol.start]:
generatedCheckoutProtocolPayloadDecoders[CheckoutProtocol.start],
[CheckoutProtocol.totalsChange]:
generatedCheckoutProtocolPayloadDecoders[CheckoutProtocol.totalsChange],
} satisfies {
[K in keyof CheckoutProtocolPayloads]: CheckoutProtocolPayloadDecoder<K>;
};

export function decodeProtocolPayload<K extends keyof CheckoutProtocolPayloads>(
method: K,
payload: unknown,
): CheckoutProtocolPayloads[K];
export function decodeProtocolPayload(
method: string,
payload: unknown,
): CheckoutProtocolPayloads[keyof CheckoutProtocolPayloads] | undefined;
export function decodeProtocolPayload(
method: string,
payload: unknown,
): CheckoutProtocolPayloads[keyof CheckoutProtocolPayloads] | undefined {
const decoder = checkoutProtocolPayloadDecoders[
method as keyof typeof checkoutProtocolPayloadDecoders
] as
| ((
payload: unknown,
) => CheckoutProtocolPayloads[keyof CheckoutProtocolPayloads])
| undefined;
return decoder?.(payload);
}

export type ProtocolHandlers = Partial<{
[K in keyof CheckoutProtocolPayloads]: (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import SafariServices
import ShopifyCheckoutKit
import ShopifyCheckoutProtocol
import UIKit

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@ public protocol CheckoutCommunicationProtocol: Sendable {
func process(_ message: String) async -> String?
}

extension CheckoutProtocol.Client: CheckoutCommunicationProtocol {}
extension CheckoutTransport.Client: CheckoutCommunicationProtocol {}
128 changes: 128 additions & 0 deletions platforms/swift/Sources/ShopifyCheckoutKit/CheckoutProtocol.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#if !COCOAPODS
import ShopifyCheckoutProtocol
#endif
import Foundation

public enum CheckoutProtocol {
public typealias Client = CheckoutTransport.Client

public static func url(for url: URL, delegations: [String] = []) -> URL {
CheckoutTransport.url(for: url, delegations: delegations)
}

public static let defaultDelegations: [String] = ["window.open"]

static let methodNotFoundCode = -32601
static let methodNotFoundMessage = "Method not found"

public static let complete = GeneratedProtocolCatalog.ecComplete
public static let error = GeneratedProtocolCatalog.ecError
public static let lineItemsChange = GeneratedProtocolCatalog.ecLineItemsChange
public static let messagesChange = GeneratedProtocolCatalog.ecMessagesChange
public static let start = GeneratedProtocolCatalog.ecStart
public static let totalsChange = GeneratedProtocolCatalog.ecTotalsChange

static let supportedProtocolMethods: Set<String> = [
CheckoutTransport.readyMethod,
start.method,
complete.method,
error.method,
lineItemsChange.method,
messagesChange.method,
totalsChange.method,
windowOpen.method
]

static func supportedProtocolMethod(_ message: String) -> String? {
guard
let envelope = try? JSONDecoder().decode(MethodEnvelope.self, from: Data(message.utf8)),
envelope.jsonrpc == "2.0",
supportedProtocolMethods.contains(envelope.method)
else {
return nil
}

return envelope.method
}

static func methodNotFoundResponse(forUnsupportedProtocolRequest message: String) -> String? {
guard
let request = try? JSONDecoder().decode(RequestEnvelope.self, from: Data(message.utf8)),
request.jsonrpc == "2.0",
!supportedProtocolMethods.contains(request.method),
let id = request.id
else {
return nil
}

let response = MethodNotFoundResponse(
id: id,
error: MethodNotFoundError(code: methodNotFoundCode, message: methodNotFoundMessage)
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
guard let data = try? encoder.encode(response) else { return nil }
return String(data: data, encoding: .utf8)
}
}

private struct MethodEnvelope: Decodable {
let jsonrpc: String
let method: String
}

private struct RequestEnvelope: Decodable {
let jsonrpc: String
let method: String
let id: RequestID?
}

enum RequestID: Codable, Equatable {
case string(String)
case int(Int64)
case null

init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()

if container.decodeNil() {
self = .null
} else if let value = try? container.decode(String.self) {
self = .string(value)
} else if let value = try? container.decode(Int64.self) {
self = .int(value)
} else {
throw DecodingError.typeMismatch(
RequestID.self,
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "JSON-RPC id must be a string, integer, or null"
)
)
}
}

func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()

switch self {
case let .string(value):
try container.encode(value)
case let .int(value):
try container.encode(value)
case .null:
try container.encodeNil()
}
}
}

private struct MethodNotFoundResponse: Encodable {
let jsonrpc = "2.0"
let id: RequestID
let error: MethodNotFoundError
}

private struct MethodNotFoundError: Encodable {
let code: Int
let message: String
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public struct ShopifyCheckout: UIViewControllerRepresentable, CheckoutConfigurab
var onFailAction: ((CheckoutError) -> Void)?

public init(checkout url: URL) {
checkoutURL = CheckoutProtocol.url(for: url)
checkoutURL = CheckoutTransport.url(for: url, delegations: CheckoutProtocol.defaultDelegations)
}

public func makeUIViewController(context _: Self.Context) -> CheckoutViewController {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ class CheckoutWebView: WKWebView {
/// 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()
lazy var defaultsClient: CheckoutTransport.Client = .init()
.on(CheckoutProtocol.complete) { _ in
CheckoutWebView.invalidate(disconnect: false)
}
Expand Down Expand Up @@ -458,7 +458,8 @@ extension CheckoutWebView: WKScriptMessageHandler {
return
}

if method == CheckoutProtocol.readyMethod, let response = CheckoutProtocol.acknowledgeReady(body) {
if method == CheckoutTransport.readyMethod,
let response = CheckoutTransport.acknowledgeReady(body, supportedDelegations: CheckoutProtocol.defaultDelegations) {
OSLogger.shared.debug("Handling ec.ready: sending UCP ready acknowledgement, isPreload: \(isPreloadRequest)")
Task { @MainActor in
await checkoutBridge.sendResponse(self, messageBody: response)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public func preload(checkout url: URL) {
return
}

let decorated = CheckoutProtocol.url(for: url)
let decorated = CheckoutTransport.url(for: url, delegations: CheckoutProtocol.defaultDelegations)
CheckoutWebView.preload(checkout: decorated)
}

Expand All @@ -61,7 +61,7 @@ public func invalidate() {
@MainActor
@discardableResult
public func present(checkout url: URL, from: UIViewController, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil) -> CheckoutViewController {
let decorated = CheckoutProtocol.url(for: url)
let decorated = CheckoutTransport.url(for: url, delegations: CheckoutProtocol.defaultDelegations)
let viewController = CheckoutViewController(checkout: decorated, delegate: delegate, client: client)
from.present(viewController, animated: true)
return viewController
Expand All @@ -70,7 +70,7 @@ public func present(checkout url: URL, from: UIViewController, delegate: (any Ch
@MainActor
@discardableResult
package func present(checkout url: URL, from: UIViewController, entryPoint: MetaData.EntryPoint, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil) -> CheckoutViewController {
let decorated = CheckoutProtocol.url(for: url)
let decorated = CheckoutTransport.url(for: url, delegations: CheckoutProtocol.defaultDelegations)
let viewController = CheckoutViewController(checkout: decorated, delegate: delegate, client: client, entryPoint: entryPoint)
from.present(viewController, animated: true)
return viewController
Expand Down
Loading
Loading