Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import SafariServices
import ShopifyCheckoutKit
import ShopifyCheckoutProtocol
import UIKit

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

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

public enum CheckoutProtocol {
public typealias Client = EmbeddedCheckoutProtocol.Client

public static func url(for url: URL) -> URL {
EmbeddedCheckoutProtocol.url(for: url, delegations: defaultDelegations)
}

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

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

public static let complete = EmbeddedCheckoutProtocol.Event.complete
public static let error = EmbeddedCheckoutProtocol.Event.error
public static let lineItemsChange = EmbeddedCheckoutProtocol.Event.lineItemsChange
public static let messagesChange = EmbeddedCheckoutProtocol.Event.messagesChange
public static let start = EmbeddedCheckoutProtocol.Event.start
public static let totalsChange = EmbeddedCheckoutProtocol.Event.totalsChange

static let supportedProtocolMethods: Set<String> = [
EmbeddedCheckoutProtocol.readyMethod,
start.method,
complete.method,
error.method,
lineItemsChange.method,
messagesChange.method,
totalsChange.method,
windowOpen.method
]
Comment on lines +6 to +34

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is essentially a facade which curates the set of supported events and lives in the kit itself


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?

private enum CodingKeys: String, CodingKey {
case jsonrpc
case method
case id
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
jsonrpc = try container.decode(String.self, forKey: .jsonrpc)
method = try container.decode(String.self, forKey: .method)
if container.contains(.id) {
id = try container.decode(RequestID.self, forKey: .id)
} else {
id = nil
}
}
}

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

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

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

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

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

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

private struct MethodNotFoundError: Encodable {
let code: Int
let message: String
}
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@
/// 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: EmbeddedCheckoutProtocol.Client = .init()
.on(CheckoutProtocol.complete) { _ in
CheckoutWebView.invalidate(disconnect: false)
}
Expand Down Expand Up @@ -458,7 +458,9 @@
return
}

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

extension CheckoutWebView: WKNavigationDelegate {
func webView(_: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {

Check warning on line 484 in platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift

View workflow job for this annotation

GitHub Actions / Breaking Changes / Swift

instance method 'webView(_:decidePolicyFor:decisionHandler:)' nearly matches optional requirement 'webView(_:decidePolicyFor:decisionHandler:)' of protocol 'WKNavigationDelegate'

Check warning on line 484 in platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift

View workflow job for this annotation

GitHub Actions / Breaking Changes / Swift

instance method 'webView(_:decidePolicyFor:decisionHandler:)' nearly matches optional requirement 'webView(_:decidePolicyFor:decisionHandler:)' of protocol 'WKNavigationDelegate'

Check warning on line 484 in platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift

View workflow job for this annotation

GitHub Actions / Breaking Changes / Swift

instance method 'webView(_:decidePolicyFor:decisionHandler:)' nearly matches optional requirement 'webView(_:decidePolicyFor:decisionHandler:)' of protocol 'WKNavigationDelegate'

Check warning on line 484 in platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift

View workflow job for this annotation

GitHub Actions / Breaking Changes / Swift

instance method 'webView(_:decidePolicyFor:decisionHandler:)' nearly matches optional requirement 'webView(_:decidePolicyFor:decisionHandler:)' of protocol 'WKNavigationDelegate'

Check warning on line 484 in platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift

View workflow job for this annotation

GitHub Actions / Swift / package-tests / Run Package Tests

instance method 'webView(_:decidePolicyFor:decisionHandler:)' nearly matches optional requirement 'webView(_:decidePolicyFor:decisionHandler:)' of protocol 'WKNavigationDelegate'

Check warning on line 484 in platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift

View workflow job for this annotation

GitHub Actions / Swift / package-tests / Run Package Tests

instance method 'webView(_:decidePolicyFor:decisionHandler:)' nearly matches optional requirement 'webView(_:decidePolicyFor:decisionHandler:)' of protocol 'WKNavigationDelegate'
// Handle rare cases where the url is nil
guard let url = action.request.url else {
decisionHandler(.allow)
Expand All @@ -505,7 +507,7 @@
decisionHandler(.allow)
}

func webView(_: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {

Check warning on line 510 in platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift

View workflow job for this annotation

GitHub Actions / Breaking Changes / Swift

instance method 'webView(_:decidePolicyFor:decisionHandler:)' nearly matches optional requirement 'webView(_:decidePolicyFor:decisionHandler:)' of protocol 'WKNavigationDelegate'

Check warning on line 510 in platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift

View workflow job for this annotation

GitHub Actions / Breaking Changes / Swift

instance method 'webView(_:decidePolicyFor:decisionHandler:)' nearly matches optional requirement 'webView(_:decidePolicyFor:decisionHandler:)' of protocol 'WKNavigationDelegate'

Check warning on line 510 in platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift

View workflow job for this annotation

GitHub Actions / Breaking Changes / Swift

instance method 'webView(_:decidePolicyFor:decisionHandler:)' nearly matches optional requirement 'webView(_:decidePolicyFor:decisionHandler:)' of protocol 'WKNavigationDelegate'

Check warning on line 510 in platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift

View workflow job for this annotation

GitHub Actions / Breaking Changes / Swift

instance method 'webView(_:decidePolicyFor:decisionHandler:)' nearly matches optional requirement 'webView(_:decidePolicyFor:decisionHandler:)' of protocol 'WKNavigationDelegate'

Check warning on line 510 in platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift

View workflow job for this annotation

GitHub Actions / Swift / package-tests / Run Package Tests

instance method 'webView(_:decidePolicyFor:decisionHandler:)' nearly matches optional requirement 'webView(_:decidePolicyFor:decisionHandler:)' of protocol 'WKNavigationDelegate'

Check warning on line 510 in platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift

View workflow job for this annotation

GitHub Actions / Swift / package-tests / Run Package Tests

instance method 'webView(_:decidePolicyFor:decisionHandler:)' nearly matches optional requirement 'webView(_:decidePolicyFor:decisionHandler:)' of protocol 'WKNavigationDelegate'
if let response = navigationResponse.response as? HTTPURLResponse {
decisionHandler(handleResponse(response))
return
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
#if !COCOAPODS
import ShopifyCheckoutProtocol
#endif
import Foundation

public struct WindowOpenRequest: EventPayload {
Expand Down Expand Up @@ -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: EmbeddedCheckoutProtocol.specVersion, status: "success")
).encode(to: encoder)
case let .rejected(reason):
try WindowOpenRejectedBody(
ucp: UCPError(version: CheckoutProtocol.specVersion),
ucp: WindowOpenUCP(version: EmbeddedCheckoutProtocol.specVersion, status: "error"),
messages: [
WindowOpenRejectedMessage(content: reason ?? "Window open rejected")
]
Expand All @@ -53,16 +56,25 @@ public enum WindowOpenResult: ResponsePayload {

extension CheckoutProtocol {
public static let windowOpen = DelegationDescriptor<WindowOpenRequest, WindowOpenResult>(
method: "ec.window.open_request",
method: EmbeddedCheckoutProtocol.Event.windowOpenRequest.method,
delegation: "window.open",
decode: { params in
try? JSONDecoder().decode(WindowOpenRequest.self, from: params)
}
)
}

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]
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 == [
EmbeddedCheckoutProtocol.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 == EmbeddedCheckoutProtocol.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 != "")
}
}
Loading
Loading