Skip to content

Commit 643d4a9

Browse files
authored
Merge pull request #419 from clemensv/issue-411-typescript-xml
TypeScript: add XML serialization support
2 parents 78a9405 + 2b04cb7 commit 643d4a9

10 files changed

Lines changed: 1153 additions & 37 deletions

avrotize/avrotots.py

Lines changed: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,11 @@ def is_typescript_reserved_word(word: str) -> bool:
2323
class 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"\nexport 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)

avrotize/avrotots/class_core.ts.jinja

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@ import avro, { type Type } from 'avro-js';
1313
{%- for import_type, import_path in imports.items() %}
1414
import { {{ import_type }} } from '{{ import_path }}';
1515
{%- endfor %}
16-
{%- if avro_annotation or typed_json_annotation %}
16+
{%- if avro_annotation or typed_json_annotation or xml_annotation %}
1717
import pako from 'pako';
1818
{%- endif %}
19+
{%- if xml_annotation %}
20+
import { deserializeXml, serializeXml, type XmlRecordMapping } from '{{ xml_runtime_import }}';
21+
{%- endif %}
1922

2023
{%- if typed_json_annotation %}
2124
@jsonObject
@@ -24,6 +27,21 @@ export class {{ class_name }} {
2427
{%- if avro_annotation %}
2528
public static AvroType: Type = avro.parse({{ avro_schema_json }});
2629
{%- endif %}
30+
{%- if xml_annotation %}
31+
public static readonly XmlMapping: XmlRecordMapping<{{ class_name }}> = {
32+
rootName: {{ xml_root_name | tojson }},
33+
{%- if xml_namespace %}
34+
namespace: {{ xml_namespace | tojson }},
35+
{%- endif %}
36+
assignMissing: true,
37+
fields: [
38+
{%- for field in fields %}
39+
{ property: {{ field.name | tojson }}, name: {{ field.xml_name | tojson }}, kind: '{{ field.xml_kind }}',{% if field.type.endswith('?') %} optional: true,{% endif %} type: {{ field.xml_type_mapping }} }{% if not loop.last %},{% endif %}
40+
{%- endfor %}
41+
],
42+
create: (values) => Object.assign(Object.create({{ class_name }}.prototype) as {{ class_name }}, values),
43+
};
44+
{%- endif %}
2745

2846
{%- for field in fields %}
2947
/** {{ field.docstring }} */
@@ -52,64 +70,78 @@ export class {{ class_name }} {
5270
{%- endfor %}
5371
}
5472

55-
{%- if avro_annotation or typed_json_annotation %}
73+
{%- if avro_annotation or typed_json_annotation or xml_annotation %}
5674
public toByteArray(contentTypeString: string): Uint8Array {
5775
const contentType = contentTypeString.split(';')[0].trim();
76+
const isGzip = contentType.endsWith('+gzip');
77+
const mediaType = isGzip ? contentType.slice(0, -5) : contentType;
5878
let result: Uint8Array | null = null;
5979

6080
{%- if avro_annotation %}
61-
if (contentType.startsWith('avro/binary') || contentType.startsWith('application/vnd.apache.avro+avro')) {
81+
if (mediaType === 'avro/binary' || mediaType === 'application/vnd.apache.avro+avro') {
6282
result = ({{ class_name }}.AvroType.toBuffer(this) as unknown) as Uint8Array;
6383
}
6484
{%- endif %}
6585

6686
{%- if typed_json_annotation %}
67-
if (contentType.startsWith('application/json')) {
87+
if (mediaType === 'application/json') {
6888
const serializer = new TypedJSON({{ class_name }});
6989
const jsonString = serializer.stringify(this);
7090
result = new TextEncoder().encode(jsonString);
7191
}
7292
{%- endif %}
7393

74-
if (result && contentTypeString.endsWith('+gzip')) {
75-
result = pako.gzip(result);
94+
{%- if xml_annotation %}
95+
if (mediaType === 'application/xml' || mediaType === 'text/xml') {
96+
result = new TextEncoder().encode(serializeXml(this, {{ class_name }}.XmlMapping));
7697
}
98+
{%- endif %}
7799

78-
if (result) {
79-
return result;
80-
} else {
81-
throw new Error(`Unsupported media type: ${contentTypeString}`);
100+
if (result && isGzip) {
101+
result = pako.gzip(result);
82102
}
103+
if (!result) throw new Error(`Unsupported media type: ${contentTypeString}`);
104+
return result;
83105
}
84106

85107
public static fromData(data: any, contentTypeString: string): {{ class_name }} {
86108
const contentType = contentTypeString.split(';')[0].trim();
87-
88-
if (contentTypeString.endsWith('+gzip')) {
89-
data = pako.ungzip(data);
109+
const isGzip = contentType.endsWith('+gzip');
110+
const mediaType = isGzip ? contentType.slice(0, -5) : contentType;
111+
if (isGzip) {
112+
if ((mediaType === 'application/xml' || mediaType === 'text/xml') && data.byteLength > 1024 * 1024) {
113+
throw new Error('Compressed XML payload exceeds the input limit');
114+
}
115+
try {
116+
data = pako.ungzip(data);
117+
} catch (error) {
118+
throw new Error(`Invalid gzip data: ${error instanceof Error ? error.message : String(error)}`);
119+
}
90120
}
91121

92122
{%- if avro_annotation %}
93-
if (contentType.startsWith('avro/binary') || contentType.startsWith('application/vnd.apache.avro+avro')) {
123+
if (mediaType === 'avro/binary' || mediaType === 'application/vnd.apache.avro+avro') {
94124
return {{ class_name }}.AvroType.fromBuffer(data);
95125
}
96126
{%- endif %}
97127

98128
{%- if typed_json_annotation %}
99-
if (contentType.startsWith('application/json')) {
129+
if (mediaType === 'application/json') {
100130
const serializer = new TypedJSON({{ class_name }});
101131
const retval = serializer.parse(new TextDecoder().decode(data));
102-
if (!(retval instanceof {{ class_name }})) {
103-
throw new Error(`Deserialized object is not an instance of {{ class_name }}`);
104-
}
132+
if (!(retval instanceof {{ class_name }})) throw new Error(`Deserialized object is not an instance of {{ class_name }}`);
105133
return retval;
106134
}
107135
{%- endif %}
108136

137+
{%- if xml_annotation %}
138+
if (mediaType === 'application/xml' || mediaType === 'text/xml') {
139+
return deserializeXml(new TextDecoder().decode(data), {{ class_name }}.XmlMapping);
140+
}
141+
{%- endif %}
109142
throw new Error(`Unsupported media type: ${contentTypeString}`);
110143
}
111144

112-
113145
{%- if typed_json_annotation %}
114146
public static isJsonMatch(element: any): boolean {
115147
{%- if fields|length == 0 %}

avrotize/avrotots/package.json.jinja

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
},
1111
"dependencies": {
1212
"avro-js": "^1.12.0",
13+
"fast-xml-parser": "^5.2.5",
1314
"typedjson": "^1.8.0",
1415
"pako": "^2.1.0",
1516
"reflect-metadata": "^0.2.2",

0 commit comments

Comments
 (0)