diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ProtocolRelay.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ProtocolRelay.swift index da135ca2..09a35505 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ProtocolRelay.swift +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ProtocolRelay.swift @@ -2,9 +2,27 @@ 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 struct DispatchEnvelope: Encodable { @@ -12,48 +30,48 @@ struct DispatchEnvelope: Encodable { 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: diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/protocol.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/protocol.ts index 09da08c3..25cafab6 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/protocol.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/protocol.ts @@ -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 = + (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; +}; + +export function decodeProtocolPayload( + 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]: ( diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/CheckoutProtocolClient.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/CheckoutProtocolClient.swift index ca311f05..64ee0433 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/CheckoutProtocolClient.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/CheckoutProtocolClient.swift @@ -1,4 +1,5 @@ import SafariServices +import ShopifyCheckoutKit import ShopifyCheckoutProtocol import UIKit diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutCommunicationProtocol.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutCommunicationProtocol.swift index 709d9889..5ab4fe46 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutCommunicationProtocol.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutCommunicationProtocol.swift @@ -7,4 +7,4 @@ public protocol CheckoutCommunicationProtocol: Sendable { func process(_ message: String) async -> String? } -extension CheckoutProtocol.Client: CheckoutCommunicationProtocol {} +extension CheckoutTransport.Client: CheckoutCommunicationProtocol {} diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutProtocol.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutProtocol.swift new file mode 100644 index 00000000..19f36a39 --- /dev/null +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutProtocol.swift @@ -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 = [ + 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 +} diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift index 0f59fc30..d9641a8b 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift @@ -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 { diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index 8b12c432..0f93118c 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -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) } @@ -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) diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift index 32f56790..0ec705c8 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift @@ -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) } @@ -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 @@ -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 diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/WindowOpen.swift b/platforms/swift/Sources/ShopifyCheckoutKit/WindowOpen.swift similarity index 78% rename from protocol/languages/swift/Sources/ShopifyCheckoutProtocol/WindowOpen.swift rename to platforms/swift/Sources/ShopifyCheckoutKit/WindowOpen.swift index 213e15eb..31c098e6 100644 --- a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/WindowOpen.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/WindowOpen.swift @@ -1,3 +1,6 @@ +#if !COCOAPODS + import ShopifyCheckoutProtocol +#endif import Foundation public struct WindowOpenRequest: EventPayload { @@ -37,12 +40,12 @@ public enum WindowOpenResult: ResponsePayload { public func encode(to encoder: Encoder) throws { switch self { case .success: - try UCPSuccessResult( - ucp: UCPSuccess(version: CheckoutProtocol.specVersion) + try WindowOpenSuccessBody( + ucp: WindowOpenUCP(version: CheckoutTransport.specVersion, status: "success") ).encode(to: encoder) case let .rejected(reason): try WindowOpenRejectedBody( - ucp: UCPError(version: CheckoutProtocol.specVersion), + ucp: WindowOpenUCP(version: CheckoutTransport.specVersion, status: "error"), messages: [ WindowOpenRejectedMessage(content: reason ?? "Window open rejected") ] @@ -53,7 +56,7 @@ public enum WindowOpenResult: ResponsePayload { extension CheckoutProtocol { public static let windowOpen = DelegationDescriptor( - method: "ec.window.open_request", + method: GeneratedProtocolCatalog.ecWindowOpenRequest.method, delegation: "window.open", decode: { params in try? JSONDecoder().decode(WindowOpenRequest.self, from: params) @@ -61,8 +64,17 @@ extension CheckoutProtocol { ) } +private struct WindowOpenUCP: Encodable { + let version: String + let status: String +} + +private struct WindowOpenSuccessBody: Encodable { + let ucp: WindowOpenUCP +} + private struct WindowOpenRejectedBody: Encodable { - let ucp: UCPError + let ucp: WindowOpenUCP let messages: [WindowOpenRejectedMessage] } diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutProtocolTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutProtocolTests.swift new file mode 100644 index 00000000..a3af43bf --- /dev/null +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutProtocolTests.swift @@ -0,0 +1,157 @@ +import Foundation +#if !COCOAPODS + import ShopifyCheckoutProtocol +#endif +@testable import ShopifyCheckoutKit +import Testing + +@Suite("Embedded Checkout Protocol Curation") +struct CheckoutProtocolTests { + @Test func defaultDelegationsAdvertiseWindowOpen() { + #expect(CheckoutProtocol.defaultDelegations == ["window.open"]) + } + + @Test func supportedProtocolMethodsCoverReadyCuratedNotificationsAndWindowOpen() { + #expect(CheckoutProtocol.supportedProtocolMethods == [ + CheckoutTransport.readyMethod, + "ec.start", + "ec.complete", + "ec.error", + "ec.line_items.change", + "ec.messages.change", + "ec.totals.change", + "ec.window.open_request" + ]) + } + + @Test func supportedProtocolMethodsExcludeUncuratedCatalogMethods() { + #expect(!CheckoutProtocol.supportedProtocolMethods.contains("ec.payment.credential_request")) + #expect(!CheckoutProtocol.supportedProtocolMethods.contains("ec.fulfillment.change")) + #expect(!CheckoutProtocol.supportedProtocolMethods.contains("ep.cart.ready")) + } + + @Test func supportedProtocolMethodParsesValidSupportedMessage() { + let message = #"{"jsonrpc":"2.0","method":"ec.start","params":{"checkout":{}}}"# + #expect(CheckoutProtocol.supportedProtocolMethod(message) == "ec.start") + } + + @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) + } + + @Test func methodNotFoundResponseEncodesUnsupportedRequests() throws { + let response = try #require( + CheckoutProtocol.methodNotFoundResponse( + forUnsupportedProtocolRequest: #"{"jsonrpc":"2.0","method":"custom","id":"unsupported","params":{}}"# + ) + ) + let object = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + + #expect(object["jsonrpc"] as? String == "2.0") + #expect(object["id"] as? String == "unsupported") + let error = try #require(object["error"] as? [String: Any]) + #expect(error["code"] as? Int == CheckoutProtocol.methodNotFoundCode) + #expect(error["message"] as? String == CheckoutProtocol.methodNotFoundMessage) + } + + @Test func methodNotFoundResponsePreservesNumericRequestID() throws { + let response = try #require( + CheckoutProtocol.methodNotFoundResponse( + forUnsupportedProtocolRequest: #"{"jsonrpc":"2.0","method":"custom","id":7,"params":{}}"# + ) + ) + let object = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + #expect(object["id"] as? Int == 7) + } + + @Test func methodNotFoundResponsePreservesNullRequestID() throws { + let response = try #require( + CheckoutProtocol.methodNotFoundResponse( + forUnsupportedProtocolRequest: #"{"jsonrpc":"2.0","method":"custom","id":null,"params":{}}"# + ) + ) + let object = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + #expect(object["id"] is NSNull) + } + + @Test func methodNotFoundResponseRejectsInvalidRequestIDs() { + #expect(CheckoutProtocol.methodNotFoundResponse(forUnsupportedProtocolRequest: #"{"jsonrpc":"2.0","method":"custom","id":true,"params":{}}"#) == nil) + #expect(CheckoutProtocol.methodNotFoundResponse(forUnsupportedProtocolRequest: #"{"jsonrpc":"2.0","method":"custom","id":{},"params":{}}"#) == nil) + #expect(CheckoutProtocol.methodNotFoundResponse(forUnsupportedProtocolRequest: #"{"jsonrpc":"2.0","method":"custom","id":1.5,"params":{}}"#) == nil) + } + + @Test func methodNotFoundResponseRejectsSupportedNotificationsOrInvalidMessages() { + #expect(CheckoutProtocol.methodNotFoundResponse(forUnsupportedProtocolRequest: #"{"jsonrpc":"2.0","method":"custom"}"#) == nil) + #expect(CheckoutProtocol.methodNotFoundResponse(forUnsupportedProtocolRequest: #"{"jsonrpc":"2.0","method":"ec.start","id":"supported"}"#) == nil) + #expect(CheckoutProtocol.methodNotFoundResponse(forUnsupportedProtocolRequest: #"{"jsonrpc":"1.0","method":"custom","id":"unsupported"}"#) == nil) + #expect(CheckoutProtocol.methodNotFoundResponse(forUnsupportedProtocolRequest: "not json") == nil) + } +} + +@Suite("Window Open Delegation") +struct WindowOpenDelegationTests { + @Test func descriptorBindsWindowOpenMethodAndDelegation() { + #expect(CheckoutProtocol.windowOpen.method == "ec.window.open_request") + #expect(CheckoutProtocol.windowOpen.delegation == "window.open") + } + + @Test func requestPayloadDecodesValidURL() throws { + let payload = try JSONDecoder().decode( + WindowOpenRequest.self, + from: Data(#"{"url":"https://example.com/terms"}"#.utf8) + ) + #expect(payload.url == URL(string: "https://example.com/terms")) + } + + @Test func requestPayloadRejectsEmptyURL() { + #expect((try? JSONDecoder().decode(WindowOpenRequest.self, from: Data(#"{"url":""}"#.utf8))) == nil) + } + + @Test func requestPayloadRejectsMissingURL() { + #expect((try? JSONDecoder().decode(WindowOpenRequest.self, from: Data("{}".utf8))) == nil) + } + + @Test func requestPayloadRejectsNullURL() { + #expect((try? JSONDecoder().decode(WindowOpenRequest.self, from: Data(#"{"url":null}"#.utf8))) == nil) + } + + private struct EncodingFailure: Error {} + + private func encode(_ result: WindowOpenResult) throws -> [String: Any] { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(result) + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw EncodingFailure() + } + return object + } + + @Test func resultEncodesSuccessBody() throws { + let body = try encode(.success) + let ucp = try #require(body["ucp"] as? [String: Any]) + #expect(ucp["status"] as? String == "success") + #expect(ucp["version"] as? String == CheckoutTransport.specVersion) + } + + @Test func resultEncodesRejectedBody() throws { + let body = try encode(.rejected(reason: "canOpenURL returned false")) + let ucp = try #require(body["ucp"] as? [String: Any]) + #expect(ucp["status"] as? String == "error") + + let messages = try #require(body["messages"] as? [[String: Any]]) + #expect(messages.count == 1) + #expect(messages[0]["type"] as? String == "error") + #expect(messages[0]["code"] as? String == "window_open_rejected_error") + #expect(messages[0]["severity"] as? String == "unrecoverable") + #expect(messages[0]["content"] as? String == "canOpenURL returned false") + } + + @Test func resultEncodesRejectedWithNilReason() throws { + let body = try encode(.rejected(reason: nil)) + let messages = try #require(body["messages"] as? [[String: Any]]) + #expect(messages[0]["content"] as? String != "") + } +} diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift index 2666ebee..e56701c9 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift @@ -75,7 +75,7 @@ class CheckoutWebViewControllerTests: XCTestCase { func test_presentationControllerDidDismiss_doesNotCleanUpBeforeViewDisappears() throws { ShopifyCheckoutKit.configuration.preloading.enabled = true ShopifyCheckoutKit.preload(checkout: url) - let viewController = TestableCheckoutWebViewController(checkoutURL: CheckoutProtocol.url(for: url), entryPoint: nil) + let viewController = TestableCheckoutWebViewController(checkoutURL: CheckoutTransport.url(for: url), entryPoint: nil) viewController.loadViewIfNeeded() let checkoutView = try XCTUnwrap(viewController.checkoutView) @@ -93,7 +93,7 @@ class CheckoutWebViewControllerTests: XCTestCase { func test_viewDidDisappear_cleansUpConsumedPreloadedWebViewWhenDismissed() throws { ShopifyCheckoutKit.configuration.preloading.enabled = true ShopifyCheckoutKit.preload(checkout: url) - let viewController = TestableCheckoutWebViewController(checkoutURL: CheckoutProtocol.url(for: url), entryPoint: nil) + let viewController = TestableCheckoutWebViewController(checkoutURL: CheckoutTransport.url(for: url), entryPoint: nil) viewController.loadViewIfNeeded() let checkoutView = try XCTUnwrap(viewController.checkoutView) diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index 4894c9f8..48ba4fc7 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -276,7 +276,7 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertTrue(CheckoutWebView.preloadCache.hasEntry()) XCTAssertTrue(CheckoutWebView.preloadCache.hasActiveKeepAlive()) - let cached = CheckoutWebView.for(checkout: CheckoutProtocol.url(for: url)) + let cached = CheckoutWebView.for(checkout: CheckoutTransport.url(for: url)) XCTAssertTrue(CheckoutWebView.preloadCache.hasEntry()) XCTAssertFalse(CheckoutWebView.preloadCache.hasActiveKeepAlive()) @@ -286,7 +286,7 @@ class CheckoutWebViewTests: XCTestCase { func testRepeatedPreloadForMatchingCheckoutDoesNotReloadCachedWebView() { let webView = LoadedRequestObservableWebView() - let checkoutURL = CheckoutProtocol.url(for: url) + let checkoutURL = CheckoutTransport.url(for: url) _ = CheckoutWebView.preloadCache.store(webView, for: PreloadKey(url: checkoutURL, entryPoint: nil)) CheckoutWebView.preload(checkout: checkoutURL) @@ -297,8 +297,8 @@ class CheckoutWebViewTests: XCTestCase { func testPresentingMatchingCheckoutReusesCachedWebViewWithoutEvictingIt() { ShopifyCheckoutKit.preload(checkout: url) - let first = CheckoutWebView.for(checkout: CheckoutProtocol.url(for: url)) - let second = CheckoutWebView.for(checkout: CheckoutProtocol.url(for: url)) + let first = CheckoutWebView.for(checkout: CheckoutTransport.url(for: url)) + let second = CheckoutWebView.for(checkout: CheckoutTransport.url(for: url)) XCTAssertTrue(first === second) XCTAssertTrue(CheckoutWebView.preloadCache.hasEntry()) @@ -308,7 +308,7 @@ class CheckoutWebViewTests: XCTestCase { ShopifyCheckoutKit.preload(checkout: url) let otherURL = try XCTUnwrap(URL(string: "http://shopify1.shopify.com/checkouts/cn/456")) - let fresh = CheckoutWebView.for(checkout: CheckoutProtocol.url(for: otherURL)) + let fresh = CheckoutWebView.for(checkout: CheckoutTransport.url(for: otherURL)) XCTAssertNil(fresh.url) XCTAssertFalse(CheckoutWebView.preloadCache.hasEntry()) @@ -316,9 +316,9 @@ class CheckoutWebViewTests: XCTestCase { } func testPresentWithDifferentEntryPointDoesNotReusePreloadedWebView() { - CheckoutWebView.preload(checkout: CheckoutProtocol.url(for: url), entryPoint: .acceleratedCheckouts) + CheckoutWebView.preload(checkout: CheckoutTransport.url(for: url), entryPoint: .acceleratedCheckouts) - let fresh = CheckoutWebView.for(checkout: CheckoutProtocol.url(for: url), entryPoint: nil) + let fresh = CheckoutWebView.for(checkout: CheckoutTransport.url(for: url), entryPoint: nil) XCTAssertNil(fresh.url) XCTAssertFalse(CheckoutWebView.preloadCache.hasEntry()) @@ -338,12 +338,12 @@ class CheckoutWebViewTests: XCTestCase { func testStalePreloadCacheIsRejectedImmediately() { let staleCreatedAt = Date(timeIntervalSinceNow: -6 * 60) - CheckoutWebView.preload(checkout: CheckoutProtocol.url(for: url), createdAt: staleCreatedAt) + CheckoutWebView.preload(checkout: CheckoutTransport.url(for: url), createdAt: staleCreatedAt) XCTAssertFalse(CheckoutWebView.preloadCache.hasEntry()) XCTAssertFalse(CheckoutWebView.preloadCache.hasActiveKeepAlive()) - let fresh = CheckoutWebView.for(checkout: CheckoutProtocol.url(for: url)) + let fresh = CheckoutWebView.for(checkout: CheckoutTransport.url(for: url)) XCTAssertNil(fresh.url) XCTAssertTrue(fresh.isBridgeAttached) @@ -353,7 +353,7 @@ class CheckoutWebViewTests: XCTestCase { func testPreloadCacheExpiresAndStopsKeepAlive() { let nearlyStaleCreatedAt = Date(timeIntervalSinceNow: -(5 * 60 - 0.1)) - CheckoutWebView.preload(checkout: CheckoutProtocol.url(for: url), createdAt: nearlyStaleCreatedAt) + CheckoutWebView.preload(checkout: CheckoutTransport.url(for: url), createdAt: nearlyStaleCreatedAt) XCTAssertTrue(CheckoutWebView.preloadCache.hasEntry()) XCTAssertTrue(CheckoutWebView.preloadCache.hasActiveKeepAlive()) @@ -378,7 +378,7 @@ class CheckoutWebViewTests: XCTestCase { func testPreloadKeepAliveFailureInvalidatesCache() { let webView = ThrowingEvaluateJavaScriptWebView() - _ = CheckoutWebView.preloadCache.store(webView, for: PreloadKey(url: CheckoutProtocol.url(for: url), entryPoint: nil)) + _ = CheckoutWebView.preloadCache.store(webView, for: PreloadKey(url: CheckoutTransport.url(for: url), entryPoint: nil)) XCTAssertTrue(CheckoutWebView.preloadCache.hasEntry()) XCTAssertTrue(CheckoutWebView.preloadCache.hasActiveKeepAlive()) @@ -403,7 +403,7 @@ class CheckoutWebViewTests: XCTestCase { func testInvalidateDetachesCachedPreloadedWebView() { ShopifyCheckoutKit.preload(checkout: url) - let cached = CheckoutWebView.for(checkout: CheckoutProtocol.url(for: url)) + let cached = CheckoutWebView.for(checkout: CheckoutTransport.url(for: url)) XCTAssertTrue(cached.isBridgeAttached) ShopifyCheckoutKit.invalidate() @@ -414,7 +414,7 @@ class CheckoutWebViewTests: XCTestCase { func testHTTPErrorInvalidatesPreloadCache() throws { ShopifyCheckoutKit.preload(checkout: url) - let cached = CheckoutWebView.for(checkout: CheckoutProtocol.url(for: url)) + let cached = CheckoutWebView.for(checkout: CheckoutTransport.url(for: url)) let link = try XCTUnwrap(cached.url) let response = try XCTUnwrap(HTTPURLResponse(url: link, statusCode: 403, httpVersion: nil, headerFields: nil)) @@ -536,7 +536,7 @@ class CheckoutWebViewTests: XCTestCase { let result = try XCTUnwrap(parsed["result"] as? [String: Any]) let ucp = try XCTUnwrap(result["ucp"] as? [String: Any]) XCTAssertEqual(ucp["status"] as? String, "success") - XCTAssertEqual(ucp["version"] as? String, CheckoutProtocol.specVersion) + XCTAssertEqual(ucp["version"] as? String, CheckoutTransport.specVersion) } @MainActor @@ -707,7 +707,7 @@ class CheckoutWebViewTests: XCTestCase { let body = #"{"jsonrpc":"2.0","method":"ec.window.open_request","id":"\#(id)","params":{"url":"https://example.com/terms"}}"# let responseSent = expectation(description: "response sent") MockCheckoutBridge.sendResponseExpectation = responseSent - view.client = CheckoutProtocol.Client() + view.client = CheckoutTransport.Client() .on(CheckoutProtocol.windowOpen) { _ in .rejected(reason: "consumer override") } @@ -772,7 +772,7 @@ class CheckoutWebViewTests: XCTestCase { /// and bypass the handler entirely. private func ecErrorBody(severity: String) -> String { return """ - {"jsonrpc":"2.0","method":"ec.error","params":{"error":{"ucp":{"status":"error","version":"\(CheckoutProtocol.specVersion)"},"messages":[{"type":"error","code":"session_failed","content":"Session failed","severity":"\(severity)"}]}}} + {"jsonrpc":"2.0","method":"ec.error","params":{"error":{"ucp":{"status":"error","version":"\(CheckoutTransport.specVersion)"},"messages":[{"type":"error","code":"session_failed","content":"Session failed","severity":"\(severity)"}]}}} """ } @@ -840,7 +840,7 @@ class CheckoutWebViewTests: XCTestCase { let consumerHandlerFired = expectation(description: "consumer handler fired") let dismissed = expectation(description: "viewDelegate received failure") mockDelegate.didFailWithErrorExpectation = dismissed - view.client = CheckoutProtocol.Client() + view.client = CheckoutTransport.Client() .on(CheckoutProtocol.error) { _ in consumerHandlerFired.fulfill() } diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol.swift deleted file mode 100644 index 55c6c31d..00000000 --- a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol.swift +++ /dev/null @@ -1,67 +0,0 @@ -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" - package static let parseErrorCode = -32700 - package static let parseErrorMessage = "Parse error" - package static let methodNotFoundCode = -32601 - package static let methodNotFoundMessage = "Method not found" - - public static let complete = NotificationDescriptor(method: "ec.complete") - public static let error = NotificationDescriptor(method: "ec.error") - public static let lineItemsChange = NotificationDescriptor( - method: "ec.line_items.change" - ) - public static let messagesChange = NotificationDescriptor( - method: "ec.messages.change" - ) - 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 envelope = try? JSONDecoder().decode(JSONRPCEnvelope.self, from: Data(message.utf8)), - envelope.jsonrpc == "2.0", - supportedProtocolMethods.contains(envelope.method) - else { - return nil - } - - return envelope.method - } - - package static func methodNotFoundResponse(forUnsupportedProtocolRequest message: String) -> String? { - guard - let request = try? JSONDecoder().decode(JSONRPCEnvelope.self, from: Data(message.utf8)), - request.jsonrpc == "2.0", - !supportedProtocolMethods.contains(request.method), - let id = request.id - else { - return nil - } - - let response = JSONRPCErrorResponse( - id: id, - error: JSONRPCError(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) - } -} diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol+URL.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutTransport+URL.swift similarity index 91% rename from protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol+URL.swift rename to protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutTransport+URL.swift index a876879b..01da875e 100644 --- a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol+URL.swift +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutTransport+URL.swift @@ -1,12 +1,12 @@ import Foundation -extension CheckoutProtocol { +extension CheckoutTransport { /// Returns the given checkout URL with the query parameters required to /// initiate the Embedded Checkout Protocol handshake (`ec_version`, /// `ec_delegate`). public static func url( for url: URL, - delegations: [String] = defaultDelegations + delegations: [String] = [] ) -> URL { guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutTransport.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutTransport.swift new file mode 100644 index 00000000..2e3bb47e --- /dev/null +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutTransport.swift @@ -0,0 +1,9 @@ +import Foundation + +public enum CheckoutTransport { + public static let specVersion = "2026-04-08" + + package static let readyMethod = "ec.ready" + package static let parseErrorCode = -32700 + package static let parseErrorMessage = "Parse error" +} diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Client.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Client.swift index 9a3c992c..8f1f2e26 100644 --- a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Client.swift +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Client.swift @@ -1,6 +1,6 @@ import Foundation -extension CheckoutProtocol { +extension CheckoutTransport { public struct Client: Sendable, MutableCopyable { private var notificationHandlers: [String: @MainActor @Sendable (any EventPayload) -> Void] private var delegationEntries: [String: DelegationEntry] @@ -38,22 +38,22 @@ extension CheckoutProtocol { handler: { id, params in guard let payload = descriptor.decode(params) else { return nil } let result = await perform(payload) - return CheckoutProtocol.encodeResponse(id: id, result: result) + return CheckoutTransport.encodeResponse(id: id, result: result) } ) } } public func process(_ message: String) async -> String? { - let decoded = CheckoutProtocol.decode(jsonRpc: message) + let decoded = CheckoutTransport.decode(jsonRpc: message) switch decoded { case let .ready(id, requested): let accepted = requested.filter(Set(delegations).contains) - return CheckoutProtocol.encodeReadyResponse(id: id, acceptedDelegations: accepted) + return CheckoutTransport.encodeReadyResponse(id: id, acceptedDelegations: accepted) case let .error(id, code, message): - return CheckoutProtocol.encodeErrorResponse(id: id, code: code, message: message) + return CheckoutTransport.encodeErrorResponse(id: id, code: code, message: message) case let .notification(method, payload): await notificationHandlers[method]?(payload) diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Codec.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Codec.swift index 2bde1ae7..fa361af1 100644 --- a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Codec.swift +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Codec.swift @@ -1,12 +1,12 @@ import Foundation -extension CheckoutProtocol { +extension CheckoutTransport { /// Returns an `ec.ready` response if the given message is an `ec.ready` request, /// otherwise `nil`. The response echoes the intersection of the merchant's /// requested delegations with `supportedDelegations` under a `delegate` array. public static func acknowledgeReady( _ message: String, - supportedDelegations: [String] = CheckoutProtocol.defaultDelegations + supportedDelegations: [String] = [] ) -> String? { switch decode(jsonRpc: message) { case let .ready(id, requested): @@ -22,7 +22,7 @@ extension CheckoutProtocol { } } -extension CheckoutProtocol { +extension CheckoutTransport { static func decode(jsonRpc: String) -> UCPMessage { guard let data = jsonRpc.data(using: .utf8) else { return .unknown(method: "", rawParams: jsonRpc) diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Descriptors.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Descriptors.swift index 488dff15..36574e80 100644 --- a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Descriptors.swift +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Descriptors.swift @@ -10,11 +10,6 @@ public protocol EventPayload: Decodable, Sendable {} /// is explicit — preventing arbitrary `Encodable & Sendable` types from silently matching. public protocol ResponsePayload: Encodable, Sendable {} -extension Checkout: EventPayload {} -extension ErrorResponse: EventPayload {} -extension InstrumentsChangeResult: ResponsePayload {} -extension CredentialResult: ResponsePayload {} - public struct NotificationDescriptor: Sendable { public let method: String } diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Generated/Catalog.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Generated/Catalog.swift new file mode 100644 index 00000000..2c101129 --- /dev/null +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Generated/Catalog.swift @@ -0,0 +1,60 @@ +// This file is generated by protocol/scripts/generate_swift_catalog.mjs. +// Do not edit directly. + +import Foundation + +extension Checkout: EventPayload {} +extension ErrorResponse: EventPayload {} +extension JSONAny: EventPayload {} + +public enum GeneratedProtocolCatalog { + public static let ecReady = NotificationDescriptor(method: "ec.ready") + public static let ecAuth = NotificationDescriptor(method: "ec.auth") + public static let ecError = NotificationDescriptor(method: "ec.error") + public static let ecStart = NotificationDescriptor(method: "ec.start") + public static let ecComplete = NotificationDescriptor(method: "ec.complete") + public static let ecMessagesChange = NotificationDescriptor(method: "ec.messages.change") + public static let ecLineItemsChange = NotificationDescriptor(method: "ec.line_items.change") + public static let ecBuyerChange = NotificationDescriptor(method: "ec.buyer.change") + public static let ecTotalsChange = NotificationDescriptor(method: "ec.totals.change") + public static let ecPaymentChange = NotificationDescriptor(method: "ec.payment.change") + public static let ecPaymentInstrumentsChangeRequest = NotificationDescriptor(method: "ec.payment.instruments_change_request") + public static let ecPaymentCredentialRequest = NotificationDescriptor(method: "ec.payment.credential_request") + public static let ecWindowOpenRequest = NotificationDescriptor(method: "ec.window.open_request") + public static let ecFulfillmentChange = NotificationDescriptor(method: "ec.fulfillment.change") + public static let ecFulfillmentAddressChangeRequest = NotificationDescriptor(method: "ec.fulfillment.address_change_request") + public static let epCartReady = NotificationDescriptor(method: "ep.cart.ready") + public static let epCartAuth = NotificationDescriptor(method: "ep.cart.auth") + public static let epCartError = NotificationDescriptor(method: "ep.cart.error") + public static let epCartStart = NotificationDescriptor(method: "ep.cart.start") + public static let epCartComplete = NotificationDescriptor(method: "ep.cart.complete") + public static let epCartLineItemsChange = NotificationDescriptor(method: "ep.cart.line_items.change") + public static let epCartBuyerChange = NotificationDescriptor(method: "ep.cart.buyer.change") + public static let epCartMessagesChange = NotificationDescriptor(method: "ep.cart.messages.change") + + public static let allMethods: [String] = [ + ecReady.method, + ecAuth.method, + ecError.method, + ecStart.method, + ecComplete.method, + ecMessagesChange.method, + ecLineItemsChange.method, + ecBuyerChange.method, + ecTotalsChange.method, + ecPaymentChange.method, + ecPaymentInstrumentsChangeRequest.method, + ecPaymentCredentialRequest.method, + ecWindowOpenRequest.method, + ecFulfillmentChange.method, + ecFulfillmentAddressChangeRequest.method, + epCartReady.method, + epCartAuth.method, + epCartError.method, + epCartStart.method, + epCartComplete.method, + epCartLineItemsChange.method, + epCartBuyerChange.method, + epCartMessagesChange.method, + ] +} diff --git a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CheckoutProtocolURLTests.swift b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CheckoutTransportURLTests.swift similarity index 66% rename from protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CheckoutProtocolURLTests.swift rename to protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CheckoutTransportURLTests.swift index 4595583c..877d57af 100644 --- a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CheckoutProtocolURLTests.swift +++ b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CheckoutTransportURLTests.swift @@ -2,7 +2,7 @@ import Foundation @testable import ShopifyCheckoutProtocol import Testing -@Suite("CheckoutProtocol URL Tests") +@Suite("CheckoutTransport URL Tests") struct CheckoutProtocolURLTests { private let baseURL = URL(string: "https://shop.com/cart/c/abc")! @@ -11,17 +11,22 @@ struct CheckoutProtocolURLTests { } @Test func appendsEcVersion() { - let items = queryItems(CheckoutProtocol.url(for: baseURL)) - #expect(items.contains(URLQueryItem(name: "ec_version", value: CheckoutProtocol.specVersion))) + let items = queryItems(CheckoutTransport.url(for: baseURL)) + #expect(items.contains(URLQueryItem(name: "ec_version", value: CheckoutTransport.specVersion))) } - @Test func appendsDefaultDelegate() { - let items = queryItems(CheckoutProtocol.url(for: baseURL)) + @Test func omitsDelegateByDefault() { + let items = queryItems(CheckoutTransport.url(for: baseURL)) + #expect(!items.contains(where: { $0.name == "ec_delegate" })) + } + + @Test func appendsSuppliedDelegate() { + let items = queryItems(CheckoutTransport.url(for: baseURL, delegations: ["window.open"])) #expect(items.contains(URLQueryItem(name: "ec_delegate", value: "window.open"))) } @Test func joinsMultipleDelegationsWithComma() { - let result = CheckoutProtocol.url( + let result = CheckoutTransport.url( for: baseURL, delegations: ["window.open", "payment.credential"] ) @@ -30,29 +35,29 @@ struct CheckoutProtocolURLTests { } @Test func omitsDelegateWhenEmpty() { - let items = queryItems(CheckoutProtocol.url(for: baseURL, delegations: [])) + let items = queryItems(CheckoutTransport.url(for: baseURL, delegations: [])) #expect(!items.contains(where: { $0.name == "ec_delegate" })) } @Test func preservesExistingQueryItems() throws { let url = try #require(URL(string: "https://shop.com/cart/c/abc?key=cart_token&utm_source=email")) - let items = queryItems(CheckoutProtocol.url(for: url)) + let items = queryItems(CheckoutTransport.url(for: url)) #expect(items.contains(URLQueryItem(name: "key", value: "cart_token"))) #expect(items.contains(URLQueryItem(name: "utm_source", value: "email"))) - #expect(items.contains(URLQueryItem(name: "ec_version", value: CheckoutProtocol.specVersion))) + #expect(items.contains(URLQueryItem(name: "ec_version", value: CheckoutTransport.specVersion))) } @Test func replacesCallerSuppliedProtocolQueryItems() throws { let url = try #require(URL(string: "https://shop.com/cart/c/abc?ec_version=stale&ec_delegate=custom")) - let items = queryItems(CheckoutProtocol.url(for: url)) + let items = queryItems(CheckoutTransport.url(for: url, delegations: ["window.open"])) - #expect(items.filter { $0.name == "ec_version" }.map(\.value) == [CheckoutProtocol.specVersion]) + #expect(items.filter { $0.name == "ec_version" }.map(\.value) == [CheckoutTransport.specVersion]) #expect(items.filter { $0.name == "ec_delegate" }.map(\.value) == ["window.open"]) } @Test func isIdempotentOnRecall() { - let once = CheckoutProtocol.url(for: baseURL) - let twice = CheckoutProtocol.url(for: once) + let once = CheckoutTransport.url(for: baseURL, delegations: ["window.open"]) + let twice = CheckoutTransport.url(for: once, delegations: ["window.open"]) let items = queryItems(twice) #expect(items.filter { $0.name == "ec_version" }.count == 1) @@ -61,9 +66,9 @@ struct CheckoutProtocolURLTests { @Test func removesExistingDelegationWhenDelegationsAreEmpty() throws { let url = try #require(URL(string: "https://shop.com/cart/c/abc?ec_delegate=custom")) - let items = queryItems(CheckoutProtocol.url(for: url, delegations: [])) + let items = queryItems(CheckoutTransport.url(for: url, delegations: [])) - #expect(items.contains(URLQueryItem(name: "ec_version", value: CheckoutProtocol.specVersion))) + #expect(items.contains(URLQueryItem(name: "ec_version", value: CheckoutTransport.specVersion))) #expect(!items.contains { $0.name == "ec_delegate" }) } } diff --git a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/ClientTests.swift b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/ClientTests.swift index 3f32d899..697a7316 100644 --- a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/ClientTests.swift +++ b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/ClientTests.swift @@ -2,6 +2,49 @@ import Foundation @testable import ShopifyCheckoutProtocol import Testing +private struct TestURLPayload: EventPayload { + let url: URL? + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let raw = try container.decode(String.self, forKey: .url) + url = URL(string: raw) + } + + private enum CodingKeys: String, CodingKey { + case url + } +} + +private enum TestDelegationResult: ResponsePayload { + case success + case rejected(reason: String) + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .success: + try container.encode(["status": "success"], forKey: .ucp) + case let .rejected(reason): + try container.encode(["status": "error"], forKey: .ucp) + try container.encode([["content": reason]], forKey: .messages) + } + } + + private enum CodingKeys: String, CodingKey { + case ucp + case messages + } +} + +private let windowOpenDescriptor = DelegationDescriptor( + method: GeneratedProtocolCatalog.ecWindowOpenRequest.method, + delegation: "window.open", + decode: { params in + try? JSONDecoder().decode(TestURLPayload.self, from: params) + } +) + @Suite("Client Tests") struct ClientTests { private func notificationFixture() throws -> String { @@ -16,8 +59,8 @@ struct ClientTests { @Test @MainActor func notificationDispatchesToRegisteredHandler() async throws { var receivedCheckout: Checkout? - let client = CheckoutProtocol.Client() - .on(CheckoutProtocol.start) { checkout in + let client = CheckoutTransport.Client() + .on(GeneratedProtocolCatalog.ecStart) { checkout in receivedCheckout = checkout } @@ -30,8 +73,8 @@ struct ClientTests { @Test @MainActor func notificationDoesNotFireUnregisteredHandler() async throws { var completeFired = false - let client = CheckoutProtocol.Client() - .on(CheckoutProtocol.complete) { (_: Checkout) in + let client = CheckoutTransport.Client() + .on(GeneratedProtocolCatalog.ecComplete) { (_: Checkout) in completeFired = true } @@ -42,8 +85,8 @@ struct ClientTests { } @Test @MainActor func notificationReturnsNil() async throws { - let client = CheckoutProtocol.Client() - .on(CheckoutProtocol.start) { (_: Checkout) in } + let client = CheckoutTransport.Client() + .on(GeneratedProtocolCatalog.ecStart) { (_: Checkout) in } let response = try await client.process(notificationFixture()) @@ -53,9 +96,9 @@ struct ClientTests { @Test @MainActor func multipleNotificationHandlersOnDifferentEvents() async throws { var startFired = false var completeFired = false - let client = CheckoutProtocol.Client() - .on(CheckoutProtocol.start) { (_: Checkout) in startFired = true } - .on(CheckoutProtocol.complete) { (_: Checkout) in completeFired = true } + let client = CheckoutTransport.Client() + .on(GeneratedProtocolCatalog.ecStart) { (_: Checkout) in startFired = true } + .on(GeneratedProtocolCatalog.ecComplete) { (_: Checkout) in completeFired = true } _ = try await client.process(notificationFixture()) @@ -64,24 +107,22 @@ struct ClientTests { } @Test @MainActor func unknownMessageReturnsNil() async { - let client = CheckoutProtocol.Client() - .on(CheckoutProtocol.start) { (_: Checkout) in } + let client = CheckoutTransport.Client() + .on(GeneratedProtocolCatalog.ecStart) { (_: Checkout) in } let response = await client.process("not valid json") #expect(response == nil) } - @Test @MainActor func windowOpenRequestDispatchesToRegisteredHandler() async throws { + @Test @MainActor func delegationRequestDispatchesToRegisteredHandler() async throws { let request = #""" {"jsonrpc":"2.0","id":"req-window-1","method":"ec.window.open_request","params":{"url":"https://example.com/terms"}} """# - var receivedURL: URL? - let client = CheckoutProtocol.Client() - .on(CheckoutProtocol.windowOpen) { payload in - receivedURL = payload.url - return .success + let client = CheckoutTransport.Client() + .on(windowOpenDescriptor) { payload in + payload.url == URL(string: "https://example.com/terms") ? .success : .rejected(reason: "unexpected url") } let response = try #require(await client.process(request)) @@ -91,16 +132,15 @@ struct ClientTests { let result = try #require(parsed["result"] as? [String: Any]) let ucp = try #require(result["ucp"] as? [String: Any]) #expect(ucp["status"] as? String == "success") - #expect(receivedURL == URL(string: "https://example.com/terms")) } - @Test @MainActor func windowOpenRequestEncodesRejectedResult() async throws { + @Test @MainActor func delegationRequestEncodesRejectedResult() async throws { let request = #""" {"jsonrpc":"2.0","id":"req-window-1","method":"ec.window.open_request","params":{"url":"https://example.com"}} """# - let client = CheckoutProtocol.Client() - .on(CheckoutProtocol.windowOpen) { _ in + let client = CheckoutTransport.Client() + .on(windowOpenDescriptor) { _ in .rejected(reason: "no presenter available") } @@ -115,8 +155,8 @@ struct ClientTests { #expect(messages[0]["content"] as? String == "no presenter available") } - @Test @MainActor func windowOpenRequestReturnsNilWhenHandlerNotRegistered() async { - let client = CheckoutProtocol.Client() + @Test @MainActor func delegationRequestReturnsNilWhenHandlerNotRegistered() async { + let client = CheckoutTransport.Client() let request = #""" {"jsonrpc":"2.0","id":"req-window-1","method":"ec.window.open_request","params":{"url":"https://example.com"}} """# @@ -125,9 +165,9 @@ struct ClientTests { #expect(response == nil) } - @Test @MainActor func windowOpenRequestWithNullURLReturnsInvalidParamsError() async throws { - let client = CheckoutProtocol.Client() - .on(CheckoutProtocol.windowOpen) { _ in .success } + @Test @MainActor func delegationRequestWithNullURLReturnsInvalidParamsError() async throws { + let client = CheckoutTransport.Client() + .on(windowOpenDescriptor) { _ in .success } let request = #""" {"jsonrpc":"2.0","id":"req-window-1","method":"ec.window.open_request","params":{"url":null}} """# @@ -141,14 +181,14 @@ struct ClientTests { #expect(error["message"] as? String == "Invalid params") } - @Test @MainActor func windowOpenRequestLastHandlerWins() async throws { + @Test @MainActor func delegationRequestLastHandlerWins() async throws { let request = #""" {"jsonrpc":"2.0","id":"req-window-1","method":"ec.window.open_request","params":{"url":"https://example.com"}} """# - let client = CheckoutProtocol.Client() - .on(CheckoutProtocol.windowOpen) { _ in .rejected(reason: "first") } - .on(CheckoutProtocol.windowOpen) { _ in .success } + let client = CheckoutTransport.Client() + .on(windowOpenDescriptor) { _ in .rejected(reason: "first") } + .on(windowOpenDescriptor) { _ in .success } let response = try #require(await client.process(request)) let parsed = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) @@ -157,13 +197,13 @@ struct ClientTests { #expect(ucp["status"] as? String == "success") } - @Test @MainActor func windowOpenRequestAdvertisesDelegationInReadyResponse() async throws { + @Test @MainActor func delegationAdvertisesDelegationInReadyResponse() async throws { let ready = #""" {"jsonrpc":"2.0","id":"ready-1","method":"ec.ready","params":{"delegate":["window.open"]}} """# - let client = CheckoutProtocol.Client() - .on(CheckoutProtocol.windowOpen) { _ in .success } + let client = CheckoutTransport.Client() + .on(windowOpenDescriptor) { _ in .success } let response = try #require(await client.process(ready)) let parsed = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) @@ -177,18 +217,18 @@ struct ClientTests { {"jsonrpc":"2.0","id":"ready-bad","method":"ec.ready","params":{"delegate":[null]}} """# - let client = CheckoutProtocol.Client() + let client = CheckoutTransport.Client() let response = try #require(await client.process(ready)) let parsed = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) #expect(parsed["id"] as? String == "ready-bad") let error = try #require(parsed["error"] as? [String: Any]) - #expect(error["code"] as? Int == CheckoutProtocol.parseErrorCode) - #expect(error["message"] as? String == CheckoutProtocol.parseErrorMessage) + #expect(error["code"] as? Int == CheckoutTransport.parseErrorCode) + #expect(error["message"] as? String == CheckoutTransport.parseErrorMessage) } @Test @MainActor func readyReturnsResponse() async throws { - let client = CheckoutProtocol.Client() + let client = CheckoutTransport.Client() let response = try await client.process(readyFixture()) @@ -199,7 +239,7 @@ struct ClientTests { #expect(parsed["params"] == nil) let result = try #require(parsed["result"] as? [String: Any]) let ucp = try #require(result["ucp"] as? [String: Any]) - #expect(ucp["version"] as? String == CheckoutProtocol.specVersion) + #expect(ucp["version"] as? String == CheckoutTransport.specVersion) #expect(ucp["status"] as? String == "success") } } diff --git a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecDecodeTests.swift b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecDecodeTests.swift index c8d46cf3..fc5eee43 100644 --- a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecDecodeTests.swift +++ b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecDecodeTests.swift @@ -6,7 +6,7 @@ import Testing struct CodecDecodeTests { @Test func decodesNotification() throws { let json = try fixtureString("notification") - let message = CheckoutProtocol.decode(jsonRpc: json) + let message = CheckoutTransport.decode(jsonRpc: json) guard case let .notification(method, payload) = message else { Issue.record("Expected .notification, got \(message)") @@ -25,7 +25,7 @@ struct CodecDecodeTests { let json = #""" {"jsonrpc":"2.0","method":"ec.error","params":{"error":{"ucp":{"version":"2026-04-08","status":"error"},"messages":[{"type":"error","code":"unrecoverable","content":"Boom.","severity":"recoverable"}]}}} """# - let message = CheckoutProtocol.decode(jsonRpc: json) + let message = CheckoutTransport.decode(jsonRpc: json) guard case let .notification(method, payload) = message else { Issue.record("Expected .notification, got \(message)") @@ -41,7 +41,7 @@ struct CodecDecodeTests { @Test func decodesRequestCarriesRawParams() throws { let json = try fixtureString("request") - let message = CheckoutProtocol.decode(jsonRpc: json) + let message = CheckoutTransport.decode(jsonRpc: json) guard case let .request(id, method, params) = message else { Issue.record("Expected .request, got \(message)") @@ -59,9 +59,9 @@ struct CodecDecodeTests { #expect(checkout["currency"] as? String == "CAD") } - @Test func decodesWindowOpenRequest() throws { + @Test func decodesWindowOpenRequestAsRawRequest() throws { let json = try fixtureString("window_open_request") - let message = CheckoutProtocol.decode(jsonRpc: json) + let message = CheckoutTransport.decode(jsonRpc: json) guard case let .request(id, method, params) = message else { Issue.record("Expected .request, got \(message)") @@ -71,15 +71,15 @@ struct CodecDecodeTests { #expect(id == "req-window-1") #expect(method == "ec.window.open_request") - let payload = try #require(CheckoutProtocol.windowOpen.decode(params)) - #expect(payload.url == URL(string: "https://example.com/terms")) + let parsed = try #require(JSONSerialization.jsonObject(with: params) as? [String: Any]) + #expect(parsed["url"] as? String == "https://example.com/terms") } @Test func windowOpenRequestDropsUnknownParamsBeforeDispatch() throws { let json = #""" {"jsonrpc":"2.0","id":"req-window-1","method":"ec.window.open_request","params":{"url":"https://example.com/terms","unknown":"value"}} """# - let message = CheckoutProtocol.decode(jsonRpc: json) + let message = CheckoutTransport.decode(jsonRpc: json) guard case let .request(_, _, params) = message else { Issue.record("Expected .request, got \(message)") @@ -91,26 +91,11 @@ struct CodecDecodeTests { #expect(parsed["unknown"] == nil) } - @Test func windowOpenDescriptorRejectsEmptyURL() { - let params = Data(#"{"url":""}"#.utf8) - #expect(CheckoutProtocol.windowOpen.decode(params) == nil) - } - - @Test func windowOpenDescriptorRejectsMissingURL() { - let params = Data("{}".utf8) - #expect(CheckoutProtocol.windowOpen.decode(params) == nil) - } - - @Test func windowOpenDescriptorRejectsNullURL() { - let params = Data(#"{"url":null}"#.utf8) - #expect(CheckoutProtocol.windowOpen.decode(params) == nil) - } - @Test func decodesMalformedWindowOpenParamsAsInvalidParamsError() { let json = #""" {"jsonrpc":"2.0","id":"req-window-bad","method":"ec.window.open_request","params":{"url":null}} """# - let message = CheckoutProtocol.decode(jsonRpc: json) + let message = CheckoutTransport.decode(jsonRpc: json) guard case let .error(id, code, responseMessage) = message else { Issue.record("Expected .error, got \(message)") @@ -126,7 +111,7 @@ struct CodecDecodeTests { let json = """ {"jsonrpc":"2.0","method":"ec.unknown","params":{"something":"else"}} """ - let message = CheckoutProtocol.decode(jsonRpc: json) + let message = CheckoutTransport.decode(jsonRpc: json) guard case let .unknown(method, _) = message else { Issue.record("Expected .unknown, got \(message)") @@ -140,7 +125,7 @@ struct CodecDecodeTests { let json = #""" {"jsonrpc":"2.0","id":1,"method":"ec.ready","params":{"delegate":[]}} """# - let message = CheckoutProtocol.decode(jsonRpc: json) + let message = CheckoutTransport.decode(jsonRpc: json) guard case let .ready(id, delegations) = message else { Issue.record("Expected .ready, got \(message)") @@ -155,7 +140,7 @@ struct CodecDecodeTests { let json = #""" {"jsonrpc":"2.0","id":null,"method":"ec.ready","params":{"delegate":[]}} """# - let message = CheckoutProtocol.decode(jsonRpc: json) + let message = CheckoutTransport.decode(jsonRpc: json) guard case let .ready(id, delegations) = message else { Issue.record("Expected .ready, got \(message)") @@ -170,7 +155,7 @@ struct CodecDecodeTests { let json = #""" {"jsonrpc":"2.0","id":"ready-no-params","method":"ec.ready"} """# - let message = CheckoutProtocol.decode(jsonRpc: json) + let message = CheckoutTransport.decode(jsonRpc: json) guard case let .ready(id, delegations) = message else { Issue.record("Expected .ready, got \(message)") @@ -185,7 +170,7 @@ struct CodecDecodeTests { let json = #""" {"jsonrpc":"2.0","id":"ready-bad","method":"ec.ready","params":{"delegate":[null]}} """# - let message = CheckoutProtocol.decode(jsonRpc: json) + let message = CheckoutTransport.decode(jsonRpc: json) guard case let .error(id, code, responseMessage) = message else { Issue.record("Expected .error, got \(message)") @@ -193,15 +178,15 @@ struct CodecDecodeTests { } #expect(id == "ready-bad") - #expect(code == CheckoutProtocol.parseErrorCode) - #expect(responseMessage == CheckoutProtocol.parseErrorMessage) + #expect(code == CheckoutTransport.parseErrorCode) + #expect(responseMessage == CheckoutTransport.parseErrorMessage) } @Test func decodesNullReadyParamsAsParseError() { let json = #""" {"jsonrpc":"2.0","id":"ready-null","method":"ec.ready","params":null} """# - let message = CheckoutProtocol.decode(jsonRpc: json) + let message = CheckoutTransport.decode(jsonRpc: json) guard case let .error(id, code, responseMessage) = message else { Issue.record("Expected .error, got \(message)") @@ -209,15 +194,15 @@ struct CodecDecodeTests { } #expect(id == "ready-null") - #expect(code == CheckoutProtocol.parseErrorCode) - #expect(responseMessage == CheckoutProtocol.parseErrorMessage) + #expect(code == CheckoutTransport.parseErrorCode) + #expect(responseMessage == CheckoutTransport.parseErrorMessage) } @Test func rejectsFractionalJSONRPCID() { let json = #""" {"jsonrpc":"2.0","id":1.5,"method":"ec.ready","params":{"delegate":[]}} """# - let message = CheckoutProtocol.decode(jsonRpc: json) + let message = CheckoutTransport.decode(jsonRpc: json) guard case let .unknown(method, _) = message else { Issue.record("Expected .unknown for fractional id, got \(message)") @@ -229,7 +214,7 @@ struct CodecDecodeTests { @Test func handlesMalformedJSON() { let json = "not valid json at all" - let message = CheckoutProtocol.decode(jsonRpc: json) + let message = CheckoutTransport.decode(jsonRpc: json) guard case let .unknown(method, _) = message else { Issue.record("Expected .unknown for malformed JSON, got \(message)") diff --git a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecEncodeTests.swift b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecEncodeTests.swift index f0c431ca..7a6f421e 100644 --- a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecEncodeTests.swift +++ b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecEncodeTests.swift @@ -14,12 +14,12 @@ struct CodecEncodeTests { paymentHandlers: nil, services: nil, status: .success, - version: CheckoutProtocol.specVersion + version: CheckoutTransport.specVersion ), continueURL: nil, messages: nil ) - let json = CheckoutProtocol.encodeResponse(id: "req-456", result: result) + let json = CheckoutTransport.encodeResponse(id: "req-456", result: result) let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) #expect(parsed["jsonrpc"] as? String == "2.0") @@ -28,7 +28,7 @@ struct CodecEncodeTests { } @Test func encodesReadyResponseWithResultEnvelope() throws { - let json = CheckoutProtocol.encodeReadyResponse(id: "ready-1", acceptedDelegations: []) + let json = CheckoutTransport.encodeReadyResponse(id: "ready-1", acceptedDelegations: []) let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) #expect(parsed["jsonrpc"] as? String == "2.0") @@ -38,13 +38,13 @@ struct CodecEncodeTests { let result = try #require(parsed["result"] as? [String: Any]) let ucp = try #require(result["ucp"] as? [String: Any]) - #expect(ucp["version"] as? String == CheckoutProtocol.specVersion) + #expect(ucp["version"] as? String == CheckoutTransport.specVersion) #expect(ucp["status"] as? String == "success") #expect(result["delegate"] == nil, "Empty delegate list must be omitted") } @Test func encodesReadyResponseEchoesAcceptedDelegations() throws { - let json = CheckoutProtocol.encodeReadyResponse(id: "ready-1", acceptedDelegations: ["window.open"]) + let json = CheckoutTransport.encodeReadyResponse(id: "ready-1", acceptedDelegations: ["window.open"]) let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) let result = try #require(parsed["result"] as? [String: Any]) @@ -53,14 +53,14 @@ struct CodecEncodeTests { } @Test func encodesReadyResponseWithNumericID() throws { - let json = CheckoutProtocol.encodeReadyResponse(id: 7, acceptedDelegations: []) + let json = CheckoutTransport.encodeReadyResponse(id: 7, acceptedDelegations: []) let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) #expect(parsed["id"] as? Int == 7) } @Test func encodesReadyResponseWithNullID() throws { - let json = CheckoutProtocol.encodeReadyResponse(id: .null, acceptedDelegations: []) + let json = CheckoutTransport.encodeReadyResponse(id: .null, acceptedDelegations: []) let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) #expect(parsed["id"] is NSNull) @@ -71,13 +71,13 @@ struct CodecEncodeTests { {"jsonrpc":"2.0","id":"ready-1","method":"ec.ready","params":{"delegate":["payment.credential"]}} """# - let response = try #require(CheckoutProtocol.acknowledgeReady(message)) + let response = try #require(CheckoutTransport.acknowledgeReady(message)) let parsed = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) #expect(parsed["id"] as? String == "ready-1") let result = try #require(parsed["result"] as? [String: Any]) let ucp = try #require(result["ucp"] as? [String: Any]) - #expect(ucp["version"] as? String == CheckoutProtocol.specVersion) + #expect(ucp["version"] as? String == CheckoutTransport.specVersion) #expect(ucp["status"] as? String == "success") } @@ -86,7 +86,7 @@ struct CodecEncodeTests { {"jsonrpc":"2.0","id":"ready-1","method":"ec.ready","params":{"delegate":["payment.credential","window.open","fulfillment.address_change"]}} """# - let response = try #require(CheckoutProtocol.acknowledgeReady(message, supportedDelegations: ["window.open"])) + let response = try #require(CheckoutTransport.acknowledgeReady(message, supportedDelegations: ["window.open"])) let parsed = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) let result = try #require(parsed["result"] as? [String: Any]) @@ -99,7 +99,7 @@ struct CodecEncodeTests { {"jsonrpc":"2.0","id":"ready-1","method":"ec.ready","params":{"delegate":["payment.credential"]}} """# - let response = try #require(CheckoutProtocol.acknowledgeReady(message, supportedDelegations: ["window.open"])) + let response = try #require(CheckoutTransport.acknowledgeReady(message, supportedDelegations: ["window.open"])) let parsed = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) let result = try #require(parsed["result"] as? [String: Any]) @@ -111,7 +111,7 @@ struct CodecEncodeTests { {"jsonrpc":"2.0","id":"ready-no-params","method":"ec.ready"} """# - let response = try #require(CheckoutProtocol.acknowledgeReady(message)) + let response = try #require(CheckoutTransport.acknowledgeReady(message)) let parsed = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) #expect(parsed["id"] as? String == "ready-no-params") @@ -124,13 +124,13 @@ struct CodecEncodeTests { {"jsonrpc":"2.0","id":"ready-bad","method":"ec.ready","params":{"delegate":[null]}} """# - let response = try #require(CheckoutProtocol.acknowledgeReady(message)) + let response = try #require(CheckoutTransport.acknowledgeReady(message)) let parsed = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) #expect(parsed["id"] as? String == "ready-bad") let error = try #require(parsed["error"] as? [String: Any]) - #expect(error["code"] as? Int == CheckoutProtocol.parseErrorCode) - #expect(error["message"] as? String == CheckoutProtocol.parseErrorMessage) + #expect(error["code"] as? Int == CheckoutTransport.parseErrorCode) + #expect(error["message"] as? String == CheckoutTransport.parseErrorMessage) } @Test func acknowledgeReadyReturnsParseErrorForNullParams() throws { @@ -138,13 +138,13 @@ struct CodecEncodeTests { {"jsonrpc":"2.0","id":"ready-null","method":"ec.ready","params":null} """# - let response = try #require(CheckoutProtocol.acknowledgeReady(message)) + let response = try #require(CheckoutTransport.acknowledgeReady(message)) let parsed = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) #expect(parsed["id"] as? String == "ready-null") let error = try #require(parsed["error"] as? [String: Any]) - #expect(error["code"] as? Int == CheckoutProtocol.parseErrorCode) - #expect(error["message"] as? String == CheckoutProtocol.parseErrorMessage) + #expect(error["code"] as? Int == CheckoutTransport.parseErrorCode) + #expect(error["message"] as? String == CheckoutTransport.parseErrorMessage) } @Test func acknowledgeReadyReturnsNilForNonReadyMessage() { @@ -152,54 +152,10 @@ struct CodecEncodeTests { {"jsonrpc":"2.0","method":"ec.start","params":{"checkout":{"id":"c"}}} """# - #expect(CheckoutProtocol.acknowledgeReady(message) == nil) + #expect(CheckoutTransport.acknowledgeReady(message) == nil) } @Test func acknowledgeReadyReturnsNilForMalformedJSON() { - #expect(CheckoutProtocol.acknowledgeReady("not json") == nil) - } - - @Test func windowOpenResultEncodesSuccessBody() throws { - let json = CheckoutProtocol.encodeResponse(id: "req-window-1", result: WindowOpenResult.success) - let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) - - #expect(parsed["jsonrpc"] as? String == "2.0") - #expect(parsed["id"] as? String == "req-window-1") - let result = try #require(parsed["result"] as? [String: Any]) - let ucp = try #require(result["ucp"] as? [String: Any]) - #expect(ucp["status"] as? String == "success") - #expect(ucp["version"] as? String == CheckoutProtocol.specVersion) - } - - @Test func windowOpenResultEncodesRejectedBody() throws { - let json = CheckoutProtocol.encodeResponse( - id: "req-window-1", - result: WindowOpenResult.rejected(reason: "canOpenURL returned false") - ) - let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) - - #expect(parsed["id"] as? String == "req-window-1") - let result = try #require(parsed["result"] as? [String: Any]) - let ucp = try #require(result["ucp"] as? [String: Any]) - #expect(ucp["status"] as? String == "error") - - let messages = try #require(result["messages"] as? [[String: Any]]) - #expect(messages.count == 1) - #expect(messages[0]["type"] as? String == "error") - #expect(messages[0]["code"] as? String == "window_open_rejected_error") - #expect(messages[0]["severity"] as? String == "unrecoverable") - #expect(messages[0]["content"] as? String == "canOpenURL returned false") - } - - @Test func windowOpenResultEncodesRejectedWithNilReason() throws { - let json = CheckoutProtocol.encodeResponse( - id: "req-window-1", - result: WindowOpenResult.rejected(reason: nil) - ) - let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) - - let result = try #require(parsed["result"] as? [String: Any]) - let messages = try #require(result["messages"] as? [[String: Any]]) - #expect(messages[0]["content"] as? String != "", "Content is required per message_error schema") + #expect(CheckoutTransport.acknowledgeReady("not json") == nil) } } diff --git a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/DescriptorTests.swift b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/DescriptorTests.swift index bada7607..a6b43938 100644 --- a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/DescriptorTests.swift +++ b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/DescriptorTests.swift @@ -7,120 +7,35 @@ struct DescriptorTests { @Suite("Spec Version") struct SpecVersion { @Test func matchesOpenRPCInfoVersion() { - #expect(CheckoutProtocol.specVersion == "2026-04-08") + #expect(CheckoutTransport.specVersion == "2026-04-08") } } - @Suite("Notifications") - struct Notifications { - @Test func startMethod() { - #expect(CheckoutProtocol.start.method == "ec.start") + @Suite("Generated Catalog") + struct GeneratedCatalog { + @Test func bindsNotificationMethods() { + #expect(GeneratedProtocolCatalog.ecStart.method == "ec.start") + #expect(GeneratedProtocolCatalog.ecComplete.method == "ec.complete") + #expect(GeneratedProtocolCatalog.ecMessagesChange.method == "ec.messages.change") + #expect(GeneratedProtocolCatalog.ecLineItemsChange.method == "ec.line_items.change") + #expect(GeneratedProtocolCatalog.ecTotalsChange.method == "ec.totals.change") + #expect(GeneratedProtocolCatalog.ecError.method == "ec.error") } - @Test func completeMethod() { - #expect(CheckoutProtocol.complete.method == "ec.complete") + @Test func exposesEveryOpenRPCMethod() { + #expect(GeneratedProtocolCatalog.allMethods.contains("ec.start")) + #expect(GeneratedProtocolCatalog.allMethods.contains("ec.complete")) + #expect(GeneratedProtocolCatalog.allMethods.contains("ec.window.open_request")) } - @Test func messagesChangeMethod() { - #expect(CheckoutProtocol.messagesChange.method == "ec.messages.change") + @Test func includesMethodsBeyondTheCuratedConsumerSubset() { + #expect(GeneratedProtocolCatalog.allMethods.contains("ec.payment.credential_request")) + #expect(GeneratedProtocolCatalog.allMethods.contains("ec.fulfillment.change")) + #expect(GeneratedProtocolCatalog.allMethods.contains("ep.cart.ready")) } - @Test func lineItemsChangeMethod() { - #expect(CheckoutProtocol.lineItemsChange.method == "ec.line_items.change") - } - - @Test func totalsChangeMethod() { - #expect(CheckoutProtocol.totalsChange.method == "ec.totals.change") - } - - @Test func errorMethod() { - #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("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) - } - - @Test func methodNotFoundResponseEncodesUnsupportedRequests() throws { - let response = try #require( - CheckoutProtocol.methodNotFoundResponse( - forUnsupportedProtocolRequest: #"{"jsonrpc":"2.0","method":"custom","id":"unsupported","params":{}}"# - ) - ) - let data = try #require(response.data(using: .utf8)) - let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) - - #expect(object["jsonrpc"] as? String == "2.0") - #expect(object["id"] as? String == "unsupported") - let error = try #require(object["error"] as? [String: Any]) - #expect(error["code"] as? Int == CheckoutProtocol.methodNotFoundCode) - #expect(error["message"] as? String == CheckoutProtocol.methodNotFoundMessage) - } - - @Test func methodNotFoundResponsePreservesNumericRequestID() throws { - let response = try #require( - CheckoutProtocol.methodNotFoundResponse( - forUnsupportedProtocolRequest: #"{"jsonrpc":"2.0","method":"custom","id":7,"params":{}}"# - ) - ) - let data = try #require(response.data(using: .utf8)) - let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) - - #expect(object["id"] as? Int == 7) - } - - @Test func methodNotFoundResponsePreservesNullRequestID() throws { - let response = try #require( - CheckoutProtocol.methodNotFoundResponse( - forUnsupportedProtocolRequest: #"{"jsonrpc":"2.0","method":"custom","id":null,"params":{}}"# - ) - ) - let data = try #require(response.data(using: .utf8)) - let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) - - #expect(object["id"] is NSNull) - } - - @Test func methodNotFoundResponseRejectsInvalidRequestIDs() { - #expect(CheckoutProtocol.methodNotFoundResponse(forUnsupportedProtocolRequest: #"{"jsonrpc":"2.0","method":"custom","id":true,"params":{}}"#) == nil) - #expect(CheckoutProtocol.methodNotFoundResponse(forUnsupportedProtocolRequest: #"{"jsonrpc":"2.0","method":"custom","id":{},"params":{}}"#) == nil) - #expect(CheckoutProtocol.methodNotFoundResponse(forUnsupportedProtocolRequest: #"{"jsonrpc":"2.0","method":"custom","id":1.5,"params":{}}"#) == nil) - } - - @Test func methodNotFoundResponseRejectsSupportedNotificationsOrInvalidMessages() { - #expect(CheckoutProtocol.methodNotFoundResponse(forUnsupportedProtocolRequest: #"{"jsonrpc":"2.0","method":"custom"}"#) == nil) - #expect(CheckoutProtocol.methodNotFoundResponse(forUnsupportedProtocolRequest: #"{"jsonrpc":"2.0","method":"ec.start","id":"supported"}"#) == nil) - #expect(CheckoutProtocol.methodNotFoundResponse(forUnsupportedProtocolRequest: #"{"jsonrpc":"1.0","method":"custom","id":"unsupported"}"#) == nil) - #expect(CheckoutProtocol.methodNotFoundResponse(forUnsupportedProtocolRequest: "not json") == nil) + @Test func methodsAreUnique() { + #expect(Set(GeneratedProtocolCatalog.allMethods).count == GeneratedProtocolCatalog.allMethods.count) } } } diff --git a/protocol/languages/typescript/src/index.d.ts b/protocol/languages/typescript/src/index.d.ts index 9f217a84..e4d8ca2b 100644 --- a/protocol/languages/typescript/src/index.d.ts +++ b/protocol/languages/typescript/src/index.d.ts @@ -1,2 +1,2 @@ export * from './generated/Models'; -export * from './notifications'; +export * from './generated/ProtocolNotifications'; diff --git a/protocol/languages/typescript/src/index.ts b/protocol/languages/typescript/src/index.ts index 9f217a84..e4d8ca2b 100644 --- a/protocol/languages/typescript/src/index.ts +++ b/protocol/languages/typescript/src/index.ts @@ -1,2 +1,2 @@ export * from './generated/Models'; -export * from './notifications'; +export * from './generated/ProtocolNotifications'; diff --git a/protocol/languages/typescript/src/notifications.d.ts b/protocol/languages/typescript/src/notifications.d.ts deleted file mode 100644 index c4d53311..00000000 --- a/protocol/languages/typescript/src/notifications.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { type GeneratedCheckoutProtocolPayloads } from './generated/ProtocolNotifications'; -export declare const CheckoutProtocol: { - readonly complete: "ec.complete"; - readonly error: "ec.error"; - readonly lineItemsChange: "ec.line_items.change"; - readonly messagesChange: "ec.messages.change"; - readonly start: "ec.start"; - readonly totalsChange: "ec.totals.change"; -}; -export type CheckoutProtocolMethod = (typeof CheckoutProtocol)[keyof typeof CheckoutProtocol]; -export type CheckoutProtocolPayloads = Pick; -export type CheckoutProtocolPayloadDecoder = (payload: unknown) => CheckoutProtocolPayloads[K]; -export declare function decodeCheckoutProtocolPayload(method: K, payload: unknown): CheckoutProtocolPayloads[K]; -export declare function decodeCheckoutProtocolPayload(method: string, payload: unknown): CheckoutProtocolPayloads[keyof CheckoutProtocolPayloads] | undefined; diff --git a/protocol/languages/typescript/src/notifications.ts b/protocol/languages/typescript/src/notifications.ts deleted file mode 100644 index 16cc25cb..00000000 --- a/protocol/languages/typescript/src/notifications.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - generatedCheckoutProtocol, - generatedCheckoutProtocolPayloadDecoders, - type GeneratedCheckoutProtocolPayloads, -} from './generated/ProtocolNotifications'; - -// Public Checkout Kit notification events. The full notification contract is -// generated from protocol/services/shopping/embedded.openrpc.json; this module -// intentionally exposes the subset supported by the public SDKs. -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; - -export type CheckoutProtocolMethod = - (typeof CheckoutProtocol)[keyof typeof CheckoutProtocol]; - -export type CheckoutProtocolPayloads = Pick< - GeneratedCheckoutProtocolPayloads, - CheckoutProtocolMethod ->; - -export 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; -}; - -export function decodeCheckoutProtocolPayload< - K extends keyof CheckoutProtocolPayloads, ->(method: K, payload: unknown): CheckoutProtocolPayloads[K]; -export function decodeCheckoutProtocolPayload( - method: string, - payload: unknown, -): CheckoutProtocolPayloads[keyof CheckoutProtocolPayloads] | undefined; -export function decodeCheckoutProtocolPayload( - method: string, - payload: unknown, -): CheckoutProtocolPayloads[keyof CheckoutProtocolPayloads] | undefined { - const decoder = decoderFor(method); - return decoder?.(payload); -} - -function decoderFor( - method: string, -): - | ((payload: unknown) => CheckoutProtocolPayloads[keyof CheckoutProtocolPayloads]) - | undefined { - return checkoutProtocolPayloadDecoders[ - method as keyof typeof checkoutProtocolPayloadDecoders - ] as - | ((payload: unknown) => CheckoutProtocolPayloads[keyof CheckoutProtocolPayloads]) - | undefined; -} diff --git a/protocol/scripts/generate_models.mjs b/protocol/scripts/generate_models.mjs index 6ea0583f..8f6f31a9 100755 --- a/protocol/scripts/generate_models.mjs +++ b/protocol/scripts/generate_models.mjs @@ -363,6 +363,8 @@ async function generateSwift(specDir, output) { return `${source.slice(0, helperStart)}${SWIFT_JSON_HELPER_REPLACEMENT}`; }); + + await run("node", [path.join(PROTOCOL_DIR, "scripts", "generate_swift_catalog.mjs")]); } async function generateTypescript(specDir, output) { diff --git a/protocol/scripts/generate_swift_catalog.mjs b/protocol/scripts/generate_swift_catalog.mjs new file mode 100644 index 00000000..c4463269 --- /dev/null +++ b/protocol/scripts/generate_swift_catalog.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const protocolRoot = path.resolve(scriptDir, '..'); + +const openRpcPath = path.resolve( + protocolRoot, + 'services/shopping/embedded.openrpc.json', +); +const outputPath = path.resolve( + protocolRoot, + 'languages/swift/Sources/ShopifyCheckoutProtocol/Generated/Catalog.swift', +); + +const fallbackPayload = 'JSONAny'; + +const refPayloadMappings = new Map([ + ['checkout.json', 'Checkout'], + ['cart.json', fallbackPayload], + ['types/error_response.json', 'ErrorResponse'], + ['error_response.json', 'ErrorResponse'], +]); + +function normalizeRef(ref) { + return ref + .replace(/^\.\.\/\.\.\/schemas\/shopping\//, '') + .replace(/^\.\.\/\.\.\/schemas\/common\//, '') + .replace(/#.*$/, ''); +} + +function methodNameToIdentifier(methodName) { + const parts = methodName.split(/[._]/g).filter(Boolean); + + return parts + .map((part, index) => + index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1), + ) + .join(''); +} + +function resolveMethod(method, openRpcDir) { + if (typeof method.$ref !== 'string') { + return method; + } + + const [filePart, pointer] = method.$ref.split('#'); + const filePath = path.resolve(openRpcDir, filePart); + const document = JSON.parse(fs.readFileSync(filePath, 'utf8')); + + const segments = (pointer ?? '').split('/').filter(Boolean); + let resolved = document; + for (const segment of segments) { + resolved = resolved?.[segment.replace(/~1/g, '/').replace(/~0/g, '~')]; + } + + if (!resolved || typeof resolved.name !== 'string') { + throw new Error(`Cannot resolve OpenRPC method $ref: ${method.$ref}`); + } + + return resolved; +} + +function payloadType(method) { + const params = method.params ?? []; + if (params.length === 0) { + return fallbackPayload; + } + + const ref = params[0]?.schema?.$ref; + if (typeof ref !== 'string') { + return fallbackPayload; + } + + const normalized = normalizeRef(ref); + return refPayloadMappings.get(normalized) ?? fallbackPayload; +} + +const openRpcDir = path.dirname(openRpcPath); +const openRpc = JSON.parse(fs.readFileSync(openRpcPath, 'utf8')); +const entries = []; + +for (const rawMethod of openRpc.methods ?? []) { + const method = resolveMethod(rawMethod, openRpcDir); + if (typeof method.name !== 'string') { + throw new Error('Encountered OpenRPC method without a name'); + } + + entries.push({ + identifier: methodNameToIdentifier(method.name), + method: method.name, + payload: payloadType(method), + }); +} + +const seen = new Set(); +for (const entry of entries) { + if (seen.has(entry.identifier)) { + throw new Error(`Duplicate catalog identifier: ${entry.identifier}`); + } + seen.add(entry.identifier); +} + +const payloadTypes = Array.from( + new Set(entries.map(entry => entry.payload)), +).sort(); + +const conformances = payloadTypes + .map(type => `extension ${type}: EventPayload {}`) + .join('\n'); + +const generated = `// This file is generated by protocol/scripts/generate_swift_catalog.mjs. +// Do not edit directly. + +import Foundation + +${conformances} + +public enum GeneratedProtocolCatalog { +${entries + .map( + entry => + ` public static let ${entry.identifier} = NotificationDescriptor<${entry.payload}>(method: "${entry.method}")`, + ) + .join('\n')} + + public static let allMethods: [String] = [ +${entries.map(entry => ` ${entry.identifier}.method,`).join('\n')} + ] +} +`; + +fs.mkdirSync(path.dirname(outputPath), {recursive: true}); +fs.writeFileSync(outputPath, generated); +console.log(`Generated ${outputPath}`);