Skip to content

Commit 030498c

Browse files
committed
Preserve additionalProperties
1 parent e32ab89 commit 030498c

8 files changed

Lines changed: 5566 additions & 1286 deletions

File tree

protocol/languages/kotlin/embedded-checkout-protocol/api/embedded-checkout-protocol.api

Lines changed: 972 additions & 996 deletions
Large diffs are not rendered by default.

protocol/languages/kotlin/embedded-checkout-protocol/src/main/java/com/shopify/ucp/embedded/checkout/Models.kt

Lines changed: 2299 additions & 154 deletions
Large diffs are not rendered by default.

protocol/languages/kotlin/embedded-checkout-protocol/src/test/java/com/shopify/ucp/embedded/checkout/ExtensionPreservationTest.kt

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,6 @@ class ExtensionPreservationTest {
1010

1111
private val json = Json { ignoreUnknownKeys = true }
1212

13-
@Test
14-
fun `preserves unknown extension keys on Signals`() {
15-
val wire = """{"dev.ucp.buyer_ip":"203.0.113.7","com.example.device_id":"abc-123"}"""
16-
17-
val signals = json.decodeFromString(Signals.serializer(), wire)
18-
val reEncoded = json.parseToJsonElement(json.encodeToString(Signals.serializer(), signals)).jsonObject
19-
20-
assertThat(reEncoded["dev.ucp.buyer_ip"]?.jsonPrimitive?.content).isEqualTo("203.0.113.7")
21-
assertThat(reEncoded["com.example.device_id"]?.jsonPrimitive?.content).isEqualTo("abc-123")
22-
}
23-
2413
@Test
2514
fun `preserves unknown extension keys on Checkout`() {
2615
val wire = """
@@ -32,6 +21,7 @@ class ExtensionPreservationTest {
3221
"status": "incomplete",
3322
"totals": [],
3423
"ucp": {"payment_handlers": {}, "version": "2026-04-08"},
24+
"signals": {"dev.ucp.buyer_ip": "203.0.113.7", "com.example.device_id": "abc-123"},
3525
"com.example.foo": "bar"
3626
}
3727
""".trimIndent()
@@ -40,5 +30,9 @@ class ExtensionPreservationTest {
4030
val reEncoded = json.parseToJsonElement(json.encodeToString(Checkout.serializer(), checkout)).jsonObject
4131

4232
assertThat(reEncoded["com.example.foo"]?.jsonPrimitive?.content).isEqualTo("bar")
33+
34+
val signals = reEncoded["signals"]?.jsonObject
35+
assertThat(signals?.get("dev.ucp.buyer_ip")?.jsonPrimitive?.content).isEqualTo("203.0.113.7")
36+
assertThat(signals?.get("com.example.device_id")?.jsonPrimitive?.content).isEqualTo("abc-123")
4337
}
4438
}

protocol/languages/swift/Sources/UniversalCommerceProtocol/EmbeddedCheckoutProtocol/Generated/Models.swift

Lines changed: 2083 additions & 67 deletions
Large diffs are not rendered by default.

protocol/languages/swift/Tests/EmbeddedCheckoutProtocolTests/ModelDecodingTests.swift

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -154,18 +154,6 @@ struct ModelDecodingTests {
154154
#expect(config.delegate == ["window.open"])
155155
}
156156

157-
@Test func preservesUnknownExtensionKeysOnSignals() throws {
158-
let json = """
159-
{"dev.ucp.buyer_ip":"203.0.113.7","com.example.device_id":"abc-123"}
160-
"""
161-
let signals = try JSONDecoder().decode(Signals.self, from: Data(json.utf8))
162-
let reEncoded = try JSONEncoder().encode(signals)
163-
let object = try #require(try JSONSerialization.jsonObject(with: reEncoded) as? [String: Any])
164-
165-
#expect(object["dev.ucp.buyer_ip"] as? String == "203.0.113.7")
166-
#expect(object["com.example.device_id"] as? String == "abc-123")
167-
}
168-
169157
@Test func preservesUnknownExtensionKeysOnCheckout() throws {
170158
let json = """
171159
{
@@ -176,6 +164,7 @@ struct ModelDecodingTests {
176164
"status": "incomplete",
177165
"totals": [],
178166
"ucp": {"payment_handlers": {}, "version": "2026-04-08"},
167+
"signals": {"dev.ucp.buyer_ip": "203.0.113.7", "com.example.device_id": "abc-123"},
179168
"com.example.foo": "bar"
180169
}
181170
"""
@@ -184,6 +173,10 @@ struct ModelDecodingTests {
184173
let object = try #require(try JSONSerialization.jsonObject(with: reEncoded) as? [String: Any])
185174

186175
#expect(object["com.example.foo"] as? String == "bar")
176+
177+
let signals = try #require(object["signals"] as? [String: Any])
178+
#expect(signals["dev.ucp.buyer_ip"] as? String == "203.0.113.7")
179+
#expect(signals["com.example.device_id"] as? String == "abc-123")
187180
}
188181
}
189182

protocol/languages/typescript/src/generated/Models.d.ts

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ export interface Checkout {
5151
*/
5252
order?: OrderConfirmation;
5353
payment?: Payment;
54-
signals?: Signals;
54+
signals?: {
55+
[key: string]: any;
56+
};
5557
/**
5658
* Checkout state indicating the current phase and required action. See Checkout Status
5759
* lifecycle documentation for state transition details.
@@ -708,24 +710,6 @@ export interface PaymentCredential {
708710
type: string;
709711
[property: string]: any;
710712
}
711-
/**
712-
* Environment data provided by the platform to support authorization and abuse prevention.
713-
* Values MUST NOT be buyer-asserted claims — platforms provide signals based on direct
714-
* observation or independently verifiable third-party attestations. All signal keys MUST
715-
* use reverse-domain naming to ensure provenance and prevent collisions when multiple
716-
* extensions contribute to the shared namespace.
717-
*/
718-
export interface Signals {
719-
/**
720-
* Client's IP address (IPv4 or IPv6).
721-
*/
722-
devUcpBuyerIp?: string;
723-
/**
724-
* Client's HTTP User-Agent header or equivalent.
725-
*/
726-
devUcpUserAgent?: string;
727-
[property: string]: any;
728-
}
729713
/**
730714
* Checkout state indicating the current phase and required action. See Checkout Status
731715
* lifecycle documentation for state transition details.

protocol/languages/typescript/src/generated/Models.ts

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export interface Checkout {
6969
*/
7070
order?: OrderConfirmation;
7171
payment?: Payment;
72-
signals?: Signals;
72+
signals?: { [key: string]: any };
7373
/**
7474
* Checkout state indicating the current phase and required action. See Checkout Status
7575
* lifecycle documentation for state transition details.
@@ -751,25 +751,6 @@ export interface PaymentCredential {
751751
[property: string]: any;
752752
}
753753

754-
/**
755-
* Environment data provided by the platform to support authorization and abuse prevention.
756-
* Values MUST NOT be buyer-asserted claims — platforms provide signals based on direct
757-
* observation or independently verifiable third-party attestations. All signal keys MUST
758-
* use reverse-domain naming to ensure provenance and prevent collisions when multiple
759-
* extensions contribute to the shared namespace.
760-
*/
761-
export interface Signals {
762-
/**
763-
* Client's IP address (IPv4 or IPv6).
764-
*/
765-
devUcpBuyerIp?: string;
766-
/**
767-
* Client's HTTP User-Agent header or equivalent.
768-
*/
769-
devUcpUserAgent?: string;
770-
[property: string]: any;
771-
}
772-
773754
/**
774755
* Checkout state indicating the current phase and required action. See Checkout Status
775756
* lifecycle documentation for state transition details.
@@ -2132,7 +2113,7 @@ const typeMap: any = {
21322113
{ json: "messages", js: "messages", typ: u(undefined, a(r("Message"))) },
21332114
{ json: "order", js: "order", typ: u(undefined, r("OrderConfirmation")) },
21342115
{ json: "payment", js: "payment", typ: u(undefined, r("Payment")) },
2135-
{ json: "signals", js: "signals", typ: u(undefined, r("Signals")) },
2116+
{ json: "signals", js: "signals", typ: u(undefined, m("any")) },
21362117
{ json: "status", js: "status", typ: r("CheckoutStatus") },
21372118
{ json: "totals", js: "totals", typ: a(r("CheckoutTotal")) },
21382119
{ json: "ucp", js: "ucp", typ: r("UcpCheckoutResponseSchema") },
@@ -2283,10 +2264,6 @@ const typeMap: any = {
22832264
"PaymentCredential": o([
22842265
{ json: "type", js: "type", typ: "" },
22852266
], "any"),
2286-
"Signals": o([
2287-
{ json: "dev.ucp.buyer_ip", js: "devUcpBuyerIp", typ: u(undefined, "") },
2288-
{ json: "dev.ucp.user_agent", js: "devUcpUserAgent", typ: u(undefined, "") },
2289-
], "any"),
22902267
"CheckoutTotal": o([
22912268
{ json: "amount", js: "amount", typ: 0 },
22922269
{ json: "display_text", js: "displayText", typ: u(undefined, "") },

protocol/scripts/generate_models.mjs

Lines changed: 197 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,11 @@ async function prepareCodegenSchemas(tempDir) {
236236
});
237237
await writeJson(path.join(schemaDir, "ucp.json"), ucp);
238238

239+
const signals = await readJson(path.join(specDir, "types", "signals.json"));
240+
delete signals.properties["dev.ucp.buyer_ip"];
241+
delete signals.properties["dev.ucp.user_agent"];
242+
await writeJson(path.join(specDir, "types", "signals.json"), signals);
243+
239244
return specDir;
240245
}
241246

@@ -386,6 +391,113 @@ function commonSchemaSources(specDir) {
386391
];
387392
}
388393

394+
const KOTLIN_CLOSED_CLASSES = new Set(["ErrorResponse"]);
395+
396+
function parseKotlinFields(inner) {
397+
const fields = [];
398+
const fieldPattern = /(?: @SerialName\("([^"]+)"\)\n)? public val (\w+): ([^\n]+)/g;
399+
let entry;
400+
while ((entry = fieldPattern.exec(inner)) !== null) {
401+
const [, serialName, name, tail] = entry;
402+
let rest = tail.trim();
403+
if (rest.endsWith(",")) {
404+
rest = rest.slice(0, -1).trim();
405+
}
406+
const eq = rest.indexOf(" = ");
407+
const type = eq === -1 ? rest : rest.slice(0, eq).trim();
408+
const optional = eq !== -1;
409+
fields.push({
410+
name,
411+
wire: serialName ?? name,
412+
type,
413+
baseType: type.endsWith("?") ? type.slice(0, -1) : type,
414+
optional,
415+
});
416+
}
417+
return fields;
418+
}
419+
420+
function kotlinSerializerObject(name, fields) {
421+
const knownList = fields.map((field) => `"${field.wire}"`).join(", ");
422+
423+
const ctorArgs = fields
424+
.map((field) =>
425+
field.optional
426+
? ` ${field.name} = obj["${field.wire}"]?.let { json.decodeFromJsonElement(serializer<${field.baseType}>(), it) },`
427+
: ` ${field.name} = json.decodeFromJsonElement(serializer<${field.type}>(), obj["${field.wire}"] ?: throw SerializationException("Missing ${field.wire} for ${name}")),`,
428+
)
429+
.join("\n");
430+
431+
const serLines = fields
432+
.map((field) =>
433+
field.optional
434+
? ` value.${field.name}?.let { map["${field.wire}"] = json.encodeToJsonElement(serializer<${field.baseType}>(), it) }`
435+
: ` map["${field.wire}"] = json.encodeToJsonElement(serializer<${field.type}>(), value.${field.name})`,
436+
)
437+
.join("\n");
438+
439+
return [
440+
"",
441+
`public object ${name}Serializer : KSerializer<${name}> {`,
442+
" override val descriptor: SerialDescriptor =",
443+
` buildClassSerialDescriptor("com.shopify.ucp.embedded.checkout.${name}")`,
444+
"",
445+
` override fun deserialize(decoder: Decoder): ${name} {`,
446+
" val input = decoder as? JsonDecoder",
447+
` ?: throw SerializationException("${name} can only be deserialized from JSON")`,
448+
" val obj = input.decodeJsonElement().jsonObject",
449+
" val json = input.json",
450+
` val known = setOf(${knownList})`,
451+
` return ${name}(`,
452+
ctorArgs,
453+
" additionalProperties = obj.filterKeys { it !in known }",
454+
" )",
455+
" }",
456+
"",
457+
` override fun serialize(encoder: Encoder, value: ${name}) {`,
458+
" val output = encoder as? JsonEncoder",
459+
` ?: throw SerializationException("${name} can only be serialized to JSON")`,
460+
" val json = output.json",
461+
" val map = linkedMapOf<String, JsonElement>()",
462+
serLines,
463+
" map.putAll(value.additionalProperties)",
464+
" output.encodeJsonElement(JsonObject(map))",
465+
" }",
466+
"}",
467+
]
468+
.filter((line) => line !== "")
469+
.join("\n");
470+
}
471+
472+
function injectKotlinAdditionalProperties(source) {
473+
const serializers = [];
474+
475+
const result = source.replace(
476+
/^@Serializable\npublic data class (\w+) \(\n([\s\S]*?)\n\)$/gm,
477+
(match, name, inner) => {
478+
if (KOTLIN_CLOSED_CLASSES.has(name)) {
479+
return match;
480+
}
481+
482+
const fields = parseKotlinFields(inner);
483+
serializers.push(kotlinSerializerObject(name, fields));
484+
485+
const widenedInner = `${inner},\n\n public val additionalProperties: Map<String, JsonElement> = emptyMap()`;
486+
return `@Serializable(with = ${name}Serializer::class)\npublic data class ${name} (\n${widenedInner}\n)`;
487+
},
488+
);
489+
490+
const expected = [...source.matchAll(/^public data class (\w+) \(/gm)]
491+
.map((entry) => entry[1])
492+
.filter((name) => !KOTLIN_CLOSED_CLASSES.has(name)).length;
493+
494+
if (serializers.length !== expected) {
495+
throw new Error(`Kotlin additionalProperties injection reached ${serializers.length} classes, expected ${expected}`);
496+
}
497+
498+
return `${result}\n${serializers.join("\n")}\n`;
499+
}
500+
389501
async function generateKotlin(specDir, output) {
390502
await fs.mkdir(path.dirname(output), {recursive: true});
391503
await runQuicktype([
@@ -422,10 +534,92 @@ async function generateKotlin(specDir, output) {
422534
throw new Error("ExtendsSerializer injection failed; quicktype Extends output may have changed");
423535
}
424536

425-
return withSerializer;
537+
return injectKotlinAdditionalProperties(withSerializer);
426538
});
427539
}
428540

541+
const SWIFT_CLOSED_STRUCTS = new Set(["ErrorResponse"]);
542+
543+
function injectSwiftAdditionalProperties(source) {
544+
let injected = 0;
545+
546+
const result = source.replace(
547+
/^public struct (\w+): Codable, Sendable \{\n([\s\S]*?)\n\}$/gm,
548+
(match, name, inner) => {
549+
if (SWIFT_CLOSED_STRUCTS.has(name)) {
550+
return match;
551+
}
552+
if (/\n public init\(from decoder:/.test(inner)) {
553+
throw new Error(`Swift additionalProperties injection: struct ${name} already declares a custom decoder`);
554+
}
555+
556+
const props = [...inner.matchAll(/^ public let (\w+): (.+)$/gm)].map((entry) => {
557+
const type = entry[2].trim();
558+
const optional = type.endsWith("?");
559+
return {name: entry[1], optional, baseType: optional ? type.slice(0, -1) : type};
560+
});
561+
562+
const hasCodingKeys = /^ public enum CodingKeys: String, CodingKey \{/m.test(inner);
563+
564+
const lines = ["", "", " public var additionalProperties: [String: JSONAny] = [:]", ""];
565+
566+
if (!hasCodingKeys) {
567+
lines.push(" public enum CodingKeys: String, CodingKey {");
568+
if (props.length > 0) {
569+
lines.push(` case ${props.map((prop) => prop.name).join(", ")}`);
570+
}
571+
lines.push(" }", "");
572+
}
573+
574+
lines.push(" public init(from decoder: Decoder) throws {");
575+
lines.push(" let container = try decoder.container(keyedBy: CodingKeys.self)");
576+
for (const prop of props) {
577+
lines.push(
578+
prop.optional
579+
? ` self.${prop.name} = try container.decodeIfPresent(${prop.baseType}.self, forKey: .${prop.name})`
580+
: ` self.${prop.name} = try container.decode(${prop.baseType}.self, forKey: .${prop.name})`,
581+
);
582+
}
583+
lines.push(" let additionalContainer = try decoder.container(keyedBy: JSONCodingKey.self)");
584+
lines.push(" let knownKeys = Set(container.allKeys.map { $0.stringValue })");
585+
lines.push(" var extras: [String: JSONAny] = [:]");
586+
lines.push(" for key in additionalContainer.allKeys where !knownKeys.contains(key.stringValue) {");
587+
lines.push(" extras[key.stringValue] = try additionalContainer.decode(JSONAny.self, forKey: key)");
588+
lines.push(" }");
589+
lines.push(" self.additionalProperties = extras");
590+
lines.push(" }", "");
591+
592+
lines.push(" public func encode(to encoder: Encoder) throws {");
593+
lines.push(" var container = encoder.container(keyedBy: CodingKeys.self)");
594+
for (const prop of props) {
595+
lines.push(
596+
prop.optional
597+
? ` try container.encodeIfPresent(${prop.name}, forKey: .${prop.name})`
598+
: ` try container.encode(${prop.name}, forKey: .${prop.name})`,
599+
);
600+
}
601+
lines.push(" var additionalContainer = encoder.container(keyedBy: JSONCodingKey.self)");
602+
lines.push(" for key in additionalProperties.keys.sorted() {");
603+
lines.push(" try additionalContainer.encode(additionalProperties[key]!, forKey: JSONCodingKey(stringValue: key)!)");
604+
lines.push(" }");
605+
lines.push(" }");
606+
607+
injected += 1;
608+
return `public struct ${name}: Codable, Sendable {\n${inner}${lines.join("\n")}\n}`;
609+
},
610+
);
611+
612+
const expected = [...source.matchAll(/^public struct (\w+): Codable, Sendable \{$/gm)]
613+
.map((entry) => entry[1])
614+
.filter((name) => !SWIFT_CLOSED_STRUCTS.has(name)).length;
615+
616+
if (injected !== expected) {
617+
throw new Error(`Swift additionalProperties injection reached ${injected} structs, expected ${expected}`);
618+
}
619+
620+
return result;
621+
}
622+
429623
async function generateSwift(specDir, output) {
430624
await fs.mkdir(path.dirname(output), {recursive: true});
431625
await runQuicktype([
@@ -459,7 +653,8 @@ async function generateSwift(specDir, output) {
459653
throw new Error(`Swift JSON helper normalization failed; quicktype helper output changed (sha256: ${generatedHelperHash})`);
460654
}
461655

462-
return `${source.slice(0, helperStart)}${SWIFT_JSON_HELPER_REPLACEMENT}`;
656+
const stripped = `${source.slice(0, helperStart)}${SWIFT_JSON_HELPER_REPLACEMENT}`;
657+
return injectSwiftAdditionalProperties(stripped);
463658
});
464659

465660
await run("node", [path.join(PROTOCOL_DIR, "scripts", "generate_swift_catalog.mjs")]);

0 commit comments

Comments
 (0)