├── test_xml_files ├── mixed_text_nodes.json ├── mixed_text_nodes.xml ├── numerical.json ├── numerical.xml ├── def_namespace.xml ├── def_namespace.json ├── xsd.xml └── xsd.json ├── .gitignore ├── examples ├── basic.rs └── json_types.rs ├── Cargo.toml ├── COPYRIGHT ├── .github └── workflows │ └── test.yml ├── README.md └── src ├── tests.rs └── lib.rs /test_xml_files/mixed_text_nodes.json: -------------------------------------------------------------------------------- 1 | { 2 | "Root": "Some text is totally valid here" 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | 4 | /target 5 | **/*.rs.bk 6 | Cargo.lock 7 | 8 | .#*w 9 | *~ 10 | *# 11 | .#* -------------------------------------------------------------------------------- /test_xml_files/mixed_text_nodes.xml: -------------------------------------------------------------------------------- 1 | 2 | Some text is totally valid here 3 | 7.25 4 | 5 | and also at this level, but this mixing of text and elements produces incorrect JSON. 6 | See https://github.com/AlecTroemel/quickxml_to_serde/issues/9 for details. 7 | A 8 | 3 9 | 24.50 10 | 11 | -------------------------------------------------------------------------------- /examples/basic.rs: -------------------------------------------------------------------------------- 1 | extern crate quickxml_to_serde; 2 | use quickxml_to_serde::{xml_string_to_json, Config, NullValue}; 3 | 4 | fn main() { 5 | let xml = r#"some text"#; 6 | let conf = Config::new_with_defaults(); 7 | let json = xml_string_to_json(xml.to_owned(), &conf); 8 | println!("{}", json.expect("Malformed XML").to_string()); 9 | 10 | let conf = Config::new_with_custom_values(true, "", "txt", NullValue::Null); 11 | let json = xml_string_to_json(xml.to_owned(), &conf); 12 | println!("{}", json.expect("Malformed XML").to_string()); 13 | } 14 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "quickxml_to_serde" 3 | version = "0.6.0" 4 | authors = ["Alec Troemel ", "Max Voskob "] 5 | description = "Convert between XML JSON using quickxml and serde" 6 | repository = "https://github.com/AlecTroemel/quickxml_to_serde" 7 | keywords = ["json", "xml", "xml2json", "xml_to_json"] 8 | license = "MIT" 9 | 10 | [dependencies] 11 | serde = "1.0" 12 | serde_json = "1.0" 13 | serde_derive = "1.0" 14 | minidom = "0.12" 15 | regex = "1.8.3" 16 | 17 | [features] 18 | json_types = [] # Enable to enforce fixed JSON data types for certain XML nodes 19 | regex_path = ["json_types"] # Enable Regex matching for JSON types 20 | -------------------------------------------------------------------------------- /examples/json_types.rs: -------------------------------------------------------------------------------- 1 | extern crate quickxml_to_serde; 2 | #[cfg(feature = "json_types")] 3 | use quickxml_to_serde::{xml_string_to_json, Config, JsonArray, JsonType}; 4 | 5 | #[cfg(feature = "json_types")] 6 | fn main() { 7 | let xml = r#"true"#; 8 | 9 | // custom config values for 1 attribute and a text node 10 | let conf = Config::new_with_defaults() 11 | .add_json_type_override("/a/b/@attr1", JsonArray::Infer(JsonType::AlwaysString)) 12 | .add_json_type_override("/a/b", JsonArray::Infer(JsonType::AlwaysString)); 13 | let json = xml_string_to_json(String::from(xml), &conf); 14 | println!("{}", json.expect("Malformed XML").to_string()); 15 | } 16 | 17 | #[cfg(not(feature = "json_types"))] 18 | fn main() { 19 | println!("Run this example with `--features json_types` parameter"); 20 | } 21 | -------------------------------------------------------------------------------- /test_xml_files/numerical.json: -------------------------------------------------------------------------------- 1 | { 2 | "Root": { 3 | "Data": [ 4 | { 5 | "Category": "A", 6 | "Price": 24.5, 7 | "Quantity": 3 8 | }, 9 | { 10 | "Category": "B", 11 | "Price": 89.99, 12 | "Quantity": 1 13 | }, 14 | { 15 | "Category": "A", 16 | "Price": 4.95, 17 | "Quantity": 5 18 | }, 19 | { 20 | "Category": "A", 21 | "Price": 66.0, 22 | "Quantity": 3 23 | }, 24 | { 25 | "Category": "B", 26 | "Price": 0.99, 27 | "Quantity": 10 28 | }, 29 | { 30 | "Category": "A", 31 | "Price": 29.0, 32 | "Quantity": 15 33 | }, 34 | { 35 | "Category": "B", 36 | "Price": 6.99, 37 | "Quantity": 8 38 | } 39 | ], 40 | "TaxRate": 7.25 41 | } 42 | } -------------------------------------------------------------------------------- /test_xml_files/numerical.xml: -------------------------------------------------------------------------------- 1 | 2 | 7.25 3 | 4 | A 5 | 3 6 | 24.50 7 | 8 | 9 | B 10 | 1 11 | 89.99 12 | 13 | 14 | A 15 | 5 16 | 4.95 17 | 18 | 19 | A 20 | 3 21 | 66.00 22 | 23 | 24 | B 25 | 10 26 | .99 27 | 28 | 29 | A 30 | 15 31 | 29.00 32 | 33 | 34 | B 35 | 8 36 | 6.99 37 | 38 | -------------------------------------------------------------------------------- /COPYRIGHT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Alec Troemel 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | pull_request: 8 | branches: [ master ] 9 | 10 | env: 11 | CARGO_TERM_COLOR: always 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout Repository 18 | uses: actions/checkout@v3 19 | 20 | - name: Generate Dependencies Hash 21 | id: cargo_toml_hash 22 | uses: KEINOS/gh-action-hash-for-cache@e0515fd0280f1ef616e13cef3b2b9566938da2c4 23 | with: 24 | path: | 25 | ./Cargo.toml 26 | 27 | - name: Retrieve Cargo's Index - Try Cache 28 | id: cargo_index_cache 29 | uses: actions/cache/restore@v3 30 | with: 31 | path: ~/.cargo 32 | key: ${{ runner.os }}-cargo-index-${{ steps.cargo_toml_hash.outputs.hash }} 33 | 34 | - name: Build 35 | run: cargo build --verbose 36 | 37 | - name: Retrieve Cargo's Index - Save to Cache 38 | if: steps.cargo_index_cache.outputs.cache-hit != 'true' 39 | uses: actions/cache/save@v3 40 | with: 41 | path: ~/.cargo 42 | key: ${{ runner.os }}-cargo-index-${{ steps.cargo_toml_hash.outputs.hash }} 43 | 44 | 45 | - name: Run tests 46 | run: cargo test --verbose 47 | -------------------------------------------------------------------------------- /test_xml_files/def_namespace.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Convert number to string 5 | Examp1.EXE 6 | 1 7 | One 8 | 9 | 10 | Find succeeding characters 11 | Examp2.EXE 12 | abc 13 | def 14 | 15 | 16 | Convert multiple numbers to strings 17 | Examp2.EXE /Verbose 18 | 123 19 | One Two Three 20 | 21 | 22 | Find correlated key 23 | Examp3.EXE 24 | a1 25 | b1 26 | 27 | 28 | Count characters 29 | FinalExamp.EXE 30 | This is a test 31 | 14 32 | 33 | 34 | Another Test 35 | Examp2.EXE 36 | Test Input 37 | 10 38 | 39 | -------------------------------------------------------------------------------- /test_xml_files/def_namespace.json: -------------------------------------------------------------------------------- 1 | { 2 | "Tests": { 3 | "Test": [ 4 | { 5 | "CommandLine": "Examp1.EXE", 6 | "Input": 1, 7 | "Name": "Convert number to string", 8 | "Output": "One", 9 | "TestId": "0001", 10 | "TestType": "CMD" 11 | }, 12 | { 13 | "CommandLine": "Examp2.EXE", 14 | "Input": "abc", 15 | "Name": "Find succeeding characters", 16 | "Output": "def", 17 | "TestId": "0002", 18 | "TestType": "CMD" 19 | }, 20 | { 21 | "CommandLine": "Examp2.EXE /Verbose", 22 | "Input": 123, 23 | "Name": "Convert multiple numbers to strings", 24 | "Output": "One Two Three", 25 | "TestId": "0003", 26 | "TestType": "GUI" 27 | }, 28 | { 29 | "CommandLine": "Examp3.EXE", 30 | "Input": "a1", 31 | "Name": "Find correlated key", 32 | "Output": "b1", 33 | "TestId": "0004", 34 | "TestType": "GUI" 35 | }, 36 | { 37 | "CommandLine": "FinalExamp.EXE", 38 | "Input": "This is a test", 39 | "Name": "Count characters", 40 | "Output": 14, 41 | "TestId": "0005", 42 | "TestType": "GUI" 43 | }, 44 | { 45 | "CommandLine": "Examp2.EXE", 46 | "Input": "Test Input", 47 | "Name": "Another Test", 48 | "Output": 10, 49 | "TestId": "0006", 50 | "TestType": "GUI" 51 | } 52 | ] 53 | } 54 | } -------------------------------------------------------------------------------- /test_xml_files/xsd.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /test_xml_files/xsd.json: -------------------------------------------------------------------------------- 1 | { 2 | "schema": { 3 | "complexType": [ 4 | { 5 | "attribute": { 6 | "name": "CustomerID", 7 | "type": "xs:token" 8 | }, 9 | "name": "CustomerType", 10 | "sequence": { 11 | "element": [ 12 | { 13 | "name": "CompanyName", 14 | "type": "xs:string" 15 | }, 16 | { 17 | "name": "ContactName", 18 | "type": "xs:string" 19 | }, 20 | { 21 | "name": "ContactTitle", 22 | "type": "xs:string" 23 | }, 24 | { 25 | "name": "Phone", 26 | "type": "xs:string" 27 | }, 28 | { 29 | "minOccurs": 0, 30 | "name": "Fax", 31 | "type": "xs:string" 32 | }, 33 | { 34 | "name": "FullAddress", 35 | "type": "AddressType" 36 | } 37 | ] 38 | } 39 | }, 40 | { 41 | "attribute": { 42 | "name": "CustomerID", 43 | "type": "xs:token" 44 | }, 45 | "name": "AddressType", 46 | "sequence": { 47 | "element": [ 48 | { 49 | "name": "Address", 50 | "type": "xs:string" 51 | }, 52 | { 53 | "name": "City", 54 | "type": "xs:string" 55 | }, 56 | { 57 | "name": "Region", 58 | "type": "xs:string" 59 | }, 60 | { 61 | "name": "PostalCode", 62 | "type": "xs:string" 63 | }, 64 | { 65 | "name": "Country", 66 | "type": "xs:string" 67 | } 68 | ] 69 | } 70 | }, 71 | { 72 | "name": "OrderType", 73 | "sequence": { 74 | "element": [ 75 | { 76 | "name": "CustomerID", 77 | "type": "xs:token" 78 | }, 79 | { 80 | "name": "EmployeeID", 81 | "type": "xs:token" 82 | }, 83 | { 84 | "name": "OrderDate", 85 | "type": "xs:dateTime" 86 | }, 87 | { 88 | "name": "RequiredDate", 89 | "type": "xs:dateTime" 90 | }, 91 | { 92 | "name": "ShipInfo", 93 | "type": "ShipInfoType" 94 | } 95 | ] 96 | } 97 | }, 98 | { 99 | "attribute": { 100 | "name": "ShippedDate", 101 | "type": "xs:dateTime" 102 | }, 103 | "name": "ShipInfoType", 104 | "sequence": { 105 | "element": [ 106 | { 107 | "name": "ShipVia", 108 | "type": "xs:integer" 109 | }, 110 | { 111 | "name": "Freight", 112 | "type": "xs:decimal" 113 | }, 114 | { 115 | "name": "ShipName", 116 | "type": "xs:string" 117 | }, 118 | { 119 | "name": "ShipAddress", 120 | "type": "xs:string" 121 | }, 122 | { 123 | "name": "ShipCity", 124 | "type": "xs:string" 125 | }, 126 | { 127 | "name": "ShipRegion", 128 | "type": "xs:string" 129 | }, 130 | { 131 | "name": "ShipPostalCode", 132 | "type": "xs:string" 133 | }, 134 | { 135 | "name": "ShipCountry", 136 | "type": "xs:string" 137 | } 138 | ] 139 | } 140 | } 141 | ], 142 | "element": { 143 | "complexType": { 144 | "sequence": { 145 | "element": [ 146 | { 147 | "complexType": { 148 | "sequence": { 149 | "element": { 150 | "maxOccurs": "unbounded", 151 | "minOccurs": 0, 152 | "name": "Customer", 153 | "type": "CustomerType" 154 | } 155 | } 156 | }, 157 | "name": "Customers" 158 | }, 159 | { 160 | "complexType": { 161 | "sequence": { 162 | "element": { 163 | "maxOccurs": "unbounded", 164 | "minOccurs": 0, 165 | "name": "Order", 166 | "type": "OrderType" 167 | } 168 | } 169 | }, 170 | "name": "Orders" 171 | } 172 | ] 173 | } 174 | }, 175 | "key": { 176 | "field": { 177 | "xpath": "@CustomerID" 178 | }, 179 | "name": "CustomerIDKey", 180 | "selector": { 181 | "xpath": "Customers/Customer" 182 | } 183 | }, 184 | "keyref": { 185 | "field": { 186 | "xpath": "CustomerID" 187 | }, 188 | "name": "CustomerIDKeyRef", 189 | "refer": "CustomerIDKey", 190 | "selector": { 191 | "xpath": "Orders/Order" 192 | } 193 | }, 194 | "name": "Root" 195 | } 196 | } 197 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # quickxml_to_serde 2 | 3 | Convert XML to JSON using [quick-xml](https://github.com/tafia/quick-xml) and [serde](https://github.com/serde-rs/json). Inspired by [node2object](https://github.com/vorot93/node2object). 4 | 5 | ## Usage examples 6 | 7 | #### Basic 8 | Dependencies: 9 | 10 | ```rust 11 | use std::fs::File; 12 | use std::io::prelude::*; 13 | use quickxml_to_serde::xml_string_to_json; 14 | ``` 15 | Rust code to perform a conversion: 16 | ```rust 17 | // read an XML file into a string 18 | let mut xml_file = File::open("test.xml")?; 19 | let mut xml_contents = String::new(); 20 | xml_file.read_to_string(&mut xml_contents)?; 21 | 22 | // convert the XML string into JSON with default config params 23 | let json = xml_string_to_json(xml_contents, &Config::new_with_defaults()); 24 | 25 | println!("{}", json); 26 | ``` 27 | 28 | #### Custom config 29 | 30 | The following config example changes the default behavior to: 31 | 32 | 1. Treat numbers starting with `0` as strings. E.g. `0001` will be `"0001"` 33 | 2. Do not prefix JSON properties created from attributes 34 | 3. Use `text` as the JSON property name for values of XML text nodes where the text is mixed with other nodes 35 | 4. Exclude empty elements from the output 36 | 37 | ```rust 38 | let conf = Config::new_with_custom_values(true, "", "text", NullValue::Ignore); 39 | ``` 40 | 41 | ## Enforcing JSON types 42 | 43 | ### Matching based on absolute path or regex 44 | 45 | You can override the type of absolute paths within the XML 46 | 47 | ``` rust 48 | let config = Config::new_with_defaults() 49 | .add_json_type_override("/a/b", JsonArray::Always(JsonType::AlwaysString)); 50 | ``` 51 | 52 | Or you can match based on a regex! 53 | 54 | ``` rust 55 | let config = Config::new_with_defaults() 56 | .add_json_type_override( 57 | Regex::new(r"element").unwrap(), 58 | JsonArray::Always(JsonType::Infer) 59 | ); 60 | ``` 61 | 62 | #### Strings 63 | 64 | The default for this library is to attempt to infer scalar data types, which can be `int`, `float`, `bool` or `string` in JSON. Sometimes it is not desirable like in the example below. Let's assume that attribute `id` is always numeric and can be safely converted to JSON integer. 65 | The `card_number` element looks like a number for the first two users and is a string for the third one. This inconsistency in JSON typing makes it 66 | difficult to deserialize the structure, so we may be better off telling the converter to use a particular JSON data type for some XML nodes. 67 | ```xml 68 | 69 | 70 | Andrew 71 | 000156 72 | 73 | 74 | John 75 | 100263 76 | 77 | 78 | Mary 79 | 100263a 80 | 81 | 82 | ``` 83 | 84 | Use `quickxml_to_serde = { version = "0.4", features = ["json_types"] }` feature in your *Cargo.toml* file to enable support for enforcing JSON types for some XML nodes using xPath-like notations. 85 | 86 | Sample XML document: 87 | ```xml 88 | true 89 | ``` 90 | Configuration to make attribute `attr1="007"` always come out as a JSON string: 91 | ```rust 92 | let conf = Config::new_with_defaults().add_json_type_override("/a/@attr1", JsonArray::Infer(JsonType::AlwaysString)); 93 | ``` 94 | Configuration to make both attributes and the text node of `` always come out as a JSON string: 95 | ```rust 96 | let conf = Config::new_with_defaults() 97 | .add_json_type_override("/a/@attr1", JsonArray::Infer(JsonType::AlwaysString)) 98 | .add_json_type_override("/a/b/@attr1", JsonArray::Infer(JsonType::AlwaysString)) 99 | .add_json_type_override("/a/b", JsonArray::Infer(JsonType::AlwaysString)); 100 | ``` 101 | 102 | #### Boolean 103 | 104 | The only two [valid boolean values in JSON](https://json-schema.org/understanding-json-schema/reference/boolean.html#boolean) are `true` and `false`. On the other hand, values such as `True`, `False`,`1` and `0` are common in programming languages and data formats. Use `JsonType::Bool(...)` type with the list of "true" values to convert arbitrary boolean values into JSON bool. 105 | 106 | ```rust 107 | let conf = Config::new_with_defaults() 108 | .add_json_type_override("/a/b", JsonArray::Infer(JsonType::Bool(vec!["True","true","1","yes"]))); 109 | ``` 110 | 111 | #### Arrays 112 | 113 | Multiple nodes with the same name are automatically converted into a JSON array. For example, 114 | ```xml 115 | 116 | 1 117 | 2 118 | 119 | ``` 120 | is converted into 121 | ```json 122 | { "a": 123 | { "b": [1,2] } 124 | } 125 | ``` 126 | By default, a single element like 127 | ```xml 128 | 129 | 1 130 | 131 | ``` 132 | is converted into a scalar value or a map 133 | ```json 134 | { "a": 135 | { "b": 1 } 136 | } 137 | ``` 138 | 139 | You can use `add_json_type_override()` with `JsonArray::Always()` to create a JSON array regardless of the number of elements so that `1` becomes `{ "a": { "b": [1] } }`. 140 | 141 | `JsonArray::Always()` and `JsonArray::Infer()` can specify what underlying JSON type should be used, e.g. 142 | * `JsonArray::Infer(JsonType::AlwaysString)` - infer array, convert the values to JSON string 143 | * `JsonArray::Always(JsonType::Infer)` - always wrap the values in a JSON array, infer the value types 144 | * `JsonArray::Always(JsonType::AlwaysString)` - always wrap the values in a JSON array and convert values to JSON string 145 | 146 | ```rust 147 | let config = Config::new_with_defaults() 148 | .add_json_type_override("/a/b", JsonArray::Always(JsonType::AlwaysString)); 149 | ``` 150 | 151 | Conversion of empty XML nodes like `` depends on `NullValue` setting. For example, 152 | ```rust 153 | let config = Config::new_with_custom_values(false, "@", "#text", NullValue::Ignore) 154 | .add_json_type_override("/a/b", JsonArray::Always(JsonType::Infer)); 155 | ``` 156 | converts `` to 157 | ```json 158 | {"a": null} 159 | ``` 160 | and the same `config` with `NullValue::Null` converts it to 161 | 162 | ```json 163 | {"a": { "b": [null] }} 164 | ``` 165 | 166 | It is not possible to get an empty array like `{"a": { "b": [] }}`. 167 | 168 | ---- 169 | 170 | *See embedded docs for `Config` struct and its members for more details.* 171 | 172 | ## Conversion specifics 173 | 174 | - The order of XML elements is not preserved 175 | - Namespace identifiers are dropped. E.g. `123` becomes `{ "a":123 }` 176 | - Integers and floats are converted into JSON integers and floats, unless the JSON type is specified in `Config`. 177 | - XML attributes become JSON properties at the same level as child elements. E.g. 178 | ```xml 179 | 180 | 1 181 | 182 | ``` 183 | is converted into 184 | ```json 185 | "Test": 186 | { 187 | "Input": 1, 188 | "TestId": "0001" 189 | } 190 | ``` 191 | - XML prolog is dropped. E.g. ``. 192 | - XML namespace definitions are dropped. E.g. `` becomes `"Tests":{}` 193 | - Processing instructions, comments and DTD are ignored 194 | - **Presence of CDATA in the XML results in malformed JSON** 195 | - XML attributes can be prefixed via `Config::xml_attr_prefix`. E.g. using the default prefix `@` converts `` into `{ "a": {"@b":"y"} }`. You can use no prefix or set your own value. 196 | - Complex XML elements with text nodes put the XML text node value into a JSON property named in `Config::xml_text_node_prop_name`. E.g. setting `xml_text_node_prop_name` to `text` will convert 197 | ```xml 198 | 1234567 199 | ``` 200 | into 201 | ```json 202 | { 203 | "CardNumber": { 204 | "Month": 3, 205 | "Year": 19, 206 | "text": 1234567 207 | } 208 | } 209 | ``` 210 | - Elements with identical names are collected into arrays. E.g. 211 | ```xml 212 | 213 | 7.25 214 | 215 | A 216 | 3 217 | 24.50 218 | 219 | 220 | B 221 | 1 222 | 89.99 223 | 224 | 225 | ``` 226 | is converted into 227 | ```json 228 | { 229 | "Root": { 230 | "Data": [ 231 | { 232 | "Category": "A", 233 | "Price": 24.5, 234 | "Quantity": 3 235 | }, 236 | { 237 | "Category": "B", 238 | "Price": 89.99, 239 | "Quantity": 1 240 | } 241 | ], 242 | "TaxRate": 7.25 243 | } 244 | } 245 | ``` 246 | - If `TaxRate` element from the above example was inserted between `Data` elements it would still produce the same JSON with all `Data` properties grouped into a single array. 247 | 248 | #### Additional info and examples 249 | 250 | See [tests.rs](src/tests.rs) for more usage examples. 251 | 252 | ## Edge cases 253 | 254 | XML and JSON are not directly compatible for 1:1 conversion without additional hints to the converter. Please, post an issue if you come across any incorrect conversion. 255 | -------------------------------------------------------------------------------- /src/tests.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use serde_json::{json, to_string_pretty}; 3 | use std::fs::File; 4 | use std::io::prelude::*; 5 | 6 | #[test] 7 | fn test_numbers() { 8 | let expected = json!({ 9 | "a": { 10 | "b":[ 12345, 12345.0, 12345.6 ] 11 | } 12 | }); 13 | let result = xml_string_to_json( 14 | String::from("1234512345.012345.6"), 15 | &Config::new_with_defaults(), 16 | ); 17 | 18 | assert_eq!(expected, result.unwrap()); 19 | } 20 | 21 | #[test] 22 | fn test_empty_elements_valid() { 23 | let mut conf = Config::new_with_custom_values(true, "", "text", NullValue::EmptyObject); 24 | let xml = r#""#; 25 | 26 | let expected = json!({ "a": {"b":1, "x":{}} }); 27 | let result = xml_string_to_json(xml.to_owned(), &conf); 28 | assert_eq!(expected, result.unwrap()); 29 | 30 | conf.empty_element_handling = NullValue::Null; 31 | let expected = json!({ "a": {"b":1, "x":null} }); 32 | let result = xml_string_to_json(xml.to_owned(), &conf); 33 | assert_eq!(expected, result.unwrap()); 34 | 35 | conf.empty_element_handling = NullValue::Ignore; 36 | let expected = json!({ "a": {"b":1} }); 37 | let result = xml_string_to_json(xml.to_owned(), &conf); 38 | assert_eq!(expected, result.unwrap()); 39 | } 40 | 41 | #[test] 42 | fn test_empty_elements_invalid() { 43 | let conf = Config::new_with_custom_values(true, "", "text", NullValue::Ignore); 44 | let expected = json!({ "a": null }); 45 | 46 | let xml = r#""#; 47 | let result = xml_string_to_json(xml.to_owned(), &conf); 48 | assert_eq!(expected, result.unwrap()); 49 | 50 | let xml = r#""#; 51 | let result = xml_string_to_json(xml.to_owned(), &conf); 52 | assert_eq!(expected, result.unwrap()); 53 | } 54 | 55 | #[test] 56 | fn test_mixed_nodes() { 57 | let xml = r#"some text"#; 58 | 59 | // test with default config values 60 | let expected_1 = json!({ 61 | "a": { 62 | "@attr1":"val1", 63 | "#text":"some text" 64 | } 65 | }); 66 | let result_1 = xml_string_to_json(String::from(xml), &Config::new_with_defaults()); 67 | assert_eq!(expected_1, result_1.unwrap()); 68 | 69 | // test with custom config values 70 | let expected_2 = json!({ 71 | "a": { 72 | "attr1":"val1", 73 | "text":"some text" 74 | } 75 | }); 76 | let conf = Config::new_with_custom_values(true, "", "text", NullValue::Null); 77 | let result_2 = xml_string_to_json(String::from(xml), &conf); 78 | assert_eq!(expected_2, result_2.unwrap()); 79 | 80 | // try the same on XML where the attr and children have a name clash 81 | let xml = r#"some text"#; 82 | let expected_3 = json!({"a":{"attr1":["val1",{"nested":"some text"}]}}); 83 | 84 | let result_3 = xml_string_to_json(String::from(xml), &conf); 85 | assert_eq!(expected_3, result_3.unwrap()); 86 | } 87 | 88 | #[cfg(feature = "json_types")] 89 | #[test] 90 | fn test_add_json_type_override() { 91 | // check if it adds the leading slash 92 | let config = Config::new_with_defaults() 93 | .add_json_type_override("a/@attr1", JsonArray::Infer(JsonType::AlwaysString)); 94 | assert!(config.json_type_overrides.get("/a/@attr1").is_some()); 95 | 96 | // check if it doesn't add any extra slashes 97 | let config = Config::new_with_defaults() 98 | .add_json_type_override("/a/@attr1", JsonArray::Infer(JsonType::AlwaysString)); 99 | assert!(config.json_type_overrides.get("/a/@attr1").is_some()); 100 | } 101 | 102 | #[cfg(feature = "json_types")] 103 | #[test] 104 | fn test_json_type_overrides() { 105 | let xml = r#"true"#; 106 | 107 | // test with default config values 108 | let expected = json!({ 109 | "a": { 110 | "@attr1":7, 111 | "b": { 112 | "@attr1":7, 113 | "@attr2":"True", 114 | "#text":true 115 | } 116 | } 117 | }); 118 | let config = Config::new_with_defaults(); 119 | let result = xml_string_to_json(String::from(xml), &config); 120 | assert_eq!(expected, result.unwrap()); 121 | 122 | // test with custom config values for 1 attribute 123 | let expected = json!({ 124 | "a": { 125 | "@attr1":"007", 126 | "b": { 127 | "@attr1":7, 128 | "@attr2":"True", 129 | "#text":true 130 | } 131 | } 132 | }); 133 | let conf = Config::new_with_defaults() 134 | .add_json_type_override("/a/@attr1", JsonArray::Infer(JsonType::AlwaysString)); 135 | let result = xml_string_to_json(String::from(xml), &conf); 136 | assert_eq!(expected, result.unwrap()); 137 | 138 | // test with custom config values for 3 attributes 139 | let expected = json!({ 140 | "a": { 141 | "@attr1":"007", 142 | "b": { 143 | "@attr1":"7", 144 | "@attr2":true, 145 | "#text":true 146 | } 147 | } 148 | }); 149 | let conf = Config::new_with_defaults() 150 | .add_json_type_override("/a/@attr1", JsonArray::Infer(JsonType::AlwaysString)) 151 | .add_json_type_override("/a/b/@attr1", JsonArray::Infer(JsonType::AlwaysString)) 152 | .add_json_type_override( 153 | "/a/b/@attr2", 154 | JsonArray::Infer(JsonType::Bool(vec!["True"])), 155 | ); 156 | let result = xml_string_to_json(String::from(xml), &conf); 157 | assert_eq!(expected, result.unwrap()); 158 | 159 | // test with custom config values for 2 attributes and a text node 160 | let expected = json!({ 161 | "a": { 162 | "@attr1":"007", 163 | "b": { 164 | "@attr1":"7", 165 | "@attr2":"True", 166 | "#text":"true" 167 | } 168 | } 169 | }); 170 | let conf = Config::new_with_defaults() 171 | .add_json_type_override("/a/@attr1", JsonArray::Infer(JsonType::AlwaysString)) 172 | .add_json_type_override("/a/b/@attr1", JsonArray::Infer(JsonType::AlwaysString)) 173 | .add_json_type_override("/a/b", JsonArray::Infer(JsonType::AlwaysString)); 174 | let result = xml_string_to_json(String::from(xml), &conf); 175 | assert_eq!(expected, result.unwrap()); 176 | } 177 | 178 | #[cfg(feature = "json_types")] 179 | #[test] 180 | fn test_enforce_array() { 181 | // test an array with default config values 182 | let xml = r#"12"#; 183 | let expected = json!({ 184 | "a": { 185 | "@attr1":"att1", 186 | "b": [{ "@c":"att", "#text":1 }, { "@c":"att", "#text":2 }] 187 | } 188 | }); 189 | let config = Config::new_with_defaults(); 190 | let result = xml_string_to_json(String::from(xml), &config); 191 | assert_eq!(expected, result.unwrap()); 192 | 193 | // test a non-array with default config values 194 | let xml = r#"1"#; 195 | let expected = json!({ 196 | "a": { 197 | "@attr1":"att1", 198 | "b": { "@c":"att", "#text":1 } 199 | } 200 | }); 201 | let result = xml_string_to_json(String::from(xml), &config); 202 | assert_eq!(expected, result.unwrap()); 203 | 204 | // test a non-array with array enforcement (as object) 205 | let xml = r#"1"#; 206 | let expected = json!({ 207 | "a": { 208 | "@attr1":"att1", 209 | "b": [{ "@c":"att", "#text":1 }] 210 | } 211 | }); 212 | let config = Config::new_with_defaults() 213 | .add_json_type_override("/a/b", JsonArray::Always(JsonType::Infer)); 214 | let result = xml_string_to_json(String::from(xml), &config); 215 | assert_eq!(expected, result.unwrap()); 216 | 217 | // test a non-array with array enforcement (as value) 218 | let xml = r#"1"#; 219 | let expected = json!({ 220 | "a": { 221 | "b": [1] 222 | } 223 | }); 224 | let config = Config::new_with_defaults() 225 | .add_json_type_override("/a/b", JsonArray::Always(JsonType::Infer)); 226 | let result = xml_string_to_json(String::from(xml), &config); 227 | assert_eq!(expected, result.unwrap()); 228 | 229 | // test an array with array enforcement (as value) 230 | let xml = r#"12"#; 231 | let expected = json!({ 232 | "a": { 233 | "b": [1,2] 234 | } 235 | }); 236 | let config = Config::new_with_defaults() 237 | .add_json_type_override("/a/b", JsonArray::Always(JsonType::Infer)); 238 | let result = xml_string_to_json(String::from(xml), &config); 239 | assert_eq!(expected, result.unwrap()); 240 | 241 | // test a non-array with array enforcement + type enforcement (as value) 242 | let xml = r#"1"#; 243 | let expected = json!({ 244 | "a": { 245 | "b": ["1"] 246 | } 247 | }); 248 | let config = Config::new_with_defaults() 249 | .add_json_type_override("/a/b", JsonArray::Always(JsonType::AlwaysString)); 250 | let result = xml_string_to_json(String::from(xml), &config); 251 | assert_eq!(expected, result.unwrap()); 252 | 253 | // test an array with array enforcement + type enforcement (as value) 254 | let xml = r#"12"#; 255 | let expected = json!({ 256 | "a": { 257 | "b": ["1","2"] 258 | } 259 | }); 260 | let config = Config::new_with_defaults() 261 | .add_json_type_override("/a/b", JsonArray::Always(JsonType::AlwaysString)); 262 | let result = xml_string_to_json(String::from(xml), &config); 263 | assert_eq!(expected, result.unwrap()); 264 | 265 | // test an array with array enforcement + null values 266 | let xml = r#""#; 267 | let expected = json!({ 268 | "a": { 269 | "b": [null] 270 | } 271 | }); 272 | let config = Config::new_with_custom_values(false, "@", "#text", NullValue::Null) 273 | .add_json_type_override("/a/b", JsonArray::Always(JsonType::Infer)); 274 | let result = xml_string_to_json(String::from(xml), &config); 275 | assert_eq!(expected, result.unwrap()); 276 | } 277 | 278 | #[test] 279 | fn test_malformed_xml() { 280 | let xml = r#"some text"#; 281 | 282 | let result_1 = xml_string_to_json(String::from(xml), &Config::new_with_defaults()); 283 | assert!(result_1.is_err()); 284 | } 285 | 286 | #[test] 287 | fn test_parse_text() { 288 | assert_eq!(0.0, parse_text("0.0", false, &JsonType::Infer)); 289 | assert_eq!(0, parse_text("0", false, &JsonType::Infer)); 290 | assert_eq!(0, parse_text("0000", false, &JsonType::Infer)); 291 | assert_eq!(0, parse_text("0", true, &JsonType::Infer)); 292 | assert_eq!("0000", parse_text("0000", true, &JsonType::Infer)); 293 | assert_eq!(0.42, parse_text("0.4200", false, &JsonType::Infer)); 294 | assert_eq!(142.42, parse_text("142.4200", false, &JsonType::Infer)); 295 | assert_eq!("0xAC", parse_text("0xAC", true, &JsonType::Infer)); 296 | assert_eq!("0x03", parse_text("0x03", true, &JsonType::Infer)); 297 | assert_eq!("142,4200", parse_text("142,4200", true, &JsonType::Infer)); 298 | assert_eq!("142,420,0", parse_text("142,420,0", true, &JsonType::Infer)); 299 | assert_eq!( 300 | "142,420,0.0", 301 | parse_text("142,420,0.0", true, &JsonType::Infer) 302 | ); 303 | assert_eq!("0Test", parse_text("0Test", true, &JsonType::Infer)); 304 | assert_eq!("0.Test", parse_text("0.Test", true, &JsonType::Infer)); 305 | assert_eq!("0.22Test", parse_text("0.22Test", true, &JsonType::Infer)); 306 | assert_eq!("0044951", parse_text("0044951", true, &JsonType::Infer)); 307 | assert_eq!(1, parse_text("1", true, &JsonType::Infer)); 308 | assert_eq!(false, parse_text("false", false, &JsonType::Infer)); 309 | assert_eq!(true, parse_text("true", true, &JsonType::Infer)); 310 | assert_eq!("True", parse_text("True", true, &JsonType::Infer)); 311 | 312 | // always enforce JSON bool type 313 | #[cfg(feature = "json_types")] 314 | { 315 | let bool_type = JsonType::Bool(vec!["true", "True", "", "1"]); 316 | assert_eq!(false, parse_text("false", false, &bool_type)); 317 | assert_eq!(true, parse_text("true", false, &bool_type)); 318 | assert_eq!(true, parse_text("True", false, &bool_type)); 319 | assert_eq!(false, parse_text("TRUE", false, &bool_type)); 320 | assert_eq!(true, parse_text("", false, &bool_type)); 321 | assert_eq!(true, parse_text("1", false, &bool_type)); 322 | assert_eq!(false, parse_text("0", false, &bool_type)); 323 | // this is an interesting quirk of &str comparison 324 | // any whitespace value == "", at least for Vec::contains() fn 325 | assert_eq!(true, parse_text(" ", false, &bool_type)); 326 | } 327 | 328 | // always enforce JSON string type 329 | assert_eq!("abc", parse_text("abc", false, &JsonType::AlwaysString)); 330 | assert_eq!("true", parse_text("true", false, &JsonType::AlwaysString)); 331 | assert_eq!("123", parse_text("123", false, &JsonType::AlwaysString)); 332 | assert_eq!("0123", parse_text("0123", false, &JsonType::AlwaysString)); 333 | assert_eq!( 334 | "0.4200", 335 | parse_text("0.4200", false, &JsonType::AlwaysString) 336 | ); 337 | } 338 | 339 | /// A shortcut for testing the conversion using XML files. 340 | /// Place your XML files in `./test_xml_files` directory and run `cargo test`. 341 | /// They will be converted into JSON and saved in the saved directory. 342 | #[test] 343 | fn convert_test_files() { 344 | // get the list of files in the text directory 345 | let mut entries = std::fs::read_dir("./test_xml_files") 346 | .unwrap() 347 | .map(|res| res.map(|e| e.path())) 348 | .collect::, std::io::Error>>() 349 | .unwrap(); 350 | 351 | entries.sort(); 352 | 353 | let conf = Config::new_with_custom_values(true, "", "text", NullValue::Null); 354 | 355 | for mut entry in entries { 356 | // only XML files should be processed 357 | if entry.extension().unwrap() != "xml" { 358 | continue; 359 | } 360 | 361 | // read the XML file 362 | let mut file = File::open(&entry).unwrap(); 363 | let mut xml_contents = String::new(); 364 | file.read_to_string(&mut xml_contents).unwrap(); 365 | 366 | // convert to json 367 | let json = xml_string_to_json(xml_contents, &conf).unwrap(); 368 | 369 | // save as json 370 | entry.set_extension("json"); 371 | let mut file = File::create(&entry).unwrap(); 372 | assert!( 373 | file.write_all(to_string_pretty(&json).unwrap().as_bytes()) 374 | .is_ok(), 375 | format!("Failed on {:?}", entry.as_os_str()) 376 | ); 377 | } 378 | } 379 | 380 | #[test] 381 | fn test_xml_str_to_json() { 382 | let expected = json!({ 383 | "a": { 384 | "b":[ 12345, 12345.0, 12345.6 ] 385 | } 386 | }); 387 | let result = xml_str_to_json( 388 | "1234512345.012345.6", 389 | &Config::new_with_defaults(), 390 | ); 391 | 392 | assert_eq!(expected, result.unwrap()); 393 | } 394 | 395 | #[cfg(feature = "regex_path")] 396 | #[test] 397 | fn test_regex_json_type_overrides() { 398 | use regex::Regex; 399 | 400 | // test a non-array with array enforcement (as object). 401 | let xml = r#"1"#; 402 | let expected = json!({ 403 | "a": { 404 | "@attr1":"att1", 405 | "b": [{ "@c":"att", "#text":1 }] 406 | } 407 | }); 408 | 409 | let config = Config::new_with_defaults() 410 | .add_json_type_override( 411 | Regex::new(r"\w/b").unwrap(), 412 | JsonArray::Always(JsonType::Infer 413 | ) 414 | ); 415 | 416 | let result = xml_string_to_json(String::from(xml), &config); 417 | assert_eq!(expected, result.unwrap()); 418 | 419 | // test a multiple elements of the same tag nested in different elements 420 | let xml = r#" 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | "#; 432 | 433 | let expected = json!({ 434 | "a": { 435 | "@attr1": "att1", 436 | "element": [ 437 | { "@name": "el1" }, 438 | { "@name": "el2" } 439 | ], 440 | "b": { 441 | "@attr2": "att2", 442 | "element": [ 443 | { "@name": "el3" } 444 | ], 445 | "c": { 446 | "@attr3": "att3", 447 | "element": [ 448 | { "@name": "el4" } 449 | ] 450 | } 451 | }, 452 | } 453 | }); 454 | 455 | let config = Config::new_with_defaults() 456 | .add_json_type_override( 457 | Regex::new(r"element").unwrap(), 458 | JsonArray::Always(JsonType::Infer) 459 | ); 460 | 461 | let result = xml_string_to_json(String::from(xml), &config); 462 | assert_eq!(expected, result.unwrap()); 463 | 464 | } -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::items_after_test_module)] 2 | #![allow(clippy::single_match)] 3 | #![allow(clippy::single_char_pattern)] 4 | #![allow(clippy::needless_borrow)] 5 | #![allow(clippy::ptr_arg)] 6 | //! # quickxml_to_serde 7 | //! Fast and flexible conversion from XML to JSON using [quick-xml](https://github.com/tafia/quick-xml) 8 | //! and [serde](https://github.com/serde-rs/json). Inspired by [node2object](https://github.com/vorot93/node2object). 9 | //! 10 | //! This crate converts XML elements, attributes and text nodes directly into corresponding JSON structures. 11 | //! Some common usage scenarios would be converting XML into JSON for loading into No-SQL databases 12 | //! or sending it to the front end application. 13 | //! 14 | //! Because of the richness and flexibility of XML some conversion behavior is configurable: 15 | //! - attribute name prefixes 16 | //! - naming of text nodes 17 | //! - number format conversion 18 | //! 19 | //! ## Usage example 20 | //! ``` 21 | //! extern crate quickxml_to_serde; 22 | //! use quickxml_to_serde::{xml_string_to_json, Config, NullValue}; 23 | //! 24 | //! fn main() { 25 | //! let xml = r#"some text"#; 26 | //! let conf = Config::new_with_defaults(); 27 | //! let json = xml_string_to_json(xml.to_owned(), &conf); 28 | //! println!("{}", json.expect("Malformed XML").to_string()); 29 | //! 30 | //! let conf = Config::new_with_custom_values(true, "", "txt", NullValue::Null); 31 | //! let json = xml_string_to_json(xml.to_owned(), &conf); 32 | //! println!("{}", json.expect("Malformed XML").to_string()); 33 | //! } 34 | //! ``` 35 | //! * **Output with the default config:** `{"a":{"@attr1":1,"b":{"c":{"#text":"some text","@attr2":1}}}}` 36 | //! * **Output with a custom config:** `{"a":{"attr1":1,"b":{"c":{"attr2":"001","txt":"some text"}}}}` 37 | //! 38 | //! ## Additional features 39 | //! Use `quickxml_to_serde = { version = "0.4", features = ["json_types"] }` to enable support for enforcing JSON types 40 | //! for some XML nodes using xPath-like notations. Example for enforcing attribute `attr2` from the snippet above 41 | //! as JSON String regardless of its contents: 42 | //! ``` 43 | //! use quickxml_to_serde::{Config, JsonArray, JsonType}; 44 | //! 45 | //! #[cfg(feature = "json_types")] 46 | //! let conf = Config::new_with_defaults() 47 | //! .add_json_type_override("/a/b/c/@attr2", JsonArray::Infer(JsonType::AlwaysString)); 48 | //! ``` 49 | //! 50 | //! ## Detailed documentation 51 | //! See [README](https://github.com/AlecTroemel/quickxml_to_serde) in the source repo for more examples, limitations and detailed behavior description. 52 | //! 53 | //! ## Testing your XML files 54 | //! 55 | //! If you want to see how your XML files are converted into JSON, place them into `./test_xml_files` directory 56 | //! and run `cargo test`. They will be converted into JSON and saved in the saved directory. 57 | 58 | extern crate minidom; 59 | extern crate serde_json; 60 | 61 | #[cfg(feature = "regex_path")] 62 | extern crate regex; 63 | 64 | use minidom::{Element, Error}; 65 | use serde_json::{Map, Number, Value}; 66 | #[cfg(feature = "json_types")] 67 | use std::collections::HashMap; 68 | use std::str::FromStr; 69 | 70 | #[cfg(feature = "regex_path")] 71 | use regex::Regex; 72 | 73 | #[cfg(test)] 74 | mod tests; 75 | 76 | /// Defines how empty elements like `` should be handled. 77 | /// `Ignore` -> exclude from JSON, `Null` -> `"x":null`, EmptyObject -> `"x":{}`. 78 | /// `EmptyObject` is the default option and is how it was handled prior to v.0.4 79 | /// Using `Ignore` on an XML document with an empty root element falls back to `Null` option. 80 | /// E.g. both `` and `` are converted into `{"a":null}`. 81 | #[derive(Debug)] 82 | pub enum NullValue { 83 | Ignore, 84 | Null, 85 | EmptyObject, 86 | } 87 | 88 | /// Defines how the values of this Node should be converted into a JSON array with the underlying types. 89 | /// * `Infer` - the nodes are converted into a JSON array only if there are multiple identical elements. 90 | /// E.g. `1` becomes a map `{"a": {"b": 1 }}` and `123` becomes 91 | /// an array `{"a": {"b": [1, 2, 3] }}` 92 | /// * `Always` - the nodes are converted into a JSON array regardless of how many there are. 93 | /// E.g. `1` becomes an array with a single value `{"a": {"b": [1] }}` and 94 | /// `123` also becomes an array `{"a": {"b": [1, 2, 3] }}` 95 | #[derive(Debug)] 96 | pub enum JsonArray { 97 | /// Convert the nodes into a JSON array even if there is only one element 98 | Always(JsonType), 99 | /// Convert the nodes into a JSON array only if there are multiple identical elements 100 | Infer(JsonType), 101 | } 102 | 103 | /// Used as a parameter for `Config.add_json_type_override`. Defines how the XML path should be matched 104 | /// in order to apply the JSON type overriding rules. This enumerator exists to allow the same function 105 | /// to be used for multiple different types of path matching rules. 106 | #[derive(Debug)] 107 | pub enum PathMatcher { 108 | /// An absolute path starting with a leading slash (`/`). E.g. `/a/b/c/@d`. 109 | /// It's implicitly converted from `&str` and automatically includes the leading slash. 110 | Absolute(String), 111 | /// A regex that will be checked against the XML path. E.g. `(\w/)*c$`. 112 | /// It's implicitly converted from `regex::Regex`. 113 | #[cfg(feature = "regex_path")] 114 | Regex(Regex), 115 | } 116 | 117 | // For retro-compatibility and for syntax's sake, a string may be coerced into an absolute path. 118 | impl From<&str> for PathMatcher { 119 | fn from(value: &str) -> Self { 120 | let path_with_leading_slash = if value.starts_with("/") { 121 | value.into() 122 | } else { 123 | ["/", value].concat() 124 | }; 125 | 126 | PathMatcher::Absolute(path_with_leading_slash) 127 | } 128 | } 129 | 130 | // ... While a Regex may be coerced into a regex path. 131 | #[cfg(feature = "regex_path")] 132 | impl From for PathMatcher { 133 | fn from(value: Regex) -> Self { 134 | PathMatcher::Regex(value) 135 | } 136 | } 137 | 138 | /// Defines which data type to apply in JSON format for consistency of output. 139 | /// E.g., the range of XML values for the same node type may be `1234`, `001234`, `AB1234`. 140 | /// It is impossible to guess with 100% consistency which data type to apply without seeing 141 | /// the entire range of values. Use this enum to tell the converter which data type should 142 | /// be applied. 143 | #[derive(Debug, PartialEq, Clone)] 144 | pub enum JsonType { 145 | /// Do not try to infer the type and convert the value to JSON string. 146 | /// E.g. convert `1234` into `{"a":"1234"}` or `true` into `{"a":"true"}` 147 | AlwaysString, 148 | /// Convert values included in this member into JSON bool `true` and any other value into `false`. 149 | /// E.g. `Bool(vec!["True", "true", "TRUE"]) will result in any of these values to become JSON bool `true`. 150 | Bool(Vec<&'static str>), 151 | /// Attempt to infer the type by looking at the single value of the node being converted. 152 | /// Not guaranteed to be consistent across multiple nodes. 153 | /// E.g. convert `1234` and `001234` into `{"a":1234}`, or `true` into `{"a":true}` 154 | /// Check if your values comply with JSON data types (case, range, format) to produce the expected result. 155 | Infer, 156 | } 157 | 158 | /// Tells the converter how to perform certain conversions. 159 | /// See docs for individual fields for more info. 160 | #[derive(Debug)] 161 | pub struct Config { 162 | /// Numeric values starting with 0 will be treated as strings. 163 | /// E.g. convert `007` into `"agent":"007"` or `"agent":7` 164 | /// Defaults to `false`. 165 | pub leading_zero_as_string: bool, 166 | /// Prefix XML attribute names with this value to distinguish them from XML elements. 167 | /// E.g. set it to `@` for `` to become `{"x": {"@a":"Hello!"}}` 168 | /// or set it to a blank string for `{"x": {"a":"Hello!"}}` 169 | /// Defaults to `@`. 170 | pub xml_attr_prefix: String, 171 | /// A property name for XML text nodes. 172 | /// E.g. set it to `text` for `Goodbye!` to become `{"x": {"@a":"Hello!", "text":"Goodbye!"}}` 173 | /// XML nodes with text only and no attributes or no child elements are converted into JSON properties with the 174 | /// name of the element. E.g. `Goodbye!` becomes `{"x":"Goodbye!"}` 175 | /// Defaults to `#text` 176 | pub xml_text_node_prop_name: String, 177 | /// Defines how empty elements like `` should be handled. 178 | pub empty_element_handling: NullValue, 179 | /// A map of XML paths with their JsonArray overrides. They take precedence over the document-wide `json_type` 180 | /// property. The path syntax is based on xPath: literal element names and attribute names prefixed with `@`. 181 | /// The path must start with a leading `/`. It is a bit of an inconvenience to remember about it, but it saves 182 | /// an extra `if`-check in the code to improve the performance. 183 | /// # Example 184 | /// - **XML**: `007` 185 | /// - path for `c`: `/a/b/@c` 186 | /// - path for `b` text node (007): `/a/b` 187 | #[cfg(feature = "json_types")] 188 | pub json_type_overrides: HashMap, 189 | /// A list of pairs of regex and JsonArray overrides. They take precedence over both the document-wide `json_type` 190 | /// property and the `json_type_overrides` property. The path syntax is based on xPath just like `json_type_overrides`. 191 | #[cfg(feature = "regex_path")] 192 | pub json_regex_type_overrides: Vec<(Regex, JsonArray)>, 193 | } 194 | 195 | impl Config { 196 | /// Numbers with leading zero will be treated as numbers. 197 | /// Prefix XML Attribute names with `@` 198 | /// Name XML text nodes `#text` for XML Elements with other children 199 | pub fn new_with_defaults() -> Self { 200 | Config { 201 | leading_zero_as_string: false, 202 | xml_attr_prefix: "@".to_owned(), 203 | xml_text_node_prop_name: "#text".to_owned(), 204 | empty_element_handling: NullValue::EmptyObject, 205 | #[cfg(feature = "json_types")] 206 | json_type_overrides: HashMap::new(), 207 | #[cfg(feature = "regex_path")] 208 | json_regex_type_overrides: Vec::new(), 209 | } 210 | } 211 | 212 | /// Create a Config object with non-default values. See the `Config` struct docs for more info. 213 | pub fn new_with_custom_values( 214 | leading_zero_as_string: bool, 215 | xml_attr_prefix: &str, 216 | xml_text_node_prop_name: &str, 217 | empty_element_handling: NullValue, 218 | ) -> Self { 219 | Config { 220 | leading_zero_as_string, 221 | xml_attr_prefix: xml_attr_prefix.to_owned(), 222 | xml_text_node_prop_name: xml_text_node_prop_name.to_owned(), 223 | empty_element_handling, 224 | #[cfg(feature = "json_types")] 225 | json_type_overrides: HashMap::new(), 226 | #[cfg(feature = "regex_path")] 227 | json_regex_type_overrides: Vec::new(), 228 | } 229 | } 230 | 231 | /// Adds a single JSON Type override rule to the current config. 232 | /// # Example 233 | /// - **XML**: `007` 234 | /// - path for `c`: `/a/b/@c` 235 | /// - path for `b` text node (007): `/a/b` 236 | /// - regex path for any `element` node: `(\w/)*element$` [requires `regex_path` feature] 237 | #[cfg(feature = "json_types")] 238 | pub fn add_json_type_override

(self, path: P, json_type: JsonArray) -> Self 239 | where 240 | P: Into 241 | { 242 | let mut conf = self; 243 | 244 | match path.into() { 245 | PathMatcher::Absolute(path) => { 246 | conf.json_type_overrides.insert(path, json_type); 247 | } 248 | #[cfg(feature = "regex_path")] 249 | PathMatcher::Regex(regex) => { 250 | conf.json_regex_type_overrides.push(( 251 | regex, 252 | json_type 253 | )); 254 | } 255 | } 256 | 257 | conf 258 | } 259 | } 260 | 261 | impl Default for Config { 262 | fn default() -> Self { 263 | Config::new_with_defaults() 264 | } 265 | } 266 | 267 | /// Returns the text as one of `serde::Value` types: int, float, bool or string. 268 | fn parse_text(text: &str, leading_zero_as_string: bool, json_type: &JsonType) -> Value { 269 | let text = text.trim(); 270 | 271 | // enforce JSON String data type regardless of the underlying type 272 | if json_type == &JsonType::AlwaysString { 273 | return Value::String(text.into()); 274 | } 275 | 276 | // enforce JSON Bool data type 277 | #[cfg(feature = "json_types")] 278 | if let JsonType::Bool(true_values) = json_type { 279 | if true_values.contains(&text) { 280 | // any values matching the `true` list are bool/true 281 | return Value::Bool(true); 282 | } else { 283 | // anything else is false 284 | return Value::Bool(false); 285 | } 286 | } 287 | 288 | // ints 289 | if let Ok(v) = text.parse::() { 290 | // don't parse octal numbers and those with leading 0 291 | // `text` value "0" will always be converted into number 0, "0000" may be converted 292 | // into 0 or "0000" depending on `leading_zero_as_string` 293 | if leading_zero_as_string && text.starts_with("0") && (v != 0 || text.len() > 1) { 294 | return Value::String(text.into()); 295 | } 296 | return Value::Number(Number::from(v)); 297 | } 298 | 299 | // floats 300 | if let Ok(v) = text.parse::() { 301 | if text.starts_with("0") && !text.starts_with("0.") { 302 | return Value::String(text.into()); 303 | } 304 | if let Some(val) = Number::from_f64(v) { 305 | return Value::Number(val); 306 | } 307 | } 308 | 309 | // booleans 310 | if let Ok(v) = text.parse::() { 311 | return Value::Bool(v); 312 | } 313 | 314 | Value::String(text.into()) 315 | } 316 | 317 | /// Converts an XML Element into a JSON property 318 | fn convert_node(el: &Element, config: &Config, path: &String) -> Option { 319 | // add the current node to the path 320 | #[cfg(feature = "json_types")] 321 | let path = [path, "/", el.name()].concat(); 322 | 323 | // get the json_type for this node 324 | let (_, json_type_value) = get_json_type(config, &path); 325 | 326 | // is it an element with text? 327 | if el.text().trim() != "" { 328 | // process node's attributes, if present 329 | if el.attrs().count() > 0 { 330 | Some(Value::Object( 331 | el.attrs() 332 | .map(|(k, v)| { 333 | // add the current node to the path 334 | #[cfg(feature = "json_types")] 335 | let path = [path.clone(), "/@".to_owned(), k.to_owned()].concat(); 336 | // get the json_type for this node 337 | #[cfg(feature = "json_types")] 338 | let (_, json_type_value) = get_json_type(config, &path); 339 | ( 340 | [config.xml_attr_prefix.clone(), k.to_owned()].concat(), 341 | parse_text(&v, config.leading_zero_as_string, &json_type_value), 342 | ) 343 | }) 344 | .chain(vec![( 345 | config.xml_text_node_prop_name.clone(), 346 | parse_text( 347 | &el.text()[..], 348 | config.leading_zero_as_string, 349 | &json_type_value, 350 | ), 351 | )]) 352 | .collect(), 353 | )) 354 | } else { 355 | Some(parse_text( 356 | &el.text()[..], 357 | config.leading_zero_as_string, 358 | &json_type_value, 359 | )) 360 | } 361 | } else { 362 | // this element has no text, but may have other child nodes 363 | let mut data = Map::new(); 364 | 365 | for (k, v) in el.attrs() { 366 | // add the current node to the path 367 | #[cfg(feature = "json_types")] 368 | let path = [path.clone(), "/@".to_owned(), k.to_owned()].concat(); 369 | // get the json_type for this node 370 | #[cfg(feature = "json_types")] 371 | let (_, json_type_value) = get_json_type(config, &path); 372 | data.insert( 373 | [config.xml_attr_prefix.clone(), k.to_owned()].concat(), 374 | parse_text(&v, config.leading_zero_as_string, &json_type_value), 375 | ); 376 | } 377 | 378 | // process child element recursively 379 | for child in el.children() { 380 | match convert_node(child, config, &path) { 381 | Some(val) => { 382 | let name = &child.name().to_string(); 383 | 384 | #[cfg(feature = "json_types")] 385 | let path = [path.clone(), "/".to_owned(), name.clone()].concat(); 386 | let (json_type_array, _) = get_json_type(config, &path); 387 | // does it have to be an array? 388 | if json_type_array || data.contains_key(name) { 389 | // was this property converted to an array earlier? 390 | if data.get(name).unwrap_or(&Value::Null).is_array() { 391 | // add the new value to an existing array 392 | data.get_mut(name) 393 | .unwrap() 394 | .as_array_mut() 395 | .unwrap() 396 | .push(val); 397 | } else { 398 | // convert the property to an array with the existing and the new values 399 | let new_val = match data.remove(name) { 400 | None => vec![val], 401 | Some(temp) => vec![temp, val], 402 | }; 403 | data.insert(name.clone(), Value::Array(new_val)); 404 | } 405 | } else { 406 | // this is the first time this property is encountered and it doesn't 407 | // have to be an array, so add it as-is 408 | data.insert(name.clone(), val); 409 | } 410 | } 411 | _ => (), 412 | } 413 | } 414 | 415 | // return the JSON object if it's not empty 416 | if !data.is_empty() { 417 | return Some(Value::Object(data)); 418 | } 419 | 420 | // empty objects are treated according to config rules set by the caller 421 | match config.empty_element_handling { 422 | NullValue::Null => Some(Value::Null), 423 | NullValue::EmptyObject => Some(Value::Object(data)), 424 | NullValue::Ignore => None, 425 | } 426 | } 427 | } 428 | 429 | fn xml_to_map(e: &Element, config: &Config) -> Value { 430 | let mut data = Map::new(); 431 | data.insert( 432 | e.name().to_string(), 433 | convert_node(&e, &config, &String::new()).unwrap_or(Value::Null), 434 | ); 435 | Value::Object(data) 436 | } 437 | 438 | /// Converts the given XML string into `serde::Value` using settings from `Config` struct. 439 | pub fn xml_str_to_json(xml: &str, config: &Config) -> Result { 440 | let root = Element::from_str(xml)?; 441 | Ok(xml_to_map(&root, config)) 442 | } 443 | 444 | /// Converts the given XML string into `serde::Value` using settings from `Config` struct. 445 | pub fn xml_string_to_json(xml: String, config: &Config) -> Result { 446 | xml_str_to_json(xml.as_str(), config) 447 | } 448 | 449 | /// Returns a tuple for Array and Value enforcements for the current node or 450 | /// `(false, JsonArray::Infer(JsonType::Infer)` if the current path is not found 451 | /// in the list of paths with custom config. 452 | #[cfg(feature = "json_types")] 453 | #[inline] 454 | fn get_json_type_with_absolute_path<'conf>(config: &'conf Config, path: &String) -> (bool, &'conf JsonType) { 455 | match config 456 | .json_type_overrides 457 | .get(path) 458 | .unwrap_or(&JsonArray::Infer(JsonType::Infer)) 459 | { 460 | JsonArray::Infer(v) => (false, v), 461 | JsonArray::Always(v) => (true, v), 462 | } 463 | } 464 | 465 | /// Simply returns `get_json_type_with_absolute_path` if `regex_path` feature is disabled. 466 | #[cfg(feature = "json_types")] 467 | #[cfg(not(feature = "regex_path"))] 468 | #[inline] 469 | fn get_json_type<'conf>(config: &'conf Config, path: &String) -> (bool, &'conf JsonType) { 470 | get_json_type_with_absolute_path(config, path) 471 | } 472 | 473 | /// Returns a tuple for Array and Value enforcements for the current node. Searches both absolute paths 474 | /// and regex paths, giving precedence to regex paths. Returns `(false, JsonArray::Infer(JsonType::Infer)` 475 | /// if the current path is not found in the list of paths with custom config. 476 | #[cfg(feature = "json_types")] 477 | #[cfg(feature = "regex_path")] 478 | #[inline] 479 | fn get_json_type<'conf>(config: &'conf Config, path: &String) -> (bool, &'conf JsonType) { 480 | for (regex, json_array) in &config.json_regex_type_overrides { 481 | if regex.is_match(path) { 482 | return match json_array { 483 | JsonArray::Infer(v) => (false, v), 484 | JsonArray::Always(v) => (true, v), 485 | }; 486 | } 487 | } 488 | 489 | get_json_type_with_absolute_path(config, path) 490 | } 491 | 492 | /// Always returns `(false, JsonArray::Infer(JsonType::Infer)` if `json_types` feature is not enabled. 493 | #[cfg(not(feature = "json_types"))] 494 | #[inline] 495 | fn get_json_type<'conf>(_config: &'conf Config, _path: &String) -> (bool, &'conf JsonType) { 496 | (false, &JsonType::Infer) 497 | } 498 | --------------------------------------------------------------------------------