@@ -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 = / (?: @ S e r i a l N a m e \( " ( [ ^ " ] + ) " \) \n ) ? p u b l i c v a l ( \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+ / ^ @ S e r i a l i z a b l e \n p u b l i c d a t a c l a s s ( \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 ( / ^ p u b l i c d a t a c l a s s ( \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+
389501async 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+ / ^ p u b l i c s t r u c t ( \w + ) : C o d a b l e , S e n d a b l e \{ \n ( [ \s \S ] * ?) \n \} $ / gm,
548+ ( match , name , inner ) => {
549+ if ( SWIFT_CLOSED_STRUCTS . has ( name ) ) {
550+ return match ;
551+ }
552+ if ( / \n p u b l i c i n i t \( f r o m d e c o d e r : / . test ( inner ) ) {
553+ throw new Error ( `Swift additionalProperties injection: struct ${ name } already declares a custom decoder` ) ;
554+ }
555+
556+ const props = [ ...inner . matchAll ( / ^ p u b l i c l e t ( \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 = / ^ p u b l i c e n u m C o d i n g K e y s : S t r i n g , C o d i n g K e y \{ / 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 ( / ^ p u b l i c s t r u c t ( \w + ) : C o d a b l e , S e n d a b l e \{ $ / 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+
429623async 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