From 11997ce62c15e905a6cd1fdf5c5cac7a0acd81e4 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Thu, 21 May 2026 14:21:00 +0100 Subject: [PATCH] feat: add all protocol events --- .../react-native/__mocks__/react-native.ts | 9 +- .../reactnative/checkoutkit/ProtocolRelay.kt | 25 +++ .../checkoutkit/ProtocolRelayTest.kt | 77 ++++++++ .../api/checkout-kit-react-native.api.md | 16 +- .../ios/ProtocolRelay.swift | 27 ++- .../ios/Tests/ProtocolRelayTests.swift | 76 ++++++++ .../components/AcceleratedCheckoutButtons.tsx | 11 +- .../checkout-kit-react-native/src/index.d.ts | 1 + .../checkout-kit-react-native/src/index.ts | 2 + .../checkout-kit-react-native/src/protocol.ts | 63 +------ .../tests/protocol.test.ts | 177 ++++++++++++++---- .../src/generated/ProtocolNotifications.d.ts | 33 ++++ .../src/generated/ProtocolNotifications.ts | 51 +++++ protocol/languages/typescript/src/index.d.ts | 1 + protocol/languages/typescript/src/index.ts | 1 + .../typescript/src/notifications.d.ts | 14 ++ .../languages/typescript/src/notifications.ts | 81 ++++++++ protocol/scripts/generate_models.sh | 38 ++-- .../generate_typescript_notifications.mjs | 146 +++++++++++++++ 19 files changed, 716 insertions(+), 133 deletions(-) create mode 100644 protocol/languages/typescript/src/generated/ProtocolNotifications.d.ts create mode 100644 protocol/languages/typescript/src/generated/ProtocolNotifications.ts create mode 100644 protocol/languages/typescript/src/notifications.d.ts create mode 100644 protocol/languages/typescript/src/notifications.ts create mode 100644 protocol/scripts/generate_typescript_notifications.mjs diff --git a/platforms/react-native/__mocks__/react-native.ts b/platforms/react-native/__mocks__/react-native.ts index 594df9f4..09a916cf 100644 --- a/platforms/react-native/__mocks__/react-native.ts +++ b/platforms/react-native/__mocks__/react-native.ts @@ -56,7 +56,14 @@ const UIManager = { if (name === 'RCTAcceleratedCheckoutButtons') { return { Constants: { - checkoutProtocolEventTypes: ['ec.start'], + checkoutProtocolEventTypes: [ + 'ec.complete', + 'ec.error', + 'ec.line_items.change', + 'ec.messages.change', + 'ec.start', + 'ec.totals.change', + ], }, }; } diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ProtocolRelay.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ProtocolRelay.kt index 3f6fa7fc..f136dd86 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ProtocolRelay.kt +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ProtocolRelay.kt @@ -35,11 +35,36 @@ object ProtocolRelay { var client = CheckoutProtocol.Client() for (method in subscribedMethods) { when (method) { + CheckoutProtocol.complete.method -> { + client = client.on(CheckoutProtocol.complete) { checkout -> + forwardEnvelope(method, checkout, dispatch) + } + } + CheckoutProtocol.error.method -> { + client = client.on(CheckoutProtocol.error) { error -> + forwardEnvelope(method, error, dispatch) + } + } + CheckoutProtocol.lineItemsChange.method -> { + client = client.on(CheckoutProtocol.lineItemsChange) { checkout -> + forwardEnvelope(method, checkout, dispatch) + } + } + CheckoutProtocol.messagesChange.method -> { + client = client.on(CheckoutProtocol.messagesChange) { checkout -> + forwardEnvelope(method, checkout, dispatch) + } + } CheckoutProtocol.start.method -> { client = client.on(CheckoutProtocol.start) { checkout -> forwardEnvelope(method, checkout, dispatch) } } + CheckoutProtocol.totalsChange.method -> { + client = client.on(CheckoutProtocol.totalsChange) { checkout -> + forwardEnvelope(method, checkout, dispatch) + } + } } } return client diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/ProtocolRelayTest.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/ProtocolRelayTest.kt index f1e8ef14..4ff53a95 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/ProtocolRelayTest.kt +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/ProtocolRelayTest.kt @@ -83,6 +83,56 @@ class ProtocolRelayTest { assertThat(logs.single().throwable).isSameAs(failure) } + @Test + fun `relay dispatches envelope for every public checkout state event`() { + val methods = listOf( + "ec.complete", + "ec.line_items.change", + "ec.messages.change", + "ec.start", + "ec.totals.change", + ) + + for (method in methods) { + var captured: String? = null + val client = ProtocolRelay.makeClient( + listOf(method), + DispatchCallback { json -> captured = json }, + ) + + client.process(checkoutNotificationFixture(method)) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + val json = captured + assertThat(json).isNotNull() + val parsed = Json.parseToJsonElement(json!!).jsonObject + assertThat(parsed["type"]?.jsonPrimitive?.content).isEqualTo(method) + assertThat(parsed["payload"]!!.jsonObject["id"]?.jsonPrimitive?.content).isEqualTo("checkout-123") + } + } + + @Test + fun `relay dispatches envelope on ec error`() { + var captured: String? = null + val client = ProtocolRelay.makeClient( + listOf("ec.error"), + DispatchCallback { json -> captured = json }, + ) + + client.process(ecErrorNotificationFixture) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + val json = captured + assertThat(json).isNotNull() + val parsed = Json.parseToJsonElement(json!!).jsonObject + assertThat(parsed["type"]?.jsonPrimitive?.content).isEqualTo("ec.error") + + val payload = parsed["payload"]!!.jsonObject + assertThat(payload["messages"]!!.jsonArray[0].jsonObject["content"]?.jsonPrimitive?.content) + .isEqualTo("Something went wrong") + assertThat(payload["ucp"]!!.jsonObject["status"]?.jsonPrimitive?.content).isEqualTo("error") + } + @Test fun `relay ignores methods not in subscribed list`() { var captured: String? = null @@ -120,6 +170,11 @@ private data class SnakePayload( @SerialName("line_items") val lineItems: List, ) +private fun checkoutNotificationFixture(method: String) = ecStartNotificationFixture.replace( + "\"method\": \"ec.start\"", + "\"method\": \"$method\"", +) + private val ecStartNotificationFixture = """ { "jsonrpc": "2.0", @@ -160,3 +215,25 @@ private val ecStartNotificationFixture = """ } } """.trimIndent() + +private val ecErrorNotificationFixture = """ +{ + "jsonrpc": "2.0", + "method": "ec.error", + "params": { + "error": { + "ucp": { + "version": "2026-04-08", + "status": "error" + }, + "messages": [ + { + "type": "error", + "content": "Something went wrong", + "severity": "recoverable" + } + ] + } + } +} +""".trimIndent() diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/api/checkout-kit-react-native.api.md b/platforms/react-native/modules/@shopify/checkout-kit-react-native/api/checkout-kit-react-native.api.md index e7c63348..d2003252 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/api/checkout-kit-react-native.api.md +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/api/checkout-kit-react-native.api.md @@ -5,6 +5,9 @@ ```ts import { Checkout } from '@shopify/checkout-kit-protocol'; +import { CheckoutProtocol } from '@shopify/checkout-kit-protocol'; +import { CheckoutProtocolPayloads } from '@shopify/checkout-kit-protocol'; +import { ErrorResponse } from '@shopify/checkout-kit-protocol'; import type { PropsWithChildren } from 'react'; import { default as React_2 } from 'react'; @@ -181,16 +184,9 @@ export enum CheckoutNativeErrorType { UnknownError = "UnknownError" } -// @public (undocumented) -export const CheckoutProtocol: { - readonly start: "ec.start"; -}; +export { CheckoutProtocol } -// @public (undocumented) -export interface CheckoutProtocolPayloads { - // (undocumented) - 'ec.start': Checkout; -} +export { CheckoutProtocolPayloads } // @public (undocumented) export enum ColorScheme { @@ -235,6 +231,8 @@ export class DispatchEventParityError extends Error { constructor(message: string); } +export { ErrorResponse } + // @public export interface Features { handleGeolocationRequests: boolean; 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 fb903b16..fe8f0536 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 @@ -16,7 +16,12 @@ struct DispatchEnvelope: Encodable { // 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.start.method + CheckoutProtocol.complete.method, + CheckoutProtocol.error.method, + CheckoutProtocol.lineItemsChange.method, + CheckoutProtocol.messagesChange.method, + CheckoutProtocol.start.method, + CheckoutProtocol.totalsChange.method ] func makeRelayClient( @@ -27,10 +32,30 @@ func makeRelayClient( for method in subscribedMethods { switch method { + case CheckoutProtocol.complete.method: + client = client.on(CheckoutProtocol.complete) { checkout in + forwardEnvelope(type: method, payload: checkout, dispatch: dispatch) + } + case CheckoutProtocol.error.method: + client = client.on(CheckoutProtocol.error) { error in + forwardEnvelope(type: method, payload: error, dispatch: dispatch) + } + case CheckoutProtocol.lineItemsChange.method: + client = client.on(CheckoutProtocol.lineItemsChange) { checkout in + forwardEnvelope(type: method, payload: checkout, dispatch: dispatch) + } + case CheckoutProtocol.messagesChange.method: + client = client.on(CheckoutProtocol.messagesChange) { checkout in + forwardEnvelope(type: method, payload: checkout, dispatch: dispatch) + } case CheckoutProtocol.start.method: client = client.on(CheckoutProtocol.start) { checkout in forwardEnvelope(type: method, payload: checkout, dispatch: dispatch) } + case CheckoutProtocol.totalsChange.method: + client = client.on(CheckoutProtocol.totalsChange) { checkout in + forwardEnvelope(type: method, payload: checkout, dispatch: dispatch) + } default: continue } diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Tests/ProtocolRelayTests.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Tests/ProtocolRelayTests.swift index d7b99bde..dda20310 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Tests/ProtocolRelayTests.swift +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Tests/ProtocolRelayTests.swift @@ -48,6 +48,53 @@ struct ProtocolRelayTests { #expect(paymentHandlers["com.example.loyalty_gold"] != nil) } + @MainActor + @Test func relayDispatchesEnvelopeForEveryPublicCheckoutStateEvent() async throws { + let methods = [ + "ec.complete", + "ec.line_items.change", + "ec.messages.change", + "ec.start", + "ec.totals.change" + ] + + for method in methods { + var captured: String? + let client = makeRelayClient( + subscribedMethods: [method], + dispatch: { json in captured = json } + ) + + _ = await client.process(checkoutNotificationFixture(method: method)) + + let json = try #require(captured) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + #expect(parsed["type"] as? String == method) + let payload = try #require(parsed["payload"] as? [String: Any]) + #expect(payload["id"] as? String == "checkout-123") + } + } + + @MainActor + @Test func relayDispatchesEnvelopeOnEcError() async throws { + var captured: String? + let client = makeRelayClient( + subscribedMethods: ["ec.error"], + dispatch: { json in captured = json } + ) + + _ = await client.process(ecErrorNotificationFixture) + + let json = try #require(captured) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + #expect(parsed["type"] as? String == "ec.error") + let payload = try #require(parsed["payload"] as? [String: Any]) + let messages = try #require(payload["messages"] as? [[String: Any]]) + #expect(messages.first?["content"] as? String == "Something went wrong") + let ucp = try #require(payload["ucp"] as? [String: Any]) + #expect(ucp["status"] as? String == "error") + } + @MainActor @Test func relayIgnoresMethodsNotInSubscribedList() async throws { var captured: String? @@ -72,6 +119,13 @@ private struct SnakePayload: Codable { } } +private func checkoutNotificationFixture(method: String) -> String { + ecStartNotificationFixture.replacingOccurrences( + of: "\"method\": \"ec.start\"", + with: "\"method\": \"\(method)\"" + ) +} + private let ecStartNotificationFixture = #""" { "jsonrpc": "2.0", @@ -112,3 +166,25 @@ private let ecStartNotificationFixture = #""" } } """# + +private let ecErrorNotificationFixture = #""" +{ + "jsonrpc": "2.0", + "method": "ec.error", + "params": { + "error": { + "ucp": { + "version": "2026-04-08", + "status": "error" + }, + "messages": [ + { + "type": "error", + "content": "Something went wrong", + "severity": "recoverable" + } + ] + } + } +} +"""# diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/components/AcceleratedCheckoutButtons.tsx b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/components/AcceleratedCheckoutButtons.tsx index 0b5737e9..abdacf26 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/components/AcceleratedCheckoutButtons.tsx +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/components/AcceleratedCheckoutButtons.tsx @@ -92,7 +92,7 @@ interface CommonAcceleratedCheckoutButtonsProps { /** * Checkout Protocol event handlers scoped to this button instance. * - * Currently supports CheckoutProtocol.start. + * Supports all public Checkout Protocol notification events. */ events?: ProtocolHandlers; @@ -414,10 +414,11 @@ function routeProtocolDispatchEnvelope( return; } - const handler = (events as Record< - string, - ((payload: unknown) => void) | undefined - > | undefined)?.[envelope.type]; + const handler = ( + events as + | Record void) | undefined> + | undefined + )?.[envelope.type]; if (handler == null) { return; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts index 53619e85..15a0a131 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts @@ -14,6 +14,7 @@ export { export type { Checkout, CheckoutProtocolPayloads, + ErrorResponse, ProtocolHandlers, } from './protocol'; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts index 9054fff7..26ff0856 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts @@ -47,6 +47,7 @@ import {CheckoutProtocol} from './protocol'; import type { Checkout, CheckoutProtocolPayloads, + ErrorResponse, ProtocolHandlers, } from './protocol'; @@ -388,6 +389,7 @@ export type { CheckoutException, CheckoutProtocolPayloads, Configuration, + ErrorResponse, Features, GeolocationRequestEvent, IosColors, 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 85757906..09da08c3 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,61 +1,18 @@ -import {Convert, type Checkout} from '@shopify/checkout-kit-protocol'; +import type {CheckoutProtocolPayloads} from '@shopify/checkout-kit-protocol'; -export type {Checkout} from '@shopify/checkout-kit-protocol'; +export { + CheckoutProtocol, + decodeCheckoutProtocolPayload as decodeProtocolPayload, +} from '@shopify/checkout-kit-protocol'; -export const CheckoutProtocol = { - start: 'ec.start', -} as const; - -export interface CheckoutProtocolPayloads { - 'ec.start': Checkout; -} +export type { + Checkout, + CheckoutProtocolPayloads, + ErrorResponse, +} from '@shopify/checkout-kit-protocol'; export type ProtocolHandlers = Partial<{ [K in keyof CheckoutProtocolPayloads]: ( payload: CheckoutProtocolPayloads[K], ) => void; }>; - -type ProtocolPayloadDecoder = ( - payload: unknown, -) => CheckoutProtocolPayloads[K]; - -// Keep this map exhaustive for CheckoutProtocolPayloads. When new protocol -// methods are added, TypeScript fails until their QuickType decoder is wired in. -const protocolPayloadDecoders = { - [CheckoutProtocol.start]: decodeWith(Convert.toCheckout), -} satisfies { - [K in keyof CheckoutProtocolPayloads]: ProtocolPayloadDecoder; -}; - -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 = decoderFor(method); - return decoder?.(payload); -} - -function decodeWith(converter: (json: string) => T): (payload: unknown) => T { - return payload => converter(JSON.stringify(payload)); -} - -function decoderFor( - method: string, -): - | ((payload: unknown) => CheckoutProtocolPayloads[keyof CheckoutProtocolPayloads]) - | undefined { - return protocolPayloadDecoders[ - method as keyof typeof protocolPayloadDecoders - ] as - | ((payload: unknown) => CheckoutProtocolPayloads[keyof CheckoutProtocolPayloads]) - | undefined; -} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/protocol.test.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/protocol.test.ts index 6d86a0ef..b6ee22bc 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/protocol.test.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/protocol.test.ts @@ -1,87 +1,182 @@ import { CheckoutProtocol, type Checkout, + type ErrorResponse, type ProtocolHandlers, } from '../src'; import {decodeProtocolPayload} from '../src/protocol'; +const checkoutPayloadMethods = [ + CheckoutProtocol.complete, + CheckoutProtocol.lineItemsChange, + CheckoutProtocol.messagesChange, + CheckoutProtocol.start, + CheckoutProtocol.totalsChange, +] as const; + describe('CheckoutProtocol', () => { describe('runtime values', () => { - it('exposes ec.start as the literal method string', () => { - expect(CheckoutProtocol.start).toBe('ec.start'); + it('exposes all public checkout protocol notification method strings', () => { + expect(CheckoutProtocol).toEqual({ + complete: 'ec.complete', + error: 'ec.error', + lineItemsChange: 'ec.line_items.change', + messagesChange: 'ec.messages.change', + start: 'ec.start', + totalsChange: 'ec.totals.change', + }); + expect(CheckoutProtocol).not.toHaveProperty('buyerChange'); + expect(CheckoutProtocol).not.toHaveProperty('paymentChange'); }); }); describe('wire payload decoding', () => { it('returns undefined for methods without a registered payload decoder', () => { expect(decodeProtocolPayload('ec.unknown', {})).toBeUndefined(); + expect(decodeProtocolPayload('ec.buyer.change', {})).toBeUndefined(); + expect(decodeProtocolPayload('ec.payment.change', {})).toBeUndefined(); }); - it('converts schema fields to camelCase while preserving dynamic map keys', () => { - const decoded = decodeProtocolPayload(CheckoutProtocol.start, { - id: 'checkout-123', - currency: 'USD', - status: 'incomplete', - line_items: [], - totals: [], - links: [], + it.each(checkoutPayloadMethods)( + 'converts %s checkout schema fields to camelCase while preserving dynamic map keys', + method => { + const decoded = decodeProtocolPayload(method, { + id: 'checkout-123', + currency: 'USD', + status: 'incomplete', + line_items: [], + totals: [], + links: [], + ucp: { + version: '2026-04-08', + payment_handlers: { + loyalty_gold: [ + { + id: 'handler-1', + version: '2026-04-08', + available_instruments: [ + { + type: 'card', + constraints: { + merchant_defined_key: true, + }, + }, + ], + }, + ], + 'com.example.loyalty_gold': [], + }, + }, + }); + + expect(decoded?.lineItems).toEqual([]); + expect(decoded?.ucp.paymentHandlers).toHaveProperty('loyalty_gold'); + expect( + Object.prototype.hasOwnProperty.call( + decoded?.ucp.paymentHandlers, + 'com.example.loyalty_gold', + ), + ).toBe(true); + const loyaltyHandlers = decoded?.ucp.paymentHandlers.loyalty_gold; + expect(loyaltyHandlers).toBeDefined(); + const loyaltyHandler = loyaltyHandlers?.[0]; + expect(loyaltyHandler?.availableInstruments?.[0]?.constraints).toEqual({ + merchant_defined_key: true, + }); + expect(loyaltyHandler).not.toHaveProperty('available_instruments'); + }, + ); + + it('converts error schema fields to camelCase while preserving dynamic map keys', () => { + const decoded = decodeProtocolPayload(CheckoutProtocol.error, { + continue_url: 'https://example.test/recover', + messages: [ + { + content: 'Something went wrong', + content_type: 'plain', + type: 'error', + }, + ], ucp: { version: '2026-04-08', + status: 'error', payment_handlers: { - loyalty_gold: [ - { - id: 'handler-1', - version: '2026-04-08', - available_instruments: [ - { - type: 'card', - constraints: { - merchant_defined_key: true, - }, - }, - ], - }, - ], 'com.example.loyalty_gold': [], }, }, }); - expect(decoded?.lineItems).toEqual([]); - expect(decoded?.ucp.paymentHandlers).toHaveProperty('loyalty_gold'); + expect(decoded?.continueUrl).toBe('https://example.test/recover'); + expect(decoded?.messages[0]?.contentType).toBe('plain'); expect( Object.prototype.hasOwnProperty.call( decoded?.ucp.paymentHandlers, 'com.example.loyalty_gold', ), ).toBe(true); - const loyaltyHandlers = decoded?.ucp.paymentHandlers.loyalty_gold; - expect(loyaltyHandlers).toBeDefined(); - const loyaltyHandler = loyaltyHandlers?.[0]; - expect(loyaltyHandler?.availableInstruments?.[0]?.constraints).toEqual({ - merchant_defined_key: true, - }); - expect(loyaltyHandler).not.toHaveProperty('available_instruments'); }); }); describe('ProtocolHandlers typing', () => { - it('accepts a handler keyed by CheckoutProtocol.start', () => { + it('accepts handlers keyed by every public CheckoutProtocol event', () => { const handlers: ProtocolHandlers = { - [CheckoutProtocol.start]: chk => { - expect(typeof chk.id).toBe('string'); + [CheckoutProtocol.complete]: checkout => { + expect(typeof checkout.id).toBe('string'); + }, + [CheckoutProtocol.error]: error => { + expect(error.messages).toBeDefined(); + }, + [CheckoutProtocol.lineItemsChange]: checkout => { + expect(typeof checkout.id).toBe('string'); + }, + [CheckoutProtocol.messagesChange]: checkout => { + expect(typeof checkout.id).toBe('string'); + }, + [CheckoutProtocol.start]: checkout => { + expect(typeof checkout.id).toBe('string'); + }, + [CheckoutProtocol.totalsChange]: checkout => { + expect(typeof checkout.id).toBe('string'); }, }; + expect(typeof handlers[CheckoutProtocol.complete]).toBe('function'); + expect(typeof handlers[CheckoutProtocol.error]).toBe('function'); + expect(typeof handlers[CheckoutProtocol.lineItemsChange]).toBe( + 'function', + ); + expect(typeof handlers[CheckoutProtocol.messagesChange]).toBe('function'); expect(typeof handlers[CheckoutProtocol.start]).toBe('function'); + expect(typeof handlers[CheckoutProtocol.totalsChange]).toBe('function'); + }); + + it('infers Checkout as the payload type for checkout-state events', () => { + type HandlerMap = ProtocolHandlers; + type CheckoutPayloadMethod = (typeof checkoutPayloadMethods)[number]; + type CheckoutPayloadParam = Parameters< + NonNullable + >[0]; + + type AllCheckoutPayloads = { + [K in CheckoutPayloadMethod]: Checkout extends CheckoutPayloadParam + ? CheckoutPayloadParam extends Checkout + ? true + : false + : false; + }[CheckoutPayloadMethod]; + + const _typeCheck: AllCheckoutPayloads = true; + + expect(_typeCheck).toBe(true); }); - it('infers Checkout as the start handler payload type', () => { - type StartHandler = NonNullable; - type StartParam = Parameters[0]; + it('infers ErrorResponse as the error handler payload type', () => { + type ErrorHandler = NonNullable; + type ErrorParam = Parameters[0]; - const _typeCheck: Checkout extends StartParam ? true : false = true; - const _reverseCheck: StartParam extends Checkout ? true : false = true; + const _typeCheck: ErrorResponse extends ErrorParam ? true : false = true; + const _reverseCheck: ErrorParam extends ErrorResponse ? true : false = + true; expect(_typeCheck).toBe(true); expect(_reverseCheck).toBe(true); diff --git a/protocol/languages/typescript/src/generated/ProtocolNotifications.d.ts b/protocol/languages/typescript/src/generated/ProtocolNotifications.d.ts new file mode 100644 index 00000000..06b18d43 --- /dev/null +++ b/protocol/languages/typescript/src/generated/ProtocolNotifications.d.ts @@ -0,0 +1,33 @@ +import { type Checkout, type ErrorResponse } from './Models'; +export declare const generatedCheckoutProtocol: { + readonly error: "ec.error"; + readonly start: "ec.start"; + readonly complete: "ec.complete"; + readonly messagesChange: "ec.messages.change"; + readonly lineItemsChange: "ec.line_items.change"; + readonly buyerChange: "ec.buyer.change"; + readonly totalsChange: "ec.totals.change"; + readonly paymentChange: "ec.payment.change"; +}; +export type GeneratedCheckoutProtocolMethod = (typeof generatedCheckoutProtocol)[keyof typeof generatedCheckoutProtocol]; +export interface GeneratedCheckoutProtocolPayloads { + 'ec.error': ErrorResponse; + 'ec.start': Checkout; + 'ec.complete': Checkout; + 'ec.messages.change': Checkout; + 'ec.line_items.change': Checkout; + 'ec.buyer.change': Checkout; + 'ec.totals.change': Checkout; + 'ec.payment.change': Checkout; +} +export type GeneratedCheckoutProtocolPayloadDecoder = (payload: unknown) => GeneratedCheckoutProtocolPayloads[K]; +export declare const generatedCheckoutProtocolPayloadDecoders: { + "ec.error": (payload: unknown) => ErrorResponse; + "ec.start": (payload: unknown) => Checkout; + "ec.complete": (payload: unknown) => Checkout; + "ec.messages.change": (payload: unknown) => Checkout; + "ec.line_items.change": (payload: unknown) => Checkout; + "ec.buyer.change": (payload: unknown) => Checkout; + "ec.totals.change": (payload: unknown) => Checkout; + "ec.payment.change": (payload: unknown) => Checkout; +}; diff --git a/protocol/languages/typescript/src/generated/ProtocolNotifications.ts b/protocol/languages/typescript/src/generated/ProtocolNotifications.ts new file mode 100644 index 00000000..ad2e7b58 --- /dev/null +++ b/protocol/languages/typescript/src/generated/ProtocolNotifications.ts @@ -0,0 +1,51 @@ +// This file is generated by protocol/scripts/generate_typescript_notifications.mjs. +// Do not edit directly. + +import {Convert, type Checkout, type ErrorResponse} from './Models'; + +export const generatedCheckoutProtocol = { + error: 'ec.error', + start: 'ec.start', + complete: 'ec.complete', + messagesChange: 'ec.messages.change', + lineItemsChange: 'ec.line_items.change', + buyerChange: 'ec.buyer.change', + totalsChange: 'ec.totals.change', + paymentChange: 'ec.payment.change', +} as const; + +export type GeneratedCheckoutProtocolMethod = + (typeof generatedCheckoutProtocol)[keyof typeof generatedCheckoutProtocol]; + +export interface GeneratedCheckoutProtocolPayloads { + 'ec.error': ErrorResponse; + 'ec.start': Checkout; + 'ec.complete': Checkout; + 'ec.messages.change': Checkout; + 'ec.line_items.change': Checkout; + 'ec.buyer.change': Checkout; + 'ec.totals.change': Checkout; + 'ec.payment.change': Checkout; +} + +export type GeneratedCheckoutProtocolPayloadDecoder< + K extends keyof GeneratedCheckoutProtocolPayloads, +> = (payload: unknown) => GeneratedCheckoutProtocolPayloads[K]; + +export const generatedCheckoutProtocolPayloadDecoders = { + [generatedCheckoutProtocol.error]: decodeWith(Convert.toErrorResponse), + [generatedCheckoutProtocol.start]: decodeWith(Convert.toCheckout), + [generatedCheckoutProtocol.complete]: decodeWith(Convert.toCheckout), + [generatedCheckoutProtocol.messagesChange]: decodeWith(Convert.toCheckout), + [generatedCheckoutProtocol.lineItemsChange]: decodeWith(Convert.toCheckout), + [generatedCheckoutProtocol.buyerChange]: decodeWith(Convert.toCheckout), + [generatedCheckoutProtocol.totalsChange]: decodeWith(Convert.toCheckout), + [generatedCheckoutProtocol.paymentChange]: decodeWith(Convert.toCheckout), +} satisfies { + [K in keyof GeneratedCheckoutProtocolPayloads]: + GeneratedCheckoutProtocolPayloadDecoder; +}; + +function decodeWith(converter: (json: string) => T): (payload: unknown) => T { + return payload => converter(JSON.stringify(payload)); +} diff --git a/protocol/languages/typescript/src/index.d.ts b/protocol/languages/typescript/src/index.d.ts index 5bb7fb3a..9f217a84 100644 --- a/protocol/languages/typescript/src/index.d.ts +++ b/protocol/languages/typescript/src/index.d.ts @@ -1 +1,2 @@ export * from './generated/Models'; +export * from './notifications'; diff --git a/protocol/languages/typescript/src/index.ts b/protocol/languages/typescript/src/index.ts index 5bb7fb3a..9f217a84 100644 --- a/protocol/languages/typescript/src/index.ts +++ b/protocol/languages/typescript/src/index.ts @@ -1 +1,2 @@ export * from './generated/Models'; +export * from './notifications'; diff --git a/protocol/languages/typescript/src/notifications.d.ts b/protocol/languages/typescript/src/notifications.d.ts new file mode 100644 index 00000000..c4d53311 --- /dev/null +++ b/protocol/languages/typescript/src/notifications.d.ts @@ -0,0 +1,14 @@ +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 new file mode 100644 index 00000000..16cc25cb --- /dev/null +++ b/protocol/languages/typescript/src/notifications.ts @@ -0,0 +1,81 @@ +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.sh b/protocol/scripts/generate_models.sh index f1757794..c0f9763b 100755 --- a/protocol/scripts/generate_models.sh +++ b/protocol/scripts/generate_models.sh @@ -178,32 +178,24 @@ case "$LANG" in "${OUTPUT}" + node "${REPO_ROOT}/protocol/scripts/generate_typescript_notifications.mjs" + # API Extractor consumers require dependency entry points to resolve to # declaration files. Runtime converter output is not valid declaration syntax, - # so emit declarations from the generated TypeScript source. - DECLARATION_OUTPUT="${OUTPUT%.ts}.d.ts" + # so emit declarations from the TypeScript package entry point. + DECLARATION_OUTPUT="${REPO_ROOT}/protocol/languages/typescript/src/index.d.ts" TSC_BIN="${REPO_ROOT}/platforms/react-native/node_modules/typescript/bin/tsc" - if [[ -f "${TSC_BIN}" ]]; then - node "${TSC_BIN}" \ - --declaration \ - --emitDeclarationOnly \ - --noEmit false \ - --rootDir "${REPO_ROOT}/protocol/languages/typescript/src" \ - --declarationDir "${REPO_ROOT}/protocol/languages/typescript/src" \ - --pretty false \ - "${OUTPUT}" - else - tsc \ - --declaration \ - --emitDeclarationOnly \ - --noEmit false \ - --rootDir "${REPO_ROOT}/protocol/languages/typescript/src" \ - --declarationDir "${REPO_ROOT}/protocol/languages/typescript/src" \ - --pretty false \ - "${OUTPUT}" - fi - - echo "Generated ${OUTPUT} and ${DECLARATION_OUTPUT}" + INDEX_OUTPUT="${REPO_ROOT}/protocol/languages/typescript/src/index.ts" + node "${TSC_BIN}" \ + --declaration \ + --emitDeclarationOnly \ + --noEmit false \ + --rootDir "${REPO_ROOT}/protocol/languages/typescript/src" \ + --declarationDir "${REPO_ROOT}/protocol/languages/typescript/src" \ + --pretty false \ + "${INDEX_OUTPUT}" + + echo "Generated ${OUTPUT}, TypeScript protocol notifications, and ${DECLARATION_OUTPUT}" ;; *) diff --git a/protocol/scripts/generate_typescript_notifications.mjs b/protocol/scripts/generate_typescript_notifications.mjs new file mode 100644 index 00000000..e49c041f --- /dev/null +++ b/protocol/scripts/generate_typescript_notifications.mjs @@ -0,0 +1,146 @@ +#!/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/typescript/src/generated/ProtocolNotifications.ts', +); + +const refMappings = new Map([ + [ + 'checkout.json', + { + typeName: 'Checkout', + converter: 'Convert.toCheckout', + }, + ], + [ + 'types/error_response.json', + { + typeName: 'ErrorResponse', + converter: 'Convert.toErrorResponse', + }, + ], +]); + +function normalizeRef(ref) { + return ref.replace(/^\.\.\/\.\.\/schemas\/shopping\//, ''); +} + +function methodNameToIdentifier(methodName) { + const suffix = methodName.replace(/^ec\./, ''); + const parts = suffix.split(/[._]/g).filter(Boolean); + + return parts + .map((part, index) => + index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1), + ) + .join(''); +} + +function isNotification(method) { + return ( + typeof method.name === 'string' && + method.name.startsWith('ec.') && + !Object.prototype.hasOwnProperty.call(method, 'result') + ); +} + +const openRpc = JSON.parse(fs.readFileSync(openRpcPath, 'utf8')); +const notifications = []; + +for (const method of openRpc.methods ?? []) { + if (!isNotification(method)) continue; + + const params = method.params ?? []; + if (params.length !== 1) { + throw new Error( + `Cannot generate notification ${method.name}: expected exactly one param, got ${params.length}`, + ); + } + + const [param] = params; + const ref = param?.schema?.$ref; + if (typeof ref !== 'string') { + throw new Error( + `Cannot generate notification ${method.name}: expected param schema.$ref`, + ); + } + + const normalizedRef = normalizeRef(ref); + const mapping = refMappings.get(normalizedRef); + if (!mapping) { + throw new Error( + `Cannot generate notification ${method.name}: unsupported schema ref ${ref}`, + ); + } + + notifications.push({ + identifier: methodNameToIdentifier(method.name), + method: method.name, + typeName: mapping.typeName, + converter: mapping.converter, + }); +} + +const typeNames = Array.from( + new Set(notifications.map(notification => notification.typeName)), +).sort(); + +const generated = `// This file is generated by protocol/scripts/generate_typescript_notifications.mjs. +// Do not edit directly. + +import {Convert, type ${typeNames.join(', type ')}} from './Models'; + +export const generatedCheckoutProtocol = { +${notifications + .map( + notification => ` ${notification.identifier}: '${notification.method}',`, + ) + .join('\n')} +} as const; + +export type GeneratedCheckoutProtocolMethod = + (typeof generatedCheckoutProtocol)[keyof typeof generatedCheckoutProtocol]; + +export interface GeneratedCheckoutProtocolPayloads { +${notifications + .map( + notification => ` '${notification.method}': ${notification.typeName};`, + ) + .join('\n')} +} + +export type GeneratedCheckoutProtocolPayloadDecoder< + K extends keyof GeneratedCheckoutProtocolPayloads, +> = (payload: unknown) => GeneratedCheckoutProtocolPayloads[K]; + +export const generatedCheckoutProtocolPayloadDecoders = { +${notifications + .map( + notification => + ` [generatedCheckoutProtocol.${notification.identifier}]: decodeWith(${notification.converter}),`, + ) + .join('\n')} +} satisfies { + [K in keyof GeneratedCheckoutProtocolPayloads]: + GeneratedCheckoutProtocolPayloadDecoder; +}; + +function decodeWith(converter: (json: string) => T): (payload: unknown) => T { + return payload => converter(JSON.stringify(payload)); +} +`; + +fs.mkdirSync(path.dirname(outputPath), {recursive: true}); +fs.writeFileSync(outputPath, generated); +console.log(`Generated ${outputPath}`);