diff --git a/crates/polyxml-cli/src/main.rs b/crates/polyxml-cli/src/main.rs index caadfc92..d2b63e54 100644 --- a/crates/polyxml-cli/src/main.rs +++ b/crates/polyxml-cli/src/main.rs @@ -38,6 +38,9 @@ pub enum Commands { /// Validate XML schema syntax and structural invariants without generating code Validate(ValidateArgs), + + /// Bidirectionally transcode XML ↔ JSON with zero-copy streaming + Transcode(TranscodeArgs), } #[derive(Debug, Args)] @@ -118,6 +121,37 @@ pub struct ValidateArgs { pub schemas: Vec, } +#[derive(Debug, Args)] +pub struct TranscodeArgs { + /// Input file path (or '-' / omitted for stdin) + #[arg(value_name = "INPUT")] + pub input: Option, + + /// Output file path (or '-' / omitted for stdout) + #[arg(short = 'o', long = "out", value_name = "OUTPUT")] + pub output: Option, + + /// Input format ('xml' or 'json', auto-detected if omitted) + #[arg(long = "from", value_name = "FORMAT")] + pub from: Option, + + /// Output format ('xml' or 'json', auto-detected if omitted) + #[arg(long = "to", value_name = "FORMAT")] + pub to: Option, + + /// Optional XSD schema file for typed schema-directed transcoding + #[arg(short = 's', long = "schema", value_name = "SCHEMA")] + pub schema: Option, + + /// Root element name (used when transcoding JSON to XML) + #[arg(short = 'r', long = "root", value_name = "ROOT")] + pub root: Option, + + /// Format output with indentation and newlines + #[arg(long = "pretty")] + pub pretty: bool, +} + fn main() { let cli = Cli::parse(); @@ -125,6 +159,7 @@ fn main() { Commands::Generate(args) => run_generate(args), Commands::Build(args) => run_build(args), Commands::Validate(args) => run_validate(args), + Commands::Transcode(args) => run_transcode(args), }; if let Err(err) = result { @@ -392,6 +427,7 @@ fn emit_target_code( emit_meta: true, emit_root_aliases: true, emit_codecs: opts.codecs.unwrap_or(true), + emit_json_metadata: true, }; let codegen = PythonCodegen::new(options); @@ -522,6 +558,7 @@ fn emit_target_code( let options = GoOptions { package_name: pkg.to_string(), emit_xml_tags: true, + emit_json_tags: true, validate_choice_exclusivity: true, validate_facets: true, emit_root_aliases: true, @@ -546,6 +583,7 @@ fn emit_target_code( let options = CSharpOptions { namespace: ns.to_string(), emit_xml_attributes: true, + emit_json_attributes: true, emit_validation: true, record_kind: CSharpRecordKind::Class, use_file_scoped_namespaces: true, @@ -685,3 +723,107 @@ fn run_language_formatter(lang: &str, dir: &Path) { _ => {} } } + +fn run_transcode(args: TranscodeArgs) -> Result<(), Box> { + use std::io::{Read, Write}; + + // 1. Read input bytes + let input_bytes = if let Some(ref path) = args.input { + if path.as_os_str() == "-" { + let mut buf = Vec::new(); + std::io::stdin().read_to_end(&mut buf)?; + buf + } else { + fs::read(path)? + } + } else { + let mut buf = Vec::new(); + std::io::stdin().read_to_end(&mut buf)?; + buf + }; + + // 2. Determine from and to formats + let from_format = if let Some(ref f) = args.from { + f.to_lowercase() + } else if let Some(ref path) = args.input { + match path.extension().and_then(|e| e.to_str()).unwrap_or("") { + "xml" => "xml".to_string(), + "json" => "json".to_string(), + _ => { + if input_bytes.iter().find(|&&b| !b.is_ascii_whitespace()) == Some(&b'<') { + "xml".to_string() + } else { + "json".to_string() + } + } + } + } else if input_bytes.iter().find(|&&b| !b.is_ascii_whitespace()) == Some(&b'<') { + "xml".to_string() + } else { + "json".to_string() + }; + + let to_format = if let Some(ref t) = args.to { + t.to_lowercase() + } else if let Some(ref path) = args.output { + match path.extension().and_then(|e| e.to_str()).unwrap_or("") { + "xml" => "xml".to_string(), + "json" => "json".to_string(), + _ => { + if from_format == "xml" { + "json".to_string() + } else { + "xml".to_string() + } + } + } + } else if from_format == "xml" { + "json".to_string() + } else { + "xml".to_string() + }; + + // 3. Load optional ModelSchema + let model_schema = if let Some(ref schema_path) = args.schema { + let mut parser = XsdParser::new(); + let ir = parser.parse_file(schema_path)?; + Some(polyxml::ModelSchema::from_ir(&ir, args.root.as_deref())?) + } else { + None + }; + + let indent = if args.pretty { Some(2) } else { None }; + + // 4. Perform transcoding + let output_bytes = match (from_format.as_str(), to_format.as_str()) { + ("xml", "json") => polyxml::xml_to_json(&input_bytes, model_schema, indent, true)?, + ("json", "xml") => polyxml::json_to_xml( + &input_bytes, + model_schema, + args.root.as_deref(), + indent, + None, + None, + )?, + _ => { + return Err(format!( + "Unsupported transcoding direction from '{}' to '{}'", + from_format, to_format + ) + .into()) + } + }; + + // 5. Write output bytes + if let Some(ref path) = args.output { + if path.as_os_str() == "-" { + std::io::stdout().write_all(&output_bytes)?; + } else { + fs::write(path, output_bytes)?; + } + } else { + std::io::stdout().write_all(&output_bytes)?; + } + + Ok(()) +} diff --git a/crates/polyxml-cli/tests/test_cli.rs b/crates/polyxml-cli/tests/test_cli.rs index 87119ff1..29e15ec8 100644 --- a/crates/polyxml-cli/tests/test_cli.rs +++ b/crates/polyxml-cli/tests/test_cli.rs @@ -272,9 +272,9 @@ fn test_cli_generate_python_backends() { assert!( pyd_py.contains("type InvoiceCode = Annotated[str, Field(min_length=5, max_length=10)]") ); - assert!(pyd_py.contains("code: InvoiceCode = Field(..., json_schema_extra={\"type\": \"Element\", \"name\": \"Code\", \"namespace\": \"https://example.com/invoice\"})")); - assert!(pyd_py.contains("total: Decimal = Field(..., json_schema_extra={\"type\": \"Element\", \"name\": \"Total\", \"namespace\": \"https://example.com/invoice\"})")); - assert!(pyd_py.contains("note: str | None = Field(default=None, json_schema_extra={\"type\": \"Element\", \"name\": \"Note\", \"namespace\": \"https://example.com/invoice\", \"nillable\": True})")); + assert!(pyd_py.contains("code: InvoiceCode = Field(..., alias=\"Code\", serialization_alias=\"Code\", json_schema_extra={\"type\": \"Element\", \"name\": \"Code\", \"json_name\": \"Code\", \"namespace\": \"https://example.com/invoice\"})")); + assert!(pyd_py.contains("total: Decimal = Field(..., alias=\"Total\", serialization_alias=\"Total\", json_schema_extra={\"type\": \"Element\", \"name\": \"Total\", \"json_name\": \"Total\", \"namespace\": \"https://example.com/invoice\"})")); + assert!(pyd_py.contains("note: str | None = Field(default=None, alias=\"Note\", serialization_alias=\"Note\", json_schema_extra={\"type\": \"Element\", \"name\": \"Note\", \"json_name\": \"Note\", \"namespace\": \"https://example.com/invoice\", \"nillable\": True})")); } #[test] @@ -756,6 +756,7 @@ fn test_cli_go_generation() { let generated_file = go_out.join("crm.go"); assert!(generated_file.exists(), "crm.go was not created"); let go_code = fs::read_to_string(&generated_file).unwrap(); + println!("GO_CODE:\n{}", go_code); assert!(go_code.contains("package crm")); assert!(go_code.contains("type AccountTier string")); @@ -763,32 +764,41 @@ fn test_cli_go_generation() { assert!(go_code.contains("\"standard\"")); assert!(go_code.contains("func (e AccountTier) IsValid() bool")); assert!(go_code.contains("type Account struct {")); - assert!(go_code.contains("XMLName xml.Name")); - assert!(go_code.contains("Name") && go_code.contains("`xml:\"name\"`")); + assert!(go_code.contains("XMLName xml.Name") && go_code.contains("`json:\"-\"`")); + assert!( + go_code.contains("Name") + && go_code.contains("xml:\"name\"") + && go_code.contains("json:\"name\"") + ); assert!( go_code.contains("Tier") && go_code.contains("AccountTier") - && go_code.contains("`xml:\"tier\"`") + && go_code.contains("xml:\"tier\"") + && go_code.contains("json:\"tier\"") ); assert!( go_code.contains("Balance") && go_code.contains("float64") - && go_code.contains("`xml:\"balance\"`") + && go_code.contains("xml:\"balance\"") + && go_code.contains("json:\"balance\"") ); assert!( go_code.contains("Alias") && go_code.contains("*string") - && go_code.contains("`xml:\"alias,omitempty\"`") + && go_code.contains("xml:\"alias,omitempty\"") + && go_code.contains("json:\"alias,omitempty\"") ); assert!( go_code.contains("Tag") && go_code.contains("[]string") - && go_code.contains("`xml:\"tag\"`") + && go_code.contains("xml:\"tag\"") + && go_code.contains("json:\"tag\"") ); assert!( go_code.contains("ID") && go_code.contains("int32") - && go_code.contains("`xml:\"id,attr\"`") + && go_code.contains("xml:\"id,attr\"") + && go_code.contains("json:\"id\"") ); assert!(go_code.contains("func (s Account) Validate() error")); @@ -934,23 +944,34 @@ fn test_cli_csharp_generation() { assert!(cs_code.contains("[XmlEnum(\"standard\")]")); assert!(cs_code.contains("public static bool IsValid(this AccountTier value)")); assert!(cs_code.contains("public record Account(")); - assert!(cs_code.contains("[property: XmlAttribute(\"id\")]") && cs_code.contains("int Id")); assert!( - cs_code.contains("[property: XmlElement(\"name\")]") && cs_code.contains("string Name") + cs_code.contains("XmlAttribute(\"id\")") + && cs_code.contains("JsonPropertyName(\"id\")") + && cs_code.contains("int Id") + ); + assert!( + cs_code.contains("XmlElement(\"name\")") + && cs_code.contains("JsonPropertyName(\"name\")") + && cs_code.contains("string Name") ); assert!( - cs_code.contains("[property: XmlElement(\"tier\")]") + cs_code.contains("XmlElement(\"tier\")") + && cs_code.contains("JsonPropertyName(\"tier\")") && cs_code.contains("AccountTier Tier") ); assert!( - cs_code.contains("[property: XmlElement(\"balance\")]") + cs_code.contains("XmlElement(\"balance\")") + && cs_code.contains("JsonPropertyName(\"balance\")") && cs_code.contains("decimal Balance") ); assert!( - cs_code.contains("[property: XmlElement(\"alias\")]") && cs_code.contains("string? Alias") + cs_code.contains("XmlElement(\"alias\")") + && cs_code.contains("JsonPropertyName(\"alias\")") + && cs_code.contains("string? Alias") ); assert!( - cs_code.contains("[property: XmlElement(\"tag\")]") + cs_code.contains("XmlElement(\"tag\")") + && cs_code.contains("JsonPropertyName(\"tag\")") && cs_code.contains("List? Tag") ); assert!(cs_code.contains("public Account() : this(")); @@ -1050,3 +1071,61 @@ public class Program "dotnet run failed on CLI generated C# files" ); } + +#[test] +fn test_cli_transcode_bidirectional() { + let dir = tempdir().unwrap(); + let xml_file = dir.path().join("input.xml"); + let json_file = dir.path().join("output.json"); + let roundtrip_xml_file = dir.path().join("roundtrip.xml"); + + fs::write( + &xml_file, + r#"API Gateway80808443"#, + ) + .unwrap(); + + let exe = env!("CARGO_BIN_EXE_polyxml"); + + // 1. XML to JSON with --pretty + let status = Command::new(exe) + .args([ + "transcode", + xml_file.to_str().unwrap(), + "-o", + json_file.to_str().unwrap(), + "--pretty", + ]) + .status() + .expect("failed to execute polyxml transcode"); + assert!(status.success()); + + let json_str = fs::read_to_string(&json_file).unwrap(); + assert!(json_str.contains("\"@id\": 99")); + assert!(json_str.contains("\"@enabled\": true")); + assert!(json_str.contains("\"name\": \"API Gateway\"")); + assert!(json_str.contains("\"port\": [\n 8080,\n 8443\n ]")); + + // 2. JSON to XML + let status2 = Command::new(exe) + .args([ + "transcode", + json_file.to_str().unwrap(), + "-o", + roundtrip_xml_file.to_str().unwrap(), + "--pretty", + ]) + .status() + .expect("failed to execute polyxml transcode"); + assert!(status2.success()); + + let roundtrip_str = fs::read_to_string(&roundtrip_xml_file).unwrap(); + assert!( + roundtrip_str.contains("API Gateway")); + assert!(roundtrip_str.contains("8080")); + assert!(roundtrip_str.contains("8443")); +} diff --git a/crates/polyxml-core/src/codegen/csharp/mod.rs b/crates/polyxml-core/src/codegen/csharp/mod.rs index 4bc6652f..85338417 100644 --- a/crates/polyxml-core/src/codegen/csharp/mod.rs +++ b/crates/polyxml-core/src/codegen/csharp/mod.rs @@ -31,6 +31,8 @@ pub struct CSharpOptions { pub namespace: String, /// Emit System.Xml.Serialization attributes ([XmlElement], [XmlAttribute], etc.) pub emit_xml_attributes: bool, + /// Emit System.Text.Json.Serialization attributes ([JsonPropertyName], etc.) + pub emit_json_attributes: bool, /// Emit IValidatableObject and restriction facet validation logic pub emit_validation: bool, /// Record emission kind (record class vs record struct) @@ -46,6 +48,7 @@ impl Default for CSharpOptions { Self { namespace: "Generated".to_string(), emit_xml_attributes: true, + emit_json_attributes: true, emit_validation: true, record_kind: CSharpRecordKind::Class, use_file_scoped_namespaces: true, @@ -225,6 +228,9 @@ impl CSharpCodegen { if self.options.emit_xml_attributes { writeln!(out, "using System.Xml.Serialization;").unwrap(); } + if self.options.emit_json_attributes { + writeln!(out, "using System.Text.Json.Serialization;").unwrap(); + } writeln!(out).unwrap(); let ns = to_csharp_namespace(&self.options.namespace); @@ -288,6 +294,14 @@ impl CSharpCodegen { self.emit_docstring(out, doc, indent); } + if self.options.emit_json_attributes { + writeln!( + out, + "{}[JsonConverter(typeof(JsonStringEnumConverter))]", + indent + ) + .unwrap(); + } writeln!(out, "{}public enum {}", indent, enum_name).unwrap(); writeln!(out, "{}{{", indent).unwrap(); @@ -425,10 +439,17 @@ impl CSharpCodegen { self.emit_docstring(out, doc, &format!("{} ", indent)); } - let xml_attr = if self.options.emit_xml_attributes { - format!("[property: XmlElement(\"{}\")] ", branch.xml_name) - } else { + let mut branch_attrs = Vec::new(); + if self.options.emit_xml_attributes { + branch_attrs.push(format!("XmlElement(\"{}\")", branch.xml_name)); + } + if self.options.emit_json_attributes { + branch_attrs.push(format!("JsonPropertyName(\"{}\")", branch.xml_name)); + } + let xml_attr = if branch_attrs.is_empty() { String::new() + } else { + format!("[property: {}] ", branch_attrs.join(", ")) }; writeln!( @@ -529,11 +550,7 @@ impl CSharpCodegen { let default_val = if can_have_default { " = null" } else { "" }; - let xml_attr = if self.options.emit_xml_attributes { - self.build_field_xml_attribute(f, ir) - } else { - String::new() - }; + let field_attrs = self.build_field_attributes(f, ir); if let Some(ref doc) = f.documentation { self.emit_docstring(out, doc, &format!("{} ", indent)); @@ -542,7 +559,7 @@ impl CSharpCodegen { writeln!( out, "{} {}{} {}{}{}", - indent, xml_attr, field_type, prop_name, default_val, comma + indent, field_attrs, field_type, prop_name, default_val, comma ) .unwrap(); } @@ -709,31 +726,48 @@ impl CSharpCodegen { } } - fn build_field_xml_attribute(&self, f: &FieldDef, ir: &SchemaIR) -> String { - // If the field is a choice (UnionDef), emit [property: XmlElement("branchXml", typeof(BranchType))] - if let TypeRef::Named(ref qname) = f.type_ref { - if let Some(TypeDef::Union(u)) = ir.types.get(qname) { - let choice_name = to_csharp_type_name(&u.qname.local); - let mut attrs = Vec::new(); - for branch in &u.branches { - let variant_name = to_csharp_type_name(&branch.variant_name); - attrs.push(format!( - "[property: XmlElement(\"{}\", typeof({}.{}))]", - branch.xml_name, choice_name, variant_name - )); + fn build_field_attributes(&self, f: &FieldDef, ir: &SchemaIR) -> String { + let mut parts = Vec::new(); + + if self.options.emit_xml_attributes { + // If the field is a choice (UnionDef), emit XmlElement("branchXml", typeof(BranchType)) + if let TypeRef::Named(ref qname) = f.type_ref { + if let Some(TypeDef::Union(u)) = ir.types.get(qname) { + let choice_name = to_csharp_type_name(&u.qname.local); + for branch in &u.branches { + let variant_name = to_csharp_type_name(&branch.variant_name); + parts.push(format!( + "XmlElement(\"{}\", typeof({}.{}))", + branch.xml_name, choice_name, variant_name + )); + } } - if !attrs.is_empty() { - return format!("{} ", attrs.join(" ")); + } + if parts.is_empty() { + match f.kind { + FieldKind::Attribute => parts.push(format!("XmlAttribute(\"{}\")", f.xml_name)), + FieldKind::Text => parts.push("XmlText".to_string()), + FieldKind::Any => parts.push("XmlAnyElement".to_string()), + FieldKind::AnyAttribute => parts.push("XmlAnyAttribute".to_string()), + FieldKind::Element => parts.push(format!("XmlElement(\"{}\")", f.xml_name)), } } } - match f.kind { - FieldKind::Attribute => format!("[property: XmlAttribute(\"{}\")] ", f.xml_name), - FieldKind::Text => "[property: XmlText] ".to_string(), - FieldKind::Any => "[property: XmlAnyElement] ".to_string(), - FieldKind::AnyAttribute => "[property: XmlAnyAttribute] ".to_string(), - FieldKind::Element => format!("[property: XmlElement(\"{}\")] ", f.xml_name), + if self.options.emit_json_attributes { + let json_name = match f.kind { + FieldKind::Text => "value", + _ => &f.xml_name, + }; + if f.kind != FieldKind::Any && f.kind != FieldKind::AnyAttribute { + parts.push(format!("JsonPropertyName(\"{}\")", json_name)); + } + } + + if parts.is_empty() { + String::new() + } else { + format!("[property: {}] ", parts.join(", ")) } } diff --git a/crates/polyxml-core/src/codegen/go/mod.rs b/crates/polyxml-core/src/codegen/go/mod.rs index 3578bcee..e69dc2e6 100644 --- a/crates/polyxml-core/src/codegen/go/mod.rs +++ b/crates/polyxml-core/src/codegen/go/mod.rs @@ -16,6 +16,8 @@ pub struct GoOptions { pub package_name: String, /// Emit encoding/xml tags (default: true) pub emit_xml_tags: bool, + /// Emit encoding/json tags (default: true) + pub emit_json_tags: bool, /// Emit custom UnmarshalXML/MarshalXML for xs:choice mutual exclusivity validation (default: true) pub validate_choice_exclusivity: bool, /// Emit Validate() error method for restriction facets (default: true) @@ -29,6 +31,7 @@ impl Default for GoOptions { Self { package_name: "models".to_string(), emit_xml_tags: true, + emit_json_tags: true, validate_choice_exclusivity: true, validate_facets: true, emit_root_aliases: true, @@ -540,7 +543,11 @@ impl GoCodegen { // Emit XMLName if xml tags enabled if self.options.emit_xml_tags { - writeln!(out, " XMLName xml.Name").unwrap(); + if self.options.emit_json_tags { + writeln!(out, " XMLName xml.Name `json:\"-\"`").unwrap(); + } else { + writeln!(out, " XMLName xml.Name").unwrap(); + } } // Struct composition / inheritance if base struct exists @@ -562,10 +569,17 @@ impl GoCodegen { .iter() .any(|f| f.name == "value" || f.kind == FieldKind::Text); if !has_value_field { - let tag = if self.options.emit_xml_tags { - " `xml:\",chardata\"`" + let mut parts = Vec::new(); + if self.options.emit_xml_tags { + parts.push("xml:\",chardata\"".to_string()); + } + if self.options.emit_json_tags { + parts.push("json:\"value,omitempty\"".to_string()); + } + let tag = if parts.is_empty() { + String::new() } else { - "" + format!(" `{}`", parts.join(" ")) }; writeln!(out, " Value {}{}", base_type_str, tag).unwrap(); } @@ -579,7 +593,7 @@ impl GoCodegen { let field_name = to_go_field_name(&f.name); let field_type = self.resolve_field_type(f); - let tag = self.build_field_xml_tag(f); + let tag = self.build_field_struct_tags(f); writeln!(out, " {} {}{}", field_name, field_type, tag).unwrap(); } @@ -606,30 +620,52 @@ impl GoCodegen { } } - fn build_field_xml_tag(&self, f: &FieldDef) -> String { - if !self.options.emit_xml_tags { - return String::new(); - } + fn build_field_struct_tags(&self, f: &FieldDef) -> String { + let mut parts = Vec::new(); - let is_opt = f.cardinality.is_optional() || f.nillable; - match f.kind { - FieldKind::Attribute => { - if is_opt { - format!(" `xml:\"{},attr,omitempty\"`", f.xml_name) - } else { - format!(" `xml:\"{},attr\"`", f.xml_name) + if self.options.emit_xml_tags { + let is_opt = f.cardinality.is_optional() || f.nillable; + let xml_val = match f.kind { + FieldKind::Attribute => { + if is_opt { + format!("{},attr,omitempty", f.xml_name) + } else { + format!("{},attr", f.xml_name) + } } - } - FieldKind::Text => " `xml:\",chardata\"`".to_string(), - FieldKind::Any => " `xml:\",any\"`".to_string(), - FieldKind::AnyAttribute => " `xml:\",any,attr\"`".to_string(), - FieldKind::Element => { - if is_opt { - format!(" `xml:\"{},omitempty\"`", f.xml_name) - } else { - format!(" `xml:\"{}\"`", f.xml_name) + FieldKind::Text => ",chardata".to_string(), + FieldKind::Any => ",any".to_string(), + FieldKind::AnyAttribute => ",any,attr".to_string(), + FieldKind::Element => { + if is_opt { + format!("{},omitempty", f.xml_name) + } else { + f.xml_name.clone() + } } - } + }; + parts.push(format!("xml:\"{}\"", xml_val)); + } + + if self.options.emit_json_tags { + let is_opt = f.cardinality.is_optional() || f.nillable; + let json_name = if f.kind == FieldKind::Text { + "value".to_string() + } else { + f.xml_name.clone() + }; + let json_val = if is_opt { + format!("{},omitempty", json_name) + } else { + json_name + }; + parts.push(format!("json:\"{}\"", json_val)); + } + + if parts.is_empty() { + String::new() + } else { + format!(" `{}`", parts.join(" ")) } } diff --git a/crates/polyxml-core/src/codegen/python/mod.rs b/crates/polyxml-core/src/codegen/python/mod.rs index b2873023..6baaaf0b 100644 --- a/crates/polyxml-core/src/codegen/python/mod.rs +++ b/crates/polyxml-core/src/codegen/python/mod.rs @@ -38,6 +38,7 @@ pub struct PythonOptions { pub emit_meta: bool, pub emit_root_aliases: bool, pub emit_codecs: bool, + pub emit_json_metadata: bool, } impl Default for PythonOptions { @@ -50,6 +51,7 @@ impl Default for PythonOptions { emit_meta: true, emit_root_aliases: true, emit_codecs: true, + emit_json_metadata: true, } } } @@ -579,7 +581,7 @@ impl PythonCodegen { self.format_dataclass_field(field, is_list, &inner_type, &meta_dict) } PythonBackend::Pydantic => { - self.format_pydantic_field(field, is_list, &inner_type, &meta_dict) + self.format_pydantic_field(field, py_name, is_list, &inner_type, &meta_dict) } }; @@ -632,6 +634,7 @@ impl PythonCodegen { fn format_pydantic_field( &self, field: &FieldDef, + py_name: &str, is_list: bool, base_type: &str, meta_dict: &str, @@ -642,11 +645,16 @@ impl PythonCodegen { .map(|f| self.format_pydantic_facets(f)) .unwrap_or_default(); - let extra_clause = if facet_args.is_empty() { - format!("json_schema_extra={}", meta_dict) - } else { - format!("json_schema_extra={}, {}", meta_dict, facet_args) - }; + let mut clauses = Vec::new(); + if self.options.emit_json_metadata && py_name != field.xml_name { + clauses.push(format!("alias=\"{}\"", field.xml_name)); + clauses.push(format!("serialization_alias=\"{}\"", field.xml_name)); + } + clauses.push(format!("json_schema_extra={}", meta_dict)); + if !facet_args.is_empty() { + clauses.push(facet_args); + } + let extra_clause = clauses.join(", "); if is_list { ( @@ -691,6 +699,9 @@ impl PythonCodegen { let mut parts = Vec::new(); parts.push(format!("\"type\": \"{}\"", kind_str)); parts.push(format!("\"name\": \"{}\"", field.xml_name)); + if self.options.emit_json_metadata { + parts.push(format!("\"json_name\": \"{}\"", field.xml_name)); + } if let Some(ref ns) = field.namespace { parts.push(format!("\"namespace\": \"{}\"", ns)); diff --git a/crates/polyxml-core/src/codegen/rust/mod.rs b/crates/polyxml-core/src/codegen/rust/mod.rs index e4d8cb09..0bd77163 100644 --- a/crates/polyxml-core/src/codegen/rust/mod.rs +++ b/crates/polyxml-core/src/codegen/rust/mod.rs @@ -1240,6 +1240,36 @@ impl RustCodegen { out.push_str(" String::from_utf8(bytes).map_err(|e| PolyXmlError::Utf8Error(e.utf8_error()))\n"); out.push_str(" }\n\n"); + if self.options.derive_serde { + let from_json_sig = if needs_lifetime { + "pub fn from_json_str(json_str: &'a str) -> std::result::Result" + } else { + "pub fn from_json_str(json_str: &str) -> std::result::Result" + }; + let _ = writeln!(out, " {}", from_json_sig); + out.push_str(" {\n"); + out.push_str(" serde_json::from_str(json_str)\n"); + out.push_str(" }\n\n"); + + let from_json_slice_sig = if needs_lifetime { + "pub fn from_json_slice(bytes: &'a [u8]) -> std::result::Result" + } else { + "pub fn from_json_slice(bytes: &[u8]) -> std::result::Result" + }; + let _ = writeln!(out, " {}", from_json_slice_sig); + out.push_str(" {\n"); + out.push_str(" serde_json::from_slice(bytes)\n"); + out.push_str(" }\n\n"); + + out.push_str(" pub fn to_json_string(&self) -> std::result::Result {\n"); + out.push_str(" serde_json::to_string(self)\n"); + out.push_str(" }\n\n"); + + out.push_str(" pub fn to_json_vec(&self) -> std::result::Result, serde_json::Error> {\n"); + out.push_str(" serde_json::to_vec(self)\n"); + out.push_str(" }\n\n"); + } + out.push_str(" pub fn encode_xml(&self, writer: &mut Writer, tag_name: Option<&str>) -> Result<()> {\n"); let _ = writeln!( out, @@ -1860,13 +1890,40 @@ impl RustCodegen { out.push_str(" }\n\n"); out.push_str(" pub fn to_xml_string(&self) -> Result {\n"); - out.push_str( - " let bytes = self.to_xml()?; -", - ); + out.push_str(" let bytes = self.to_xml()?;\n"); out.push_str(" String::from_utf8(bytes).map_err(|e| PolyXmlError::Utf8Error(e.utf8_error()))\n"); out.push_str(" }\n\n"); + if self.options.derive_serde { + let from_json_sig = if needs_lifetime { + "pub fn from_json_str(json_str: &'a str) -> std::result::Result" + } else { + "pub fn from_json_str(json_str: &str) -> std::result::Result" + }; + let _ = writeln!(out, " {}", from_json_sig); + out.push_str(" {\n"); + out.push_str(" serde_json::from_str(json_str)\n"); + out.push_str(" }\n\n"); + + let from_json_slice_sig = if needs_lifetime { + "pub fn from_json_slice(bytes: &'a [u8]) -> std::result::Result" + } else { + "pub fn from_json_slice(bytes: &[u8]) -> std::result::Result" + }; + let _ = writeln!(out, " {}", from_json_slice_sig); + out.push_str(" {\n"); + out.push_str(" serde_json::from_slice(bytes)\n"); + out.push_str(" }\n\n"); + + out.push_str(" pub fn to_json_string(&self) -> std::result::Result {\n"); + out.push_str(" serde_json::to_string(self)\n"); + out.push_str(" }\n\n"); + + out.push_str(" pub fn to_json_vec(&self) -> std::result::Result, serde_json::Error> {\n"); + out.push_str(" serde_json::to_vec(self)\n"); + out.push_str(" }\n\n"); + } + out.push_str(" pub fn encode_xml(&self, writer: &mut Writer, tag_name: Option<&str>) -> Result<()> {\n"); out.push_str(" match self {\n"); for branch in &u.branches { diff --git a/crates/polyxml-core/src/lib.rs b/crates/polyxml-core/src/lib.rs index ee4d1844..4a73abe9 100644 --- a/crates/polyxml-core/src/lib.rs +++ b/crates/polyxml-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod parser; pub mod schema; pub mod schema_parser; pub mod serializer; +pub mod transcoder; pub mod value; pub use error::{PolyXmlError, Result}; @@ -14,6 +15,7 @@ pub use json::{deserialize_json, serialize_json}; pub use parser::{XmlDeserializer, XmlItemStream}; pub use schema::{FieldKind, FieldSchema, ModelSchema, ScalarType, ValueType}; pub use serializer::XmlSerializer; +pub use transcoder::{json_to_xml, xml_to_json}; pub use value::PolyValue; use std::sync::Arc; diff --git a/crates/polyxml-core/src/schema.rs b/crates/polyxml-core/src/schema.rs index 6d647c67..cebf0fa5 100644 --- a/crates/polyxml-core/src/schema.rs +++ b/crates/polyxml-core/src/schema.rs @@ -86,6 +86,197 @@ impl ModelSchema { pub fn builder(name: impl Into) -> ModelSchemaBuilder { ModelSchemaBuilder::new(name) } + + /// Construct a runtime `ModelSchema` from a compiled `SchemaIR`. + pub fn from_ir( + ir: &crate::ir::SchemaIR, + root_name: Option<&str>, + ) -> crate::error::Result> { + use crate::ir::{PrimitiveType, TypeDef, TypeRef}; + use std::collections::HashSet; + + let (name, qname_opt, type_ref) = if let Some(target) = root_name { + if let Some((qname, elem)) = ir.elements.iter().find(|(q, _)| q.local == target) { + ( + elem.qname.local.clone(), + Some(qname.clone()), + elem.type_ref.clone(), + ) + } else if let Some((qname, _)) = ir.types.iter().find(|(q, _)| q.local == target) { + ( + qname.local.clone(), + Some(qname.clone()), + TypeRef::Named(qname.clone()), + ) + } else { + return Err(crate::error::PolyXmlError::SchemaError(format!( + "Root element or type '{}' not found in schema", + target + ))); + } + } else if let Some((_, elem)) = ir.elements.iter().next() { + ( + elem.qname.local.clone(), + Some(elem.qname.clone()), + elem.type_ref.clone(), + ) + } else if let Some((qname, _)) = ir.types.iter().next() { + ( + qname.local.clone(), + Some(qname.clone()), + TypeRef::Named(qname.clone()), + ) + } else { + return Err(crate::error::PolyXmlError::SchemaError( + "SchemaIR contains no elements or types".into(), + )); + }; + + fn map_primitive(prim: PrimitiveType) -> ScalarType { + match prim { + PrimitiveType::String + | PrimitiveType::NormalizedString + | PrimitiveType::Token + | PrimitiveType::Language + | PrimitiveType::Name + | PrimitiveType::NCName + | PrimitiveType::Id + | PrimitiveType::IdRef + | PrimitiveType::IdRefs + | PrimitiveType::Entity + | PrimitiveType::Entities + | PrimitiveType::NMTOKEN + | PrimitiveType::NMTOKENS + | PrimitiveType::AnyUri + | PrimitiveType::QName => ScalarType::String, + PrimitiveType::Boolean => ScalarType::Bool, + PrimitiveType::Decimal => ScalarType::Decimal, + PrimitiveType::Float | PrimitiveType::Double => ScalarType::Float, + PrimitiveType::Duration => ScalarType::XmlDuration, + PrimitiveType::DateTime => ScalarType::XmlDateTime, + PrimitiveType::Time => ScalarType::XmlTime, + PrimitiveType::Date => ScalarType::XmlDate, + PrimitiveType::Int + | PrimitiveType::Integer + | PrimitiveType::NonPositiveInteger + | PrimitiveType::NegativeInteger + | PrimitiveType::Long + | PrimitiveType::Short + | PrimitiveType::Byte + | PrimitiveType::NonNegativeInteger + | PrimitiveType::UnsignedLong + | PrimitiveType::UnsignedInt + | PrimitiveType::UnsignedShort + | PrimitiveType::UnsignedByte + | PrimitiveType::PositiveInteger => ScalarType::Int, + PrimitiveType::Base64Binary | PrimitiveType::HexBinary => ScalarType::String, + _ => ScalarType::String, + } + } + + fn build_type( + tr: &TypeRef, + ir: &crate::ir::SchemaIR, + visited: &mut HashSet, + ) -> ValueType { + match tr { + TypeRef::Primitive(prim) => ValueType::Scalar(map_primitive(*prim)), + TypeRef::List(inner) => ValueType::List(Box::new(build_type(inner, ir, visited))), + TypeRef::Boxed(inner) => build_type(inner, ir, visited), + TypeRef::Named(qname) => { + if let Some(type_def) = ir.types.get(qname) { + match type_def { + TypeDef::Struct(s) => { + if visited.contains(&s.qname) { + ValueType::Nested(ModelSchema::builder(&s.qname.local).build()) + } else { + visited.insert(s.qname.clone()); + let child_schema = build_struct(s, ir, visited); + visited.remove(&s.qname); + ValueType::Nested(child_schema) + } + } + TypeDef::Simple(sim) => build_type(&sim.base_type, ir, visited), + TypeDef::Enum(_) => ValueType::Scalar(ScalarType::String), + TypeDef::Union(_) => ValueType::Scalar(ScalarType::String), + } + } else { + ValueType::Scalar(ScalarType::String) + } + } + } + } + + fn build_struct( + s: &crate::ir::StructDef, + ir: &crate::ir::SchemaIR, + visited: &mut HashSet, + ) -> Arc { + let mut builder = ModelSchema::builder(&s.qname.local); + if let Some(ref ns) = s.qname.namespace { + builder = builder.namespace(ns); + } + + for f in &s.fields { + let kind = match f.kind { + crate::ir::FieldKind::Attribute => FieldKind::Attribute, + crate::ir::FieldKind::Element => FieldKind::Element, + crate::ir::FieldKind::Text => FieldKind::Text, + _ => FieldKind::Element, + }; + + let mut val_type = build_type(&f.type_ref, ir, visited); + if f.cardinality.is_list() && !matches!(val_type, ValueType::List(_)) { + val_type = ValueType::List(Box::new(val_type)); + } + + let mut field_schema = + FieldSchema::new(&f.name, f.xml_name.as_bytes(), kind, val_type); + if let Some(ref ns) = f.namespace { + field_schema = field_schema.namespace(ns); + } + if f.cardinality.min_occurs > 0 && !f.cardinality.is_optional() { + field_schema = field_schema.required(); + } + + builder = builder.field(field_schema); + } + + builder.build() + } + + let mut visited = HashSet::new(); + if let Some(qn) = qname_opt.as_ref() { + visited.insert(qn.clone()); + } + + match type_ref { + TypeRef::Named(ref qname) if ir.types.contains_key(qname) => { + if let Some(TypeDef::Struct(s)) = ir.types.get(qname) { + Ok(build_struct(s, ir, &mut visited)) + } else { + let mut b = ModelSchema::builder(name); + b = b.field(FieldSchema::new( + "value", + b"value", + FieldKind::Text, + build_type(&type_ref, ir, &mut visited), + )); + Ok(b.build()) + } + } + _ => { + let mut b = ModelSchema::builder(name); + b = b.field(FieldSchema::new( + "value", + b"value", + FieldKind::Text, + build_type(&type_ref, ir, &mut visited), + )); + Ok(b.build()) + } + } + } } pub struct ModelSchemaBuilder { diff --git a/crates/polyxml-core/src/transcoder.rs b/crates/polyxml-core/src/transcoder.rs new file mode 100644 index 00000000..9e60e069 --- /dev/null +++ b/crates/polyxml-core/src/transcoder.rs @@ -0,0 +1,377 @@ +use std::collections::HashMap; +use std::io::Cursor; +use std::sync::Arc; + +use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event}; +use quick_xml::{Reader, Writer}; +use serde_json::{Map, Value}; + +use crate::error::{PolyXmlError, Result}; +use crate::json::{deserialize_json, serialize_json}; +use crate::parser::XmlDeserializer; +use crate::schema::ModelSchema; +use crate::serializer::XmlSerializer; + +/// Transcode XML bytes into JSON bytes. +/// +/// If a `ModelSchema` is provided, typed data-binding is used, respecting numeric, boolean, +/// and collection types as well as field aliases. +/// If `schema` is None, zero-copy dynamic streaming transcoding is used. +pub fn xml_to_json( + xml: &[u8], + schema: Option>, + indent: Option, + by_alias: bool, +) -> Result> { + if let Some(s) = schema { + let value = XmlDeserializer::deserialize(xml, Arc::clone(&s))?; + serialize_json(&value, &s, indent, by_alias) + } else { + xml_to_json_dynamic(xml, indent) + } +} + +/// Transcode JSON bytes into XML bytes. +/// +/// If a `ModelSchema` is provided, typed data-binding is used, mapping JSON properties to XML +/// elements, attributes, and namespaces. +/// If `schema` is None, zero-copy dynamic streaming transcoding is used. +pub fn json_to_xml( + json: &[u8], + schema: Option>, + root_name: Option<&str>, + indent: Option, + namespaces: Option, + ns_map: Option<&HashMap>, +) -> Result> { + if let Some(s) = schema { + let value = deserialize_json(json, Arc::clone(&s))?; + let effective_root = root_name.unwrap_or(&s.name); + XmlSerializer::serialize_with_options( + effective_root, + &value, + &s, + indent, + namespaces, + ns_map, + ) + } else { + json_to_xml_dynamic(json, root_name, indent) + } +} + +struct DynamicFrame { + tag_name: String, + attrs: Map, + children: Vec<(String, Value)>, + text_buf: String, +} + +fn parse_dynamic_scalar(s: &str) -> Value { + let trimmed = s.trim(); + if trimmed.is_empty() { + return Value::String(String::new()); + } + if trimmed == "true" { + return Value::Bool(true); + } + if trimmed == "false" { + return Value::Bool(false); + } + if let Ok(n) = trimmed.parse::() { + return Value::Number(n.into()); + } + if let Ok(f) = trimmed.parse::() { + if let Some(num) = serde_json::Number::from_f64(f) { + return Value::Number(num); + } + } + Value::String(s.to_string()) +} + +/// Schema-less dynamic XML -> JSON transcoder. +pub fn xml_to_json_dynamic(xml: &[u8], indent: Option) -> Result> { + let mut reader = Reader::from_reader(Cursor::new(xml)); + reader.config_mut().trim_text(true); + + let mut buf = Vec::new(); + let mut stack: Vec = Vec::new(); + let mut root_val: Option<(String, Value)> = None; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => { + let tag = e.local_name().as_ref().to_string(); + let mut attrs = Map::new(); + for attr in e.attributes() { + let attr = attr.map_err(|err| PolyXmlError::XmlSyntaxError { + position: reader.buffer_position(), + source: quick_xml::Error::InvalidAttr(err), + })?; + let key = attr.key.local_name().as_ref().to_string(); + let raw_val = attr.value.as_ref(); + attrs.insert(format!("@{}", key), parse_dynamic_scalar(raw_val)); + } + stack.push(DynamicFrame { + tag_name: tag, + attrs, + children: Vec::new(), + text_buf: String::new(), + }); + } + Ok(Event::Text(ref e)) => { + if let Some(frame) = stack.last_mut() { + let text = quick_xml::escape::unescape(e.as_ref()).map_err(|err| { + PolyXmlError::XmlSyntaxError { + position: reader.buffer_position(), + source: quick_xml::Error::Escape(err), + } + })?; + frame.text_buf.push_str(&text); + } + } + Ok(Event::CData(ref e)) => { + if let Some(frame) = stack.last_mut() { + frame.text_buf.push_str(e.as_ref()); + } + } + Ok(Event::Empty(ref e)) => { + let tag = e.local_name().as_ref().to_string(); + let mut attrs = Map::new(); + for attr in e.attributes() { + let attr = attr.map_err(|err| PolyXmlError::XmlSyntaxError { + position: reader.buffer_position(), + source: quick_xml::Error::InvalidAttr(err), + })?; + let key = attr.key.local_name().as_ref().to_string(); + let raw_val = attr.value.as_ref(); + attrs.insert(format!("@{}", key), parse_dynamic_scalar(raw_val)); + } + let val = if attrs.is_empty() { + Value::Null + } else { + Value::Object(attrs) + }; + + if let Some(parent) = stack.last_mut() { + parent.children.push((tag, val)); + } else { + root_val = Some((tag, val)); + } + } + Ok(Event::End(_)) => { + if let Some(frame) = stack.pop() { + let mut obj = frame.attrs; + let has_children = !frame.children.is_empty(); + let has_attrs = !obj.is_empty(); + let trimmed_text = frame.text_buf.trim(); + let has_text = !trimmed_text.is_empty(); + + let node_val = if !has_children && !has_attrs { + if has_text { + parse_dynamic_scalar(trimmed_text) + } else { + Value::String(String::new()) + } + } else { + // Group repeated children into JSON arrays + let mut order = Vec::new(); + let mut groups: HashMap> = HashMap::new(); + for (child_name, child_val) in frame.children { + if !groups.contains_key(&child_name) { + order.push(child_name.clone()); + } + groups.entry(child_name).or_default().push(child_val); + } + + for child_name in order { + let mut items = groups.remove(&child_name).unwrap(); + if items.len() == 1 { + obj.insert(child_name, items.pop().unwrap()); + } else { + obj.insert(child_name, Value::Array(items)); + } + } + + if has_text { + obj.insert("value".to_string(), parse_dynamic_scalar(trimmed_text)); + } + + Value::Object(obj) + }; + + if let Some(parent) = stack.last_mut() { + parent.children.push((frame.tag_name, node_val)); + } else { + root_val = Some((frame.tag_name, node_val)); + } + } + } + Ok(Event::Eof) => break, + Err(err) => { + return Err(PolyXmlError::XmlSyntaxError { + position: reader.buffer_position(), + source: err, + }); + } + _ => {} + } + buf.clear(); + } + + let Some((root_tag, val)) = root_val else { + return Err(PolyXmlError::SchemaError("Empty XML input".into())); + }; + + let mut top = Map::new(); + top.insert(root_tag, val); + let final_json = Value::Object(top); + + let bytes = if let Some(indent_size) = indent { + let indent_str = " ".repeat(indent_size); + let formatter = serde_json::ser::PrettyFormatter::with_indent(indent_str.as_bytes()); + let mut out_buf = Vec::new(); + let mut ser = serde_json::Serializer::with_formatter(&mut out_buf, formatter); + serde::Serialize::serialize(&final_json, &mut ser) + .map_err(|e| PolyXmlError::SerializationError(e.to_string()))?; + out_buf + } else { + serde_json::to_vec(&final_json) + .map_err(|e| PolyXmlError::SerializationError(e.to_string()))? + }; + + Ok(bytes) +} + +/// Schema-less dynamic JSON -> XML transcoder. +pub fn json_to_xml_dynamic( + json_bytes: &[u8], + root_name: Option<&str>, + indent: Option, +) -> Result> { + let val: Value = serde_json::from_slice(json_bytes) + .map_err(|e| PolyXmlError::SerializationError(format!("Invalid JSON: {}", e)))?; + + let mut out = Vec::new(); + let mut writer = Writer::new(Cursor::new(&mut out)); + + match &val { + Value::Object(map) if root_name.is_none() && map.len() == 1 => { + let (root_tag, inner_val) = map.iter().next().unwrap(); + emit_json_value_as_xml(&mut writer, root_tag, inner_val, 0, indent)?; + } + _ => { + let root_tag = root_name.unwrap_or("root"); + emit_json_value_as_xml(&mut writer, root_tag, &val, 0, indent)?; + } + } + + if indent.is_some() { + out.push(b'\n'); + } + + Ok(out) +} + +fn emit_indent( + writer: &mut Writer, + depth: usize, + indent: Option, +) -> Result<()> { + if let Some(indent_size) = indent { + writer.write_event(Event::Text(BytesText::new("\n")))?; + let spaces = " ".repeat(depth * indent_size); + if !spaces.is_empty() { + writer.write_event(Event::Text(BytesText::new(&spaces)))?; + } + } + Ok(()) +} + +fn emit_json_value_as_xml( + writer: &mut Writer, + tag_name: &str, + val: &Value, + depth: usize, + indent: Option, +) -> Result<()> { + emit_indent(writer, depth, indent)?; + + match val { + Value::Null => { + let start = BytesStart::new(tag_name); + writer.write_event(Event::Empty(start))?; + } + Value::Bool(b) => { + let start = BytesStart::new(tag_name); + writer.write_event(Event::Start(start))?; + let s = if *b { "true" } else { "false" }; + writer.write_event(Event::Text(BytesText::new(s)))?; + writer.write_event(Event::End(BytesEnd::new(tag_name)))?; + } + Value::Number(n) => { + let start = BytesStart::new(tag_name); + writer.write_event(Event::Start(start))?; + let s = n.to_string(); + writer.write_event(Event::Text(BytesText::new(&s)))?; + writer.write_event(Event::End(BytesEnd::new(tag_name)))?; + } + Value::String(s) => { + let start = BytesStart::new(tag_name); + writer.write_event(Event::Start(start))?; + writer.write_event(Event::Text(BytesText::new(s)))?; + writer.write_event(Event::End(BytesEnd::new(tag_name)))?; + } + Value::Array(arr) => { + for item in arr { + emit_json_value_as_xml(writer, tag_name, item, depth, indent)?; + } + } + Value::Object(map) => { + let mut start = BytesStart::new(tag_name); + let mut children = Vec::new(); + let mut text_val = None; + + for (k, v) in map { + if let Some(attr_name) = k.strip_prefix('@') { + let attr_str = match v { + Value::String(s) => s.clone(), + _ => v.to_string(), + }; + start.push_attribute((attr_name, attr_str.as_str())); + } else if k == "value" || k == "#text" || k == "$" { + text_val = Some(match v { + Value::String(s) => s.clone(), + _ => v.to_string(), + }); + } else { + children.push((k.as_str(), v)); + } + } + + if children.is_empty() && text_val.is_none() { + writer.write_event(Event::Empty(start))?; + } else { + writer.write_event(Event::Start(start))?; + for (child_name, child_val) in &children { + if let Value::Array(items) = child_val { + for item in items { + emit_json_value_as_xml(writer, child_name, item, depth + 1, indent)?; + } + } else { + emit_json_value_as_xml(writer, child_name, child_val, depth + 1, indent)?; + } + } + if let Some(t) = text_val { + writer.write_event(Event::Text(BytesText::new(&t)))?; + } + if !children.is_empty() { + emit_indent(writer, depth, indent)?; + } + writer.write_event(Event::End(BytesEnd::new(tag_name)))?; + } + } + } + + Ok(()) +} diff --git a/crates/polyxml-core/tests/test_csharp_codegen.rs b/crates/polyxml-core/tests/test_csharp_codegen.rs index 53650d10..f54c7918 100644 --- a/crates/polyxml-core/tests/test_csharp_codegen.rs +++ b/crates/polyxml-core/tests/test_csharp_codegen.rs @@ -159,6 +159,7 @@ fn test_csharp_records_and_enums_generation() { let options = CSharpOptions { namespace: "Crm.Models".to_string(), emit_xml_attributes: true, + emit_json_attributes: true, emit_validation: true, record_kind: CSharpRecordKind::Class, use_file_scoped_namespaces: true, @@ -168,18 +169,25 @@ fn test_csharp_records_and_enums_generation() { let codegen = CSharpCodegen::new(options); let cs_code = codegen.generate_module(&ir); + assert!(cs_code.contains("using System.Text.Json.Serialization;")); assert!(cs_code.contains("namespace Crm.Models;")); + assert!(cs_code.contains("[JsonConverter(typeof(JsonStringEnumConverter))]")); assert!(cs_code.contains("public enum OrderStatus")); assert!(cs_code.contains("[XmlEnum(\"pending\")]")); assert!(cs_code.contains("Pending,")); assert!(cs_code.contains("public static bool IsValid(this OrderStatus value)")); assert!(cs_code.contains("public static string ToXmlValue(this OrderStatus value)")); assert!(cs_code.contains("public record Customer(")); - assert!(cs_code.contains("[property: XmlAttribute(\"id\")] int Id,")); - assert!(cs_code.contains("[property: XmlElement(\"name\")] string Name,")); - assert!(cs_code.contains("[property: XmlElement(\"email\")] string? Email,")); - assert!(cs_code.contains("[property: XmlElement(\"tag\")] List? Tag,")); - assert!(cs_code.contains("[property: XmlElement(\"status\")] OrderStatus Status")); + assert!(cs_code.contains("[property: XmlAttribute(\"id\"), JsonPropertyName(\"id\")] int Id,")); + assert!(cs_code + .contains("[property: XmlElement(\"name\"), JsonPropertyName(\"name\")] string Name,")); + assert!(cs_code + .contains("[property: XmlElement(\"email\"), JsonPropertyName(\"email\")] string? Email,")); + assert!(cs_code + .contains("[property: XmlElement(\"tag\"), JsonPropertyName(\"tag\")] List? Tag,")); + assert!(cs_code.contains( + "[property: XmlElement(\"status\"), JsonPropertyName(\"status\")] OrderStatus Status" + )); assert!(cs_code.contains("public Customer() : this(")); assert!(cs_code.contains("IValidatableObject")); @@ -205,6 +213,7 @@ fn test_csharp_records_and_enums_generation() { using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.IO; +using System.Text.Json; using System.Xml.Serialization; using Crm.Models; @@ -258,6 +267,21 @@ public class Program return 1; } + // Test System.Text.Json roundtrip + var json = JsonSerializer.Serialize(cust); + if (!json.Contains("\"id\":42") || !json.Contains("\"name\":\"Alice\"") || !json.Contains("\"Pending\"")) + { + Console.WriteLine("JSON serialization missing fields: " + json); + return 1; + } + + var jsonDecoded = JsonSerializer.Deserialize(json); + if (jsonDecoded == null || jsonDecoded.Id != 42 || jsonDecoded.Name != "Alice" || jsonDecoded.Email != "alice@example.com" || jsonDecoded.Status != OrderStatus.Pending) + { + Console.WriteLine("JSON deserialization mismatch"); + return 1; + } + // Test IValidatableObject validation var validResults = new List(); bool isValid = Validator.TryValidateObject(cust, new ValidationContext(cust), validResults, true); @@ -374,6 +398,7 @@ fn test_csharp_choice_polymorphic_hierarchy() { let options = CSharpOptions { namespace: "Payments".to_string(), emit_xml_attributes: true, + emit_json_attributes: true, emit_validation: true, record_kind: CSharpRecordKind::Class, use_file_scoped_namespaces: true, @@ -388,8 +413,9 @@ fn test_csharp_choice_polymorphic_hierarchy() { assert!(cs_code.contains("[XmlInclude(typeof(ContactChoice.Phone))]")); assert!(cs_code.contains("public sealed record Email(")); assert!(cs_code.contains("public sealed record Phone(")); - assert!(cs_code.contains("[property: XmlElement(\"email\", typeof(ContactChoice.Email))]")); - assert!(cs_code.contains("[property: XmlElement(\"phone\", typeof(ContactChoice.Phone))]")); + assert!(cs_code.contains("XmlElement(\"email\", typeof(ContactChoice.Email))")); + assert!(cs_code.contains("XmlElement(\"phone\", typeof(ContactChoice.Phone))")); + assert!(cs_code.contains("JsonPropertyName(\"contact\")")); let temp = tempdir().unwrap(); let csproj = r#" @@ -524,6 +550,7 @@ fn test_csharp_recursive_cycle() { let options = CSharpOptions { namespace: "Tree".to_string(), emit_xml_attributes: true, + emit_json_attributes: true, emit_validation: true, record_kind: CSharpRecordKind::Class, use_file_scoped_namespaces: true, @@ -534,8 +561,11 @@ fn test_csharp_recursive_cycle() { let cs_code = codegen.generate_module(&ir); assert!(cs_code.contains("public record TreeNode(")); - assert!(cs_code.contains("[property: XmlElement(\"label\")] string Label,")); - assert!(cs_code.contains("[property: XmlElement(\"next\")] TreeNode? Next = null")); + assert!(cs_code + .contains("[property: XmlElement(\"label\"), JsonPropertyName(\"label\")] string Label,")); + assert!(cs_code.contains( + "[property: XmlElement(\"next\"), JsonPropertyName(\"next\")] TreeNode? Next = null" + )); let temp = tempdir().unwrap(); let csproj = r#" diff --git a/crates/polyxml-core/tests/test_go_codegen.rs b/crates/polyxml-core/tests/test_go_codegen.rs index 6d9b5d8b..78bd3497 100644 --- a/crates/polyxml-core/tests/test_go_codegen.rs +++ b/crates/polyxml-core/tests/test_go_codegen.rs @@ -158,6 +158,7 @@ fn test_go_struct_and_enum_generation() { let options = GoOptions { package_name: "crm".to_string(), emit_xml_tags: true, + emit_json_tags: true, validate_choice_exclusivity: true, validate_facets: true, emit_root_aliases: true, @@ -171,11 +172,12 @@ fn test_go_struct_and_enum_generation() { assert!(go_code.contains("OrderStatusPending OrderStatus = \"pending\"")); assert!(go_code.contains("func (e OrderStatus) IsValid() bool")); assert!(go_code.contains("type Customer struct {")); - assert!(go_code.contains("ID int32 `xml:\"id,attr\"`")); - assert!(go_code.contains("Name string `xml:\"name\"`")); - assert!(go_code.contains("Email *string `xml:\"email,omitempty\"`")); - assert!(go_code.contains("Tags []string `xml:\"tag\"`")); - assert!(go_code.contains("Status OrderStatus `xml:\"status\"`")); + assert!(go_code.contains("XMLName xml.Name `json:\"-\"`")); + assert!(go_code.contains("ID int32 `xml:\"id,attr\" json:\"id\"`")); + assert!(go_code.contains("Name string `xml:\"name\" json:\"name\"`")); + assert!(go_code.contains("Email *string `xml:\"email,omitempty\" json:\"email,omitempty\"`")); + assert!(go_code.contains("Tags []string `xml:\"tag\" json:\"tag\"`")); + assert!(go_code.contains("Status OrderStatus `xml:\"status\" json:\"status\"`")); // Verify Go compilation and test execution let temp = tempdir().unwrap(); @@ -188,7 +190,9 @@ fn test_go_struct_and_enum_generation() { r#"package crm import ( + "encoding/json" "encoding/xml" + "strings" "testing" ) @@ -206,26 +210,50 @@ func TestCustomerRoundtrip(t *testing.T) { t.Fatalf("expected status to be valid") } - data, err := xml.Marshal(c) + // 1. XML Roundtrip + xmlData, err := xml.Marshal(c) if err != nil { - t.Fatalf("marshal failed: %v", err) + t.Fatalf("xml marshal failed: %v", err) } - var decoded Customer - if err := xml.Unmarshal(data, &decoded); err != nil { - t.Fatalf("unmarshal failed: %v", err) + var xmlDecoded Customer + if err := xml.Unmarshal(xmlData, &xmlDecoded); err != nil { + t.Fatalf("xml unmarshal failed: %v", err) } - if decoded.ID != 42 || decoded.Name != "Alice" || decoded.Email == nil || *decoded.Email != "alice@example.com" { - t.Fatalf("roundtrip mismatch: %+v", decoded) + if xmlDecoded.ID != 42 || xmlDecoded.Name != "Alice" || xmlDecoded.Email == nil || *xmlDecoded.Email != "alice@example.com" { + t.Fatalf("xml roundtrip mismatch: %+v", xmlDecoded) } - if len(decoded.Tags) != 2 || decoded.Tags[0] != "vip" { - t.Fatalf("tags mismatch: %+v", decoded.Tags) + if len(xmlDecoded.Tags) != 2 || xmlDecoded.Tags[0] != "vip" { + t.Fatalf("xml tags mismatch: %+v", xmlDecoded.Tags) } - if err := decoded.Validate(); err != nil { + if err := xmlDecoded.Validate(); err != nil { t.Fatalf("validation failed: %v", err) } + + // 2. JSON Roundtrip + jsonData, err := json.Marshal(c) + if err != nil { + t.Fatalf("json marshal failed: %v", err) + } + + jsonStr := string(jsonData) + if strings.Contains(jsonStr, "XMLName") { + t.Fatalf("json contains XMLName: %s", jsonStr) + } + if !strings.Contains(jsonStr, `"id":42`) { + t.Fatalf("json missing id: %s", jsonStr) + } + + var jsonDecoded Customer + if err := json.Unmarshal(jsonData, &jsonDecoded); err != nil { + t.Fatalf("json unmarshal failed: %v", err) + } + + if jsonDecoded.ID != 42 || jsonDecoded.Name != "Alice" || jsonDecoded.Email == nil || *jsonDecoded.Email != "alice@example.com" { + t.Fatalf("json roundtrip mismatch: %+v", jsonDecoded) + } } "#, ) @@ -313,6 +341,7 @@ fn test_go_choice_mutual_exclusivity() { let options = GoOptions { package_name: "payments".to_string(), emit_xml_tags: true, + emit_json_tags: true, validate_choice_exclusivity: true, validate_facets: true, emit_root_aliases: true, @@ -448,7 +477,7 @@ fn test_go_recursive_cycle_pointers() { let codegen = GoCodegen::new(options); let go_code = codegen.generate_module(&ir); - assert!(go_code.contains("Next *TreeNode `xml:\"next,omitempty\"`")); + assert!(go_code.contains("Next *TreeNode `xml:\"next,omitempty\" json:\"next,omitempty\"`")); let temp = tempdir().unwrap(); let mod_path = temp.path().join("tree.go"); diff --git a/crates/polyxml-core/tests/test_python_codegen.rs b/crates/polyxml-core/tests/test_python_codegen.rs index 2543e7ac..eca2ff16 100644 --- a/crates/polyxml-core/tests/test_python_codegen.rs +++ b/crates/polyxml-core/tests/test_python_codegen.rs @@ -126,6 +126,7 @@ fn test_python_dataclass_codegen() { emit_meta: true, emit_root_aliases: true, emit_codecs: true, + emit_json_metadata: true, }); let code = codegen.generate_module(&ir); @@ -145,10 +146,10 @@ fn test_python_dataclass_codegen() { assert!(code.contains("namespace = \"https://example.com/shop\"")); // Check fields - assert!(code.contains("id: int = field(metadata={\"type\": \"Attribute\", \"name\": \"id\"})")); - assert!(code.contains("type_: str | None = field(default=None, metadata={\"type\": \"Element\", \"name\": \"type\", \"namespace\": \"https://example.com/shop\", \"nillable\": True})")); - assert!(code.contains("status: OrderStatus = field(default=\"pending\", metadata={\"type\": \"Element\", \"name\": \"status\"})")); - assert!(code.contains("tags: list[str] = field(default_factory=list, metadata={\"type\": \"Element\", \"name\": \"tag\"})")); + assert!(code.contains("id: int = field(metadata={\"type\": \"Attribute\", \"name\": \"id\", \"json_name\": \"id\"})")); + assert!(code.contains("type_: str | None = field(default=None, metadata={\"type\": \"Element\", \"name\": \"type\", \"json_name\": \"type\", \"namespace\": \"https://example.com/shop\", \"nillable\": True})")); + assert!(code.contains("status: OrderStatus = field(default=\"pending\", metadata={\"type\": \"Element\", \"name\": \"status\", \"json_name\": \"status\"})")); + assert!(code.contains("tags: list[str] = field(default_factory=list, metadata={\"type\": \"Element\", \"name\": \"tag\", \"json_name\": \"tag\"})")); } #[test] @@ -220,6 +221,7 @@ fn test_python_pydantic_codegen_with_facets() { emit_meta: true, emit_root_aliases: true, emit_codecs: true, + emit_json_metadata: true, }); let code = codegen.generate_module(&ir); @@ -229,9 +231,9 @@ fn test_python_pydantic_codegen_with_facets() { assert!(code.contains("type Age = Annotated[int, Field(ge=0, le=120)]")); assert!(code.contains("class User(BaseModel):")); assert!(code.contains("model_config = ConfigDict(defer_build=True, populate_by_name=True)")); - assert!(code.contains("username: str = Field(..., json_schema_extra={\"type\": \"Element\", \"name\": \"username\"}, min_length=3, max_length=20, pattern=r\"^[a-zA-Z0-9_]+$\")")); + assert!(code.contains("username: str = Field(..., json_schema_extra={\"type\": \"Element\", \"name\": \"username\", \"json_name\": \"username\"}, min_length=3, max_length=20, pattern=r\"^[a-zA-Z0-9_]+$\")")); assert!(code.contains( - "user_age: Age = Field(..., json_schema_extra={\"type\": \"Element\", \"name\": \"age\"})" + "user_age: Age = Field(..., alias=\"age\", serialization_alias=\"age\", json_schema_extra={\"type\": \"Element\", \"name\": \"age\", \"json_name\": \"age\"})" )); } diff --git a/crates/polyxml-core/tests/test_rust_codegen.rs b/crates/polyxml-core/tests/test_rust_codegen.rs index 0a0f3db9..9b807c69 100644 --- a/crates/polyxml-core/tests/test_rust_codegen.rs +++ b/crates/polyxml-core/tests/test_rust_codegen.rs @@ -420,11 +420,22 @@ fn test_rust_codecs_codegen() { let code_enabled = codegen_enabled.generate_module(&ir); assert!(code_enabled.contains("pub fn from_xml(xml: &'a str) -> Result")); assert!(code_enabled.contains("pub fn from_xml_bytes(xml_bytes: &'a [u8]) -> Result")); + assert!(code_enabled.contains( + "pub fn from_json_str(json_str: &'a str) -> std::result::Result" + )); + assert!(code_enabled.contains( + "pub fn from_json_slice(bytes: &'a [u8]) -> std::result::Result" + )); assert!(code_enabled.contains( "pub fn decode_xml(reader: &mut Reader<&'a [u8]>, start: &BytesStart<'_>) -> Result" )); assert!(code_enabled.contains("pub fn to_xml(&self) -> Result>")); assert!(code_enabled.contains("pub fn to_xml_string(&self) -> Result")); + assert!(code_enabled.contains( + "pub fn to_json_string(&self) -> std::result::Result" + )); + assert!(code_enabled + .contains("pub fn to_json_vec(&self) -> std::result::Result, serde_json::Error>")); assert!(code_enabled.contains("pub fn encode_xml(&self, writer: &mut Writer, tag_name: Option<&str>) -> Result<()>")); let codegen_disabled = RustCodegen::new(RustOptions { @@ -434,4 +445,6 @@ fn test_rust_codecs_codegen() { let code_disabled = codegen_disabled.generate_module(&ir); assert!(!code_disabled.contains("pub fn from_xml(")); assert!(!code_disabled.contains("pub fn to_xml(&self)")); + assert!(!code_disabled.contains("pub fn from_json_str(")); + assert!(!code_disabled.contains("pub fn to_json_string(")); } diff --git a/crates/polyxml-core/tests/test_transcoder.rs b/crates/polyxml-core/tests/test_transcoder.rs new file mode 100644 index 00000000..51b6afd5 --- /dev/null +++ b/crates/polyxml-core/tests/test_transcoder.rs @@ -0,0 +1,153 @@ +use std::sync::Arc; + +use polyxml::schema::{FieldKind, FieldSchema, ModelSchema, ScalarType, ValueType}; +use polyxml::schema_parser::XsdParser; +use polyxml::{json_to_xml, xml_to_json}; + +#[test] +fn test_schemaless_xml_to_json_and_back() { + let xml = br#"AliceBookPen"#; + + let json_bytes = xml_to_json(xml, None, None, true).expect("xml_to_json"); + let json_str = String::from_utf8(json_bytes.clone()).unwrap(); + + assert!(json_str.contains("\"@id\":42")); + assert!(json_str.contains("\"@vip\":true")); + assert!(json_str.contains("\"customer\":\"Alice\"")); + assert!(json_str.contains("\"item\":[\"Book\",\"Pen\"]")); + + // Transcode back from JSON to XML + let roundtrip_xml = + json_to_xml(&json_bytes, None, None, None, None, None).expect("json_to_xml"); + let roundtrip_str = String::from_utf8(roundtrip_xml).unwrap(); + + assert!( + roundtrip_str.contains("Alice")); + assert!(roundtrip_str.contains("Book")); + assert!(roundtrip_str.contains("Pen")); +} + +#[test] +fn test_schemaless_pretty_transcoding() { + let xml = br#"Hello World"#; + let json_bytes = xml_to_json(xml, None, Some(2), true).unwrap(); + let json_str = String::from_utf8(json_bytes).unwrap(); + + assert!(json_str.contains("{\n \"root\": {\n \"greeting\": \"Hello World\"\n }\n}")); + + let xml_roundtrip = json_to_xml(json_str.as_bytes(), None, None, Some(2), None, None).unwrap(); + let xml_str = String::from_utf8(xml_roundtrip).unwrap(); + assert!(xml_str.contains("\n Hello World\n")); +} + +#[test] +fn test_schema_directed_transcoding() { + let item_schema = ModelSchema::builder("Item") + .field(FieldSchema::new( + "name", + b"name", + FieldKind::Element, + ValueType::Scalar(ScalarType::String), + )) + .field(FieldSchema::new( + "sku", + b"sku", + FieldKind::Attribute, + ValueType::Scalar(ScalarType::String), + )) + .build(); + + let order_schema = ModelSchema::builder("Order") + .field(FieldSchema::new( + "id", + b"id", + FieldKind::Attribute, + ValueType::Scalar(ScalarType::Int), + )) + .field(FieldSchema::new( + "active", + b"active", + FieldKind::Element, + ValueType::Scalar(ScalarType::Bool), + )) + .field(FieldSchema::new( + "item", + b"item", + FieldKind::Element, + ValueType::List(Box::new(ValueType::Nested(item_schema))), + )) + .build(); + + let xml = br#"trueKeyboardMouse"#; + + let json_bytes = xml_to_json(xml, Some(Arc::clone(&order_schema)), Some(2), true).unwrap(); + let json_str = String::from_utf8(json_bytes.clone()).unwrap(); + + assert!(json_str.contains("\"id\": 999")); + assert!(json_str.contains("\"active\": true")); + assert!(json_str.contains("\"sku\": \"SKU-1\"")); + assert!(json_str.contains("\"name\": \"Keyboard\"")); + + let roundtrip_xml = json_to_xml( + &json_bytes, + Some(order_schema), + Some("Order"), + None, + None, + None, + ) + .unwrap(); + let roundtrip_str = String::from_utf8(roundtrip_xml).unwrap(); + + assert!(roundtrip_str.contains("id=\"999\"")); + assert!(roundtrip_str.contains("true")); + assert!(roundtrip_str.contains("Keyboard")); + assert!(roundtrip_str.contains("Mouse")); +} + +#[test] +fn test_schema_from_ir_transcoding() { + let xsd = r#" + + + + + + + + + +"#; + + let mut parser = XsdParser::new(); + let ir = parser.parse_str(xsd).expect("parse xsd"); + let model_schema = + ModelSchema::from_ir(&ir, Some("Product")).expect("build model schema from ir"); + + assert_eq!(model_schema.name, "ProductType"); + + let xml = + br#"Rust in Action39.99"#; + let json_bytes = + xml_to_json(xml, Some(model_schema), None, true).expect("transcode with ir schema"); + let json_str = String::from_utf8(json_bytes).unwrap(); + + assert!(json_str.contains("\"code\":\"PROD-101\"")); + assert!(json_str.contains("\"title\":\"Rust in Action\"")); + assert!(json_str.contains("\"price\":\"39.99\"")); +} + +#[test] +fn test_transcoder_errors() { + // Empty XML + let err = xml_to_json(b"", None, None, true).unwrap_err(); + assert!(err.to_string().contains("Empty XML input")); + + // Malformed JSON + let err = json_to_xml(b"{ not json }", None, None, None, None, None).unwrap_err(); + assert!(err.to_string().contains("Invalid JSON")); +} diff --git a/crates/polyxml-python/python/polyxml/__init__.py b/crates/polyxml-python/python/polyxml/__init__.py index 876d0630..ee4aad53 100644 --- a/crates/polyxml-python/python/polyxml/__init__.py +++ b/crates/polyxml-python/python/polyxml/__init__.py @@ -43,6 +43,9 @@ from polyxml._polyxml import ( iterparse as _iterparse, ) +from polyxml._polyxml import ( + json_to_xml as _json_to_xml, +) from polyxml._polyxml import ( serialize as _serialize, ) @@ -52,6 +55,9 @@ from polyxml._polyxml import ( version as _version, ) +from polyxml._polyxml import ( + xml_to_json as _xml_to_json, +) __version__: str = _version() @@ -195,6 +201,81 @@ def dumps_json( return serialize_json(obj, indent=indent, by_alias=by_alias).decode("utf-8") +def xml_to_json( + source: bytes | str | pathlib.Path | IO[bytes] | IO[str], + target_type: type[object] | None = None, + *, + model: type[object] | None = None, + schema_path: str | pathlib.Path | None = None, + root: str | None = None, + indent: int | None = None, + by_alias: bool = True, +) -> bytes: + """Transcode XML into JSON bytes directly in C/Rust. + + Args: + source: XML content as raw bytes, string, Path, or file stream. + target_type: Optional Python dataclass/model type to guide schema-directed transcoding. + model: Alias for target_type. + schema_path: Optional path to an XSD schema file. + root: Optional root element name when using schema_path. + indent: Optional indentation size in spaces for pretty-printed JSON. + by_alias: Whether to use XML tag aliases as JSON keys (default True). + + Returns: + Transcoded UTF-8 JSON bytes. + """ + effective_type = model if model is not None else target_type + path_str = str(schema_path) if schema_path is not None else None + return _xml_to_json( + _to_bytes(source), + target_type=effective_type, + schema_path=path_str, + root=root, + indent=indent, + by_alias=by_alias, + ) + + +def json_to_xml( + source: bytes | str | pathlib.Path | IO[bytes] | IO[str], + target_type: type[object] | None = None, + *, + model: type[object] | None = None, + schema_path: str | pathlib.Path | None = None, + root: str | None = None, + indent: int | None = None, + namespaces: bool | None = None, + ns_map: dict[str, str] | None = None, +) -> bytes: + """Transcode JSON into XML bytes directly in C/Rust. + + Args: + source: JSON content as raw bytes, string, Path, or file stream. + target_type: Optional Python dataclass/model type to guide schema-directed transcoding. + model: Alias for target_type. + schema_path: Optional path to an XSD schema file. + root: Optional root XML element tag name. + indent: Optional indentation size in spaces for pretty-printed XML. + namespaces: Optional boolean toggle for XML namespaces. + ns_map: Optional prefix-to-URI or URI-to-prefix mapping dictionary. + + Returns: + Transcoded UTF-8 XML bytes. + """ + effective_type = model if model is not None else target_type + path_str = str(schema_path) if schema_path is not None else None + return _json_to_xml( + _to_bytes(source), + target_type=effective_type, + schema_path=path_str, + root=root, + indent=indent, + namespaces=namespaces, + ns_map=ns_map, + ) + + class JsonSerializer: """Drop-in xsdata-compatible JSON serializer.""" @@ -443,8 +524,10 @@ def loads_binary[T](data: bytes, target_type: type[T] | None = None) -> T | obje "dumps_binary", "dumps_json", "iterparse", + "json_to_xml", "loads_binary", "loads_json", "serialize", "serialize_json", + "xml_to_json", ] diff --git a/crates/polyxml-python/src/lib.rs b/crates/polyxml-python/src/lib.rs index 3b3cd82d..7af8bb68 100644 --- a/crates/polyxml-python/src/lib.rs +++ b/crates/polyxml-python/src/lib.rs @@ -1,6 +1,7 @@ #![allow(clippy::type_complexity)] #![allow(clippy::only_used_in_recursion)] #![allow(clippy::useless_conversion)] +#![allow(clippy::too_many_arguments)] use pyo3::prelude::*; use pyo3::types::{PyBytes, PyDict, PyList, PyString, PyTuple, PyType}; @@ -928,6 +929,108 @@ fn serialize_json<'py>( Ok(PyBytes::new(py, &bytes)) } +#[pyfunction] +#[pyo3(signature = (source, target_type=None, schema_path=None, root=None, indent=None, by_alias=None))] +fn xml_to_json<'py>( + py: Python<'py>, + source: &[u8], + target_type: Option>, + schema_path: Option<&str>, + root: Option<&str>, + indent: Option, + by_alias: Option, +) -> PyResult> { + let bytes = if let Some(ref target_type) = target_type { + let meta = get_or_create_schema_meta(target_type)?; + polyxml::xml_to_json( + source, + Some(Arc::clone(&meta.schema)), + indent, + by_alias.unwrap_or(true), + ) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))? + } else if let Some(path) = schema_path { + let xsd_str = std::fs::read_to_string(path).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!("Failed to read schema file: {}", e)) + })?; + let mut parser = polyxml::schema_parser::XsdParser::new(); + let ir = parser.parse_str(&xsd_str).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!("Failed to parse schema: {}", e)) + })?; + let model_schema = ModelSchema::from_ir(&ir, root).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!( + "Failed to build schema from IR: {}", + e + )) + })?; + polyxml::xml_to_json(source, Some(model_schema), indent, by_alias.unwrap_or(true)) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))? + } else { + polyxml::transcoder::xml_to_json_dynamic(source, indent) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))? + }; + + Ok(PyBytes::new(py, &bytes)) +} + +#[pyfunction] +#[pyo3(signature = (source, target_type=None, schema_path=None, root=None, indent=None, namespaces=None, ns_map=None))] +fn json_to_xml<'py>( + py: Python<'py>, + source: &[u8], + target_type: Option>, + schema_path: Option<&str>, + root: Option<&str>, + indent: Option, + namespaces: Option, + ns_map: Option>, +) -> PyResult> { + let bytes = if let Some(ref target_type) = target_type { + let meta = get_or_create_schema_meta(target_type)?; + let root_name = root.unwrap_or(&meta.schema.name); + polyxml::json_to_xml( + source, + Some(Arc::clone(&meta.schema)), + Some(root_name), + indent, + namespaces, + ns_map.as_ref(), + ) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))? + } else if let Some(path) = schema_path { + let xsd_str = std::fs::read_to_string(path).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!("Failed to read schema file: {}", e)) + })?; + let mut parser = polyxml::schema_parser::XsdParser::new(); + let ir = parser.parse_str(&xsd_str).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!("Failed to parse schema: {}", e)) + })?; + let model_schema = ModelSchema::from_ir(&ir, root).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!( + "Failed to build schema from IR: {}", + e + )) + })?; + let root_name = root + .map(|s| s.to_string()) + .unwrap_or_else(|| model_schema.name.clone()); + polyxml::json_to_xml( + source, + Some(model_schema), + Some(&root_name), + indent, + namespaces, + ns_map.as_ref(), + ) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))? + } else { + polyxml::transcoder::json_to_xml_dynamic(source, root, indent) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))? + }; + + Ok(PyBytes::new(py, &bytes)) +} + #[pyfunction] fn version() -> &'static str { env!("CARGO_PKG_VERSION") @@ -941,6 +1044,8 @@ fn _polyxml(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(iterparse, m)?)?; m.add_function(wrap_pyfunction!(serialize, m)?)?; m.add_function(wrap_pyfunction!(serialize_json, m)?)?; + m.add_function(wrap_pyfunction!(xml_to_json, m)?)?; + m.add_function(wrap_pyfunction!(json_to_xml, m)?)?; m.add_function(wrap_pyfunction!(version, m)?)?; Ok(()) } diff --git a/crates/polyxml-python/tests/test_json.py b/crates/polyxml-python/tests/test_json.py index 82e79da5..640f975e 100644 --- a/crates/polyxml-python/tests/test_json.py +++ b/crates/polyxml-python/tests/test_json.py @@ -273,3 +273,115 @@ def test_to_bytes_error_handling(): def test_invalid_json_raises(): with pytest.raises(ValueError): polyxml.deserialize_json(b"{not valid json}", Customer) + + +def test_transcoder_dynamic_bidirectional(tmp_path: pathlib.Path): + xml_str = 'API Gateway80808443' + + # 1. XML to JSON (dynamic) + json_bytes = polyxml.xml_to_json(xml_str, indent=2) + assert b'"@id": 99' in json_bytes + assert b'"@enabled": true' in json_bytes + assert b'"name": "API Gateway"' in json_bytes + assert b"8080" in json_bytes + assert b"8443" in json_bytes + + # 2. JSON to XML (dynamic) + roundtrip_xml = polyxml.json_to_xml(json_bytes, indent=2) + assert b"API Gateway" in roundtrip_xml + assert b"8080" in roundtrip_xml + + # Test with pathlib.Path and IO stream + xml_file = tmp_path / "service.xml" + xml_file.write_bytes(xml_str.encode("utf-8")) + json_from_file = polyxml.xml_to_json(xml_file) + assert b'"@id":99' in json_from_file + + stream = io.BytesIO(json_from_file) + xml_from_stream = polyxml.json_to_xml(stream) + assert b'id="99"' in xml_from_stream + + +def test_transcoder_model_guided(): + xml_data = ( + b'' + b"Cyberdyne Systems" + b"1250000.50" + b"true" + b"active" + b'99999.99' + b"" + ) + + # 1. XML -> JSON guided by Model (dataclass) + json_bytes = polyxml.xml_to_json(xml_data, Customer, indent=2) + assert b'"customerId": 456' in json_bytes + assert b'"companyName": "Cyberdyne Systems"' in json_bytes + assert b'"accountBalance": "1250000.50"' in json_bytes + assert b'"skuCode": "T-800"' in json_bytes + + # Using model= kwarg alias + json_bytes_kwarg = polyxml.xml_to_json(xml_data, model=Customer, by_alias=True) + assert b'"customerId":456' in json_bytes_kwarg + + # 2. JSON -> XML guided by Model + xml_roundtrip = polyxml.json_to_xml(json_bytes, model=Customer, root="Customer", indent=2) + assert b'' in xml_roundtrip + assert b"Cyberdyne Systems" in xml_roundtrip + assert b' + + + + + + + + + +""" + + xsd_file = tmp_path / "product.xsd" + xsd_file.write_text(xsd_content, encoding="utf-8") + + xml_content = ( + 'Mechanical Keyboard129.99' + ) + + # 1. XML -> JSON with schema_path + json_bytes = polyxml.xml_to_json(xml_content, schema_path=xsd_file, root="Product", indent=2) + assert b'"code": "PROD-77"' in json_bytes + assert b'"title": "Mechanical Keyboard"' in json_bytes + assert b'"price": "129.99"' in json_bytes + + # 2. JSON -> XML with schema_path and str path + xml_bytes = polyxml.json_to_xml(json_bytes, schema_path=str(xsd_file), root="Product", indent=2) + assert b'code="PROD-77"' in xml_bytes + assert b"Mechanical Keyboard" in xml_bytes + assert b"129.99" in xml_bytes + + +def test_transcoder_error_handling(tmp_path: pathlib.Path): + # Non-existent schema file + with pytest.raises(ValueError, match="Failed to read schema file"): + polyxml.xml_to_json(b"", schema_path="non_existent_schema.xsd") + + # Invalid schema XML + bad_xsd = tmp_path / "bad.xsd" + bad_xsd.write_text("not xml", encoding="utf-8") + with pytest.raises(ValueError, match="Failed to (parse schema|build schema)"): + polyxml.xml_to_json(b"", schema_path=bad_xsd) + + # Empty XML + with pytest.raises(ValueError): + polyxml.xml_to_json(b"", indent=2) + + # Invalid JSON + with pytest.raises(ValueError): + polyxml.json_to_xml(b"not json", indent=2)