Skip to content
Merged
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
12 changes: 11 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2,085 changes: 1,320 additions & 765 deletions platforms/swift/api/EmbeddedCheckoutProtocol.json

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.shopify.ucp.embedded.checkout

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test

class ExtensionPreservationTest {

private val json = Json { ignoreUnknownKeys = true }

@Test
fun `preserves unknown extension keys on Checkout`() {
val wire = """
{
"id": "checkout-123",
"currency": "USD",
"line_items": [],
"links": [],
"status": "incomplete",
"totals": [],
"ucp": {"payment_handlers": {}, "version": "2026-04-08"},
"signals": {"dev.ucp.buyer_ip": "203.0.113.7", "com.example.device_id": "abc-123"},
"com.example.foo": "bar"
}
""".trimIndent()

val checkout = json.decodeFromString(Checkout.serializer(), wire)
val reEncoded = json.parseToJsonElement(json.encodeToString(Checkout.serializer(), checkout)).jsonObject

assertThat(reEncoded["com.example.foo"]?.jsonPrimitive?.content).isEqualTo("bar")

val signals = reEncoded["signals"]?.jsonObject
assertThat(signals?.get("dev.ucp.buyer_ip")?.jsonPrimitive?.content).isEqualTo("203.0.113.7")
assertThat(signals?.get("com.example.device_id")?.jsonPrimitive?.content).isEqualTo("abc-123")

val colliding = checkout.copy(
additionalProperties = checkout.additionalProperties + ("id" to JsonPrimitive("extension-id")),
)
val collisionEncoded = json.parseToJsonElement(json.encodeToString(Checkout.serializer(), colliding)).jsonObject
assertThat(collisionEncoded["id"]?.jsonPrimitive?.content).isEqualTo("checkout-123")
}
}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,36 @@ struct ModelDecodingTests {
#expect(colorScheme == [.light, .dark])
#expect(config.delegate == ["window.open"])
}

@Test func preservesUnknownExtensionKeysOnCheckout() throws {
let json = """
{
"id": "checkout-123",
"currency": "USD",
"line_items": [],
"links": [],
"status": "incomplete",
"totals": [],
"ucp": {"payment_handlers": {}, "version": "2026-04-08"},
"signals": {"dev.ucp.buyer_ip": "203.0.113.7", "com.example.device_id": "abc-123"},
"com.example.foo": "bar"
}
"""
var checkout = try JSONDecoder().decode(Checkout.self, from: Data(json.utf8))
let reEncoded = try JSONEncoder().encode(checkout)
let object = try #require(try JSONSerialization.jsonObject(with: reEncoded) as? [String: Any])

#expect(object["com.example.foo"] as? String == "bar")

let signals = try #require(object["signals"] as? [String: Any])
#expect(signals["dev.ucp.buyer_ip"] as? String == "203.0.113.7")
#expect(signals["com.example.device_id"] as? String == "abc-123")

checkout.additionalProperties["id"] = try JSONDecoder().decode(JSONAny.self, from: Data("\"extension-id\"".utf8))
let collisionEncoded = try JSONEncoder().encode(checkout)
let collisionObject = try #require(try JSONSerialization.jsonObject(with: collisionEncoded) as? [String: Any])
#expect(collisionObject["id"] as? String == "checkout-123")
}
}

private func fixtureString(_ name: String) throws -> String {
Expand Down
15 changes: 3 additions & 12 deletions protocol/languages/typescript/src/generated/Models.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ export interface Checkout {
*/
order?: OrderConfirmation;
payment?: Payment;
signals?: Signals;
signals?: {
[key: string]: any;
};
/**
* Checkout state indicating the current phase and required action. See Checkout Status
* lifecycle documentation for state transition details.
Expand Down Expand Up @@ -715,17 +717,6 @@ export interface PaymentCredential {
* use reverse-domain naming to ensure provenance and prevent collisions when multiple
* extensions contribute to the shared namespace.
*/
export interface Signals {
/**
* Client's IP address (IPv4 or IPv6).
*/
devUcpBuyerIp?: string;
/**
* Client's HTTP User-Agent header or equivalent.
*/
devUcpUserAgent?: string;
[property: string]: any;
}
/**
* Checkout state indicating the current phase and required action. See Checkout Status
* lifecycle documentation for state transition details.
Expand Down
19 changes: 2 additions & 17 deletions protocol/languages/typescript/src/generated/Models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export interface Checkout {
*/
order?: OrderConfirmation;
payment?: Payment;
signals?: Signals;
signals?: { [key: string]: any };
/**
* Checkout state indicating the current phase and required action. See Checkout Status
* lifecycle documentation for state transition details.
Expand Down Expand Up @@ -758,17 +758,6 @@ export interface PaymentCredential {
* use reverse-domain naming to ensure provenance and prevent collisions when multiple
* extensions contribute to the shared namespace.
*/
export interface Signals {
/**
* Client's IP address (IPv4 or IPv6).
*/
devUcpBuyerIp?: string;
/**
* Client's HTTP User-Agent header or equivalent.
*/
devUcpUserAgent?: string;
[property: string]: any;
}

/**
* Checkout state indicating the current phase and required action. See Checkout Status
Expand Down Expand Up @@ -2132,7 +2121,7 @@ const typeMap: any = {
{ json: "messages", js: "messages", typ: u(undefined, a(r("Message"))) },
{ json: "order", js: "order", typ: u(undefined, r("OrderConfirmation")) },
{ json: "payment", js: "payment", typ: u(undefined, r("Payment")) },
{ json: "signals", js: "signals", typ: u(undefined, r("Signals")) },
{ json: "signals", js: "signals", typ: u(undefined, m("any")) },
{ json: "status", js: "status", typ: r("CheckoutStatus") },
{ json: "totals", js: "totals", typ: a(r("CheckoutTotal")) },
{ json: "ucp", js: "ucp", typ: r("UcpCheckoutResponseSchema") },
Expand Down Expand Up @@ -2283,10 +2272,6 @@ const typeMap: any = {
"PaymentCredential": o([
{ json: "type", js: "type", typ: "" },
], "any"),
"Signals": o([
{ json: "dev.ucp.buyer_ip", js: "devUcpBuyerIp", typ: u(undefined, "") },
{ json: "dev.ucp.user_agent", js: "devUcpUserAgent", typ: u(undefined, "") },
], "any"),
"CheckoutTotal": o([
{ json: "amount", js: "amount", typ: 0 },
{ json: "display_text", js: "displayText", typ: u(undefined, "") },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {expect, test} from 'vitest';

import {Convert} from '../src/generated/Models';

test('preserves unknown top-level and nested extension keys through Convert round-trip', () => {
const wire = {
id: 'checkout-123',
currency: 'USD',
line_items: [],
links: [],
status: 'incomplete',
totals: [],
ucp: {payment_handlers: {}, version: '2026-04-08'},
signals: {
'dev.ucp.buyer_ip': '203.0.113.7',
'com.example.device_id': 'abc-123',
},
'com.example.foo': 'bar',
};

const checkout = Convert.toCheckout(JSON.stringify(wire));
const roundTripped = JSON.parse(Convert.checkoutToJson(checkout));

expect(roundTripped['com.example.foo']).toBe('bar');
expect(roundTripped.signals['dev.ucp.buyer_ip']).toBe('203.0.113.7');
expect(roundTripped.signals['com.example.device_id']).toBe('abc-123');
});
Loading
Loading