@@ -23,10 +23,11 @@ def is_typescript_reserved_word(word: str) -> bool:
2323class AvroToTypeScript :
2424 """Converts Avro schema to TypeScript classes using templates with namespace support."""
2525
26- def __init__ (self , base_package : str = '' , typed_json_annotation = False , avro_annotation = False ) -> None :
26+ def __init__ (self , base_package : str = '' , typed_json_annotation = False , avro_annotation = False , xml_annotation = False ) -> None :
2727 self .base_package = base_package
2828 self .typed_json_annotation = typed_json_annotation
2929 self .avro_annotation = avro_annotation
30+ self .xml_annotation = xml_annotation
3031 self .output_dir = os .getcwd ()
3132 self .src_dir = os .path .join (self .output_dir , "src" )
3233 self .generated_types : Dict [str , str ] = {}
@@ -87,6 +88,60 @@ def safe_name(self, name: str) -> str:
8788 return name + "_"
8889 return name
8990
91+ @staticmethod
92+ def xml_name (default_name : str , schema : Dict ) -> str :
93+ """Resolve an XML local name from an ``altnames.xml`` annotation."""
94+ altnames = schema .get ("altnames" , {})
95+ return str (altnames .get ("xml" , default_name )) if isinstance (altnames , dict ) else default_name
96+
97+ def xml_enum_values (self , schema : Dict ) -> Dict [str , str ]:
98+ """Build runtime-enum-value to XML-wire-value mappings."""
99+ alternates = schema .get ("altenums" , {})
100+ xml_alternates = alternates .get ("xml" , {}) if isinstance (alternates , dict ) else {}
101+ return {str (symbol ): str (xml_alternates .get (str (symbol ), symbol )) for symbol in schema .get ("symbols" , [])}
102+
103+ def xml_type_mapping (self , avro_type : Union [str , Dict , List ], parent_namespace : str , wrapper_type : str = '' ) -> str :
104+ """Render a typed XML value mapping for a generated field."""
105+ if isinstance (avro_type , list ):
106+ non_null = [item for item in avro_type if item != "null" ]
107+ if len (non_null ) == 1 :
108+ return self .xml_type_mapping (non_null [0 ], parent_namespace )
109+ variants = ", " .join (self .xml_type_mapping (item , parent_namespace ) for item in non_null )
110+ if wrapper_type :
111+ return f"{{ kind: 'union', variants: { wrapper_type } .XmlVariants, unwrap: (value) => (value as any).value, wrap: (value) => new { wrapper_type } (value as any) }}"
112+ return f"{{ kind: 'union', variants: [{ variants } ] }}"
113+ if isinstance (avro_type , str ):
114+ primitive_kinds = {
115+ "boolean" : "boolean" , "int" : "number" , "long" : "number" ,
116+ "float" : "number" , "double" : "number" , "bytes" : "string" ,
117+ "string" : "string" , "null" : "any" ,
118+ }
119+ if avro_type in primitive_kinds :
120+ return f"{{ kind: '{ primitive_kinds [avro_type ]} ' }}"
121+ type_name = fullname (avro_type , parent_namespace )
122+ named_schema = self .type_dict .get (type_name ) if self .type_dict else None
123+ if isinstance (named_schema , dict ) and named_schema .get ("type" ) == "enum" :
124+ values = json .dumps (self .xml_enum_values (named_schema ))
125+ return f"{{ kind: 'enum', enumValues: { values } }}"
126+ class_name = pascal (avro_type .split ("." )[- 1 ])
127+ return f"{{ kind: 'record', record: () => { class_name } .XmlMapping }}"
128+ if isinstance (avro_type , dict ):
129+ logical_type = avro_type .get ("logicalType" )
130+ if logical_type in ["date" , "time-millis" , "time-micros" , "timestamp-millis" , "timestamp-micros" ]:
131+ return "{ kind: 'date' }"
132+ schema_type = avro_type .get ("type" )
133+ if schema_type == "record" :
134+ return f"{{ kind: 'record', record: () => { pascal (avro_type ['name' ])} .XmlMapping }}"
135+ if schema_type == "enum" :
136+ values = json .dumps (self .xml_enum_values (avro_type ))
137+ return f"{{ kind: 'enum', enumValues: { values } }}"
138+ if schema_type == "array" :
139+ return f"{{ kind: 'array', item: { self .xml_type_mapping (avro_type ['items' ], parent_namespace )} }}"
140+ if schema_type == "map" :
141+ return f"{{ kind: 'map', value: { self .xml_type_mapping (avro_type ['values' ], parent_namespace )} }}"
142+ return self .xml_type_mapping (schema_type , parent_namespace )
143+ return "{ kind: 'any' }"
144+
90145 def convert_avro_type_to_typescript (self , avro_type : Union [str , Dict , List ], parent_namespace : str , import_types : Set [str ], class_name : str = '' , field_name : str = '' ) -> str :
91146 """Convert Avro type to TypeScript type with namespace support."""
92147 if isinstance (avro_type , str ):
@@ -151,6 +206,7 @@ def generate_class(self, avro_schema: Dict, parent_namespace: str, write_file: b
151206
152207 fields = [{
153208 'definition' : self .generate_field (field , avro_schema .get ('namespace' , parent_namespace ), import_types , class_name ),
209+ 'schema' : field ,
154210 'docstring' : field .get ('doc' , '' )
155211 } for field in avro_schema .get ('fields' , [])]
156212
@@ -165,6 +221,9 @@ def generate_class(self, avro_schema: Dict, parent_namespace: str, write_file: b
165221 'is_union' : field ['definition' ]['is_union' ],
166222 'docstring' : field ['docstring' ],
167223 'test_value' : self .generate_test_value (field ['definition' ]['type' ], field ['definition' ]['is_enum' ]),
224+ 'xml_name' : self .xml_name (field ['schema' ]['name' ], field ['schema' ]),
225+ 'xml_kind' : 'attribute' if field ['schema' ].get ('xmlkind' ) == 'attribute' else 'element' ,
226+ 'xml_type_mapping' : self .xml_type_mapping (field ['schema' ]['type' ], avro_schema .get ('namespace' , parent_namespace ), field ['definition' ]['type' ] if field ['definition' ]['is_union' ] else '' ),
168227 } for field in fields ]
169228
170229 imports_with_paths : Dict [str , str ] = {}
@@ -199,6 +258,10 @@ def generate_class(self, avro_schema: Dict, parent_namespace: str, write_file: b
199258 base_package = self .base_package ,
200259 avro_annotation = self .avro_annotation ,
201260 typed_json_annotation = self .typed_json_annotation ,
261+ xml_annotation = self .xml_annotation ,
262+ xml_root_name = self .xml_name (avro_schema ['name' ], avro_schema ),
263+ xml_namespace = avro_schema .get ('xmlns' ),
264+ xml_runtime_import = ('../' * len (namespace .split ('.' )) if namespace else './' ) + 'xml.js' ,
202265 avro_schema_json = avro_schema_json ,
203266 get_is_json_match_clause = self .get_is_json_match_clause ,
204267 )
@@ -248,7 +311,7 @@ def generate_field(self, field: Dict, parent_namespace: str, import_types: Set[s
248311 'type' : field_type ,
249312 'is_primitive' : self .is_typescript_primitive (field_type .replace ('[]' , '' )),
250313 'is_array' : field_type .endswith ('[]' ),
251- 'is_union' : self . generated_types . get ( import_name , '' ) == 'union' ,
314+ 'is_union' : any ( kind == 'union' and name . endswith ( '.' + self . strip_nullable ( field_type )) for name , kind in self . generated_types . items ()) ,
252315 'is_enum' : self .generated_types .get (import_name , '' ) == 'enum' ,
253316 }
254317
@@ -266,7 +329,8 @@ def generate_test_value(self, field_type: str, is_enum: bool = False) -> str:
266329
267330 # Handle map/dict types
268331 if field_type .startswith ('{ [key: string]:' ):
269- return "{ 'key': 'value' }"
332+ value_type = field_type [len ('{ [key: string]:' ):].rsplit ('}' , 1 )[0 ].strip ()
333+ return f"{{ 'key': { self .generate_test_value (value_type )} }}"
270334
271335 # Handle union types (pipe-separated)
272336 if '|' in field_type :
@@ -323,7 +387,7 @@ def get_is_json_match_clause(self, field_name: str, field_type: str, field_is_en
323387 elif field_type .endswith ('[]' ):
324388 clause += f"Array.isArray(element['{ field_name_js } '])"
325389 else :
326- clause += f"{ field_type } .isJsonMatch(element['{ field_name_js } '])"
390+ clause += f"( { field_type } as any) .isJsonMatch(element['{ field_name_js } '])"
327391
328392 if is_optional :
329393 clause += f") || element['{ field_name_js } '] === null"
@@ -355,10 +419,13 @@ def generate_embedded_union(self, class_name: str, field_name: str, avro_type: L
355419
356420 if self .typed_json_annotation :
357421 class_definition += "import 'reflect-metadata';\n "
358- class_definition += "import { CustomDeserializerParams, CustomSerializerParams } from 'typedjson/lib/types/metadata.js';\n "
422+ class_definition += "import type { CustomDeserializerParams, CustomSerializerParams } from 'typedjson/lib/types/metadata.js';\n "
359423
360424
361425 class_definition += f"\n export class { union_class_name } {{\n "
426+ if self .xml_annotation :
427+ xml_variants = ', ' .join (self .xml_type_mapping (item , parent_namespace ) for item in avro_type if item != 'null' )
428+ class_definition += f"{ self .INDENT } public static readonly XmlVariants = [{ xml_variants } ] as const;\n \n "
362429
363430 class_definition += f"{ self .INDENT } private value: any;\n \n "
364431
@@ -397,8 +464,8 @@ def generate_embedded_union(self, class_name: str, field_name: str, avro_type: L
397464 class_definition += f"{ self .INDENT } public static fromData(element: any, contentTypeString: string): { union_class_name } {{\n "
398465 class_definition += f"{ self .INDENT * 2 } const unionTypes = [{ ', ' .join ([t .strip () for t in union_types if not self .is_typescript_primitive (t .strip ())])} ];\n "
399466 class_definition += f"{ self .INDENT * 2 } for (const type of unionTypes) {{\n "
400- class_definition += f"{ self .INDENT * 3 } if (type.isJsonMatch(element)) {{\n "
401- class_definition += f"{ self .INDENT * 4 } return new { union_class_name } (type.fromData(element, contentTypeString));\n "
467+ class_definition += f"{ self .INDENT * 3 } if (( type as any) .isJsonMatch(element)) {{\n "
468+ class_definition += f"{ self .INDENT * 4 } return new { union_class_name } (( type as any) .fromData(element, contentTypeString));\n "
402469 class_definition += f"{ self .INDENT * 3 } }}\n "
403470 class_definition += f"{ self .INDENT * 2 } }}\n "
404471 class_definition += f"{ self .INDENT * 2 } throw new Error('No matching type for union');\n "
@@ -423,6 +490,10 @@ def generate_embedded_union(self, class_name: str, field_name: str, avro_type: L
423490 class_definition += f"{ self .INDENT * 2 } }}\n "
424491 class_definition += f"{ self .INDENT } }}\n \n "
425492
493+ sample_value = self .generate_test_value (union_types [0 ])
494+ class_definition += f"{ self .INDENT } public static createInstance(): { union_class_name } {{\n "
495+ class_definition += f"{ self .INDENT * 2 } return new { union_class_name } ({ sample_value } );\n "
496+ class_definition += f"{ self .INDENT } }}\n "
426497 class_definition += "}\n "
427498
428499 if write_file :
@@ -502,6 +573,10 @@ def generate_project_files(self, output_dir: str):
502573 # Generate TypeScript type definitions for avro-js when using Avro annotations
503574 if self .avro_annotation :
504575 self .generate_avro_js_types (output_dir )
576+ if self .xml_annotation :
577+ xml_runtime = process_template ("avrotots/xml_runtime.ts.jinja" )
578+ with open (os .path .join (self .src_dir , 'xml.ts' ), 'w' , encoding = 'utf-8' ) as file :
579+ file .write (xml_runtime )
505580
506581 def generate_avro_js_types (self , output_dir : str ):
507582 """Generate TypeScript type declaration file for avro-js module."""
@@ -743,19 +818,19 @@ def convert(self, avro_schema_path: str, output_dir: str):
743818 self .generate_project_files (output_dir )
744819
745820
746- def convert_avro_to_typescript (avro_schema_path , js_dir_path , package_name = '' , typedjson_annotation = False , avro_annotation = False ):
821+ def convert_avro_to_typescript (avro_schema_path , js_dir_path , package_name = '' , typedjson_annotation = False , avro_annotation = False , xml_annotation = False ):
747822 """Convert Avro schema to TypeScript classes."""
748823 if not package_name :
749824 package_name = os .path .splitext (os .path .basename (avro_schema_path ))[0 ].lower ().replace ('-' , '_' )
750825
751826 converter = AvroToTypeScript (package_name , typed_json_annotation = typedjson_annotation ,
752- avro_annotation = avro_annotation )
827+ avro_annotation = avro_annotation , xml_annotation = xml_annotation )
753828 converter .convert (avro_schema_path , js_dir_path )
754829
755830
756- def convert_avro_schema_to_typescript (avro_schema , js_dir_path , package_name = '' , typedjson_annotation = False , avro_annotation = False ):
831+ def convert_avro_schema_to_typescript (avro_schema , js_dir_path , package_name = '' , typedjson_annotation = False , avro_annotation = False , xml_annotation = False ):
757832 """Convert Avro schema to TypeScript classes."""
758833 converter = AvroToTypeScript (package_name , typed_json_annotation = typedjson_annotation ,
759- avro_annotation = avro_annotation )
834+ avro_annotation = avro_annotation , xml_annotation = xml_annotation )
760835 converter .convert_schema (avro_schema , js_dir_path )
761836 converter .generate_project_files (js_dir_path )
0 commit comments