Skip to content

Commit

Permalink
Merge pull request #14 from rimutaka/enforce_array
Browse files Browse the repository at this point in the history
Enforce JSON array type for specified XML nodes
  • Loading branch information
rimutaka authored Dec 18, 2020
2 parents 8c578de + 4c9939a commit 5c884d5
Show file tree
Hide file tree
Showing 5 changed files with 258 additions and 52 deletions.
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "quickxml_to_serde"
version = "0.4.2"
version = "0.4.3"
authors = ["Alec Troemel <[email protected]>", "Max Voskob <[email protected]>"]
description = "Convert between XML JSON using quickxml and serde"
repository = "https://github.com/AlecTroemel/quickxml_to_serde"
Expand Down
75 changes: 67 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,14 @@ Sample XML document:
```
Configuration to make attribute `attr1="007"` always come out as a JSON string:
```rust
let conf = Config::new_with_defaults().add_json_type_override("/a/@attr1", JsonType::AlwaysString);
let conf = Config::new_with_defaults().add_json_type_override("/a/@attr1", JsonArray::Infer(JsonType::AlwaysString));
```
Configuration to make both attributes and the text node of `<b />` always come out as a JSON string:
```rust
let conf = Config::new_with_defaults()
.add_json_type_override("/a/@attr1", JsonType::AlwaysString)
.add_json_type_override("/a/b/@attr1", JsonType::AlwaysString)
.add_json_type_override("/a/b", JsonType::AlwaysString);
.add_json_type_override("/a/@attr1", JsonArray::Infer(JsonType::AlwaysString))
.add_json_type_override("/a/b/@attr1", JsonArray::Infer(JsonType::AlwaysString))
.add_json_type_override("/a/b", JsonArray::Infer(JsonType::AlwaysString));
```

#### Boolean
Expand All @@ -86,10 +86,69 @@ The only two [valid boolean values in JSON](https://json-schema.org/understandin

```rust
let conf = Config::new_with_defaults()
.add_json_type_override("/a/b", JsonType::Bool(vec!["True","true","1","yes"]));
.add_json_type_override("/a/b", JsonArray::Infer(JsonType::Bool(vec!["True","true","1","yes"])));
```

See embedded docs for `Config` struct and its members for more details.
#### Arrays

Multiple nodes with the same name are automatically converted into a JSON array. For example,
```xml
<a>
<b>1</b>
<b>2</b>
</a>
```
is converted into
```json
{ "a":
{ "b": [1,2] }
}
```
By default, a single element like
```xml
<a>
<b>1</b>
</a>
```
is converted into a scalar value or a map
```json
{ "a":
{ "b": 1 }
}
```

You can use `add_json_type_override()` with `JsonArray::Always()` to create a JSON array regardless of the number of elements so that `<a><b>1</b></a>` becomes `{ "a": { "b": [1] } }`.

`JsonArray::Always()` and `JsonArray::Infer()` can specify what underlying JSON type should be used, e.g.
* `JsonArray::Infer(JsonType::AlwaysString)` - infer array, convert the values to JSON string
* `JsonArray::Always(JsonType::Infer)` - always wrap the values in a JSON array, infer the value types
* `JsonArray::Always(JsonType::AlwaysString)` - always wrap the values in a JSON array and convert values to JSON string

```rust
let config = Config::new_with_defaults()
.add_json_type_override("/a/b", JsonArray::Always(JsonType::AlwaysString));
```

Conversion of empty XML nodes like `<a><b /></a>` depends on `NullValue` setting. For example,
```rust
let config = Config::new_with_custom_values(false, "@", "#text", NullValue::Ignore)
.add_json_type_override("/a/b", JsonArray::Always(JsonType::Infer));
```
converts `<a><b /></a>` to
```json
{"a": null}
```
and the same `config` with `NullValue::Null` converts it to

```json
{"a": { "b": [null] }}
```

It is not possible to get an empty array like `{"a": { "b": [] }}`.

----

*See embedded docs for `Config` struct and its members for more details.*

## Conversion specifics

Expand Down Expand Up @@ -169,8 +228,8 @@ is converted into

#### Additional info and examples

See `mod tests` inside [lib.rs](src/lib.rs) for more usage examples.
See [tests.rs](src/tests.rs) for more usage examples.

## Edge cases

XML and JSON are not directly compatible for 1:1 conversion without additional hints to the converter. Feel free to post an issue if you come across any incorrect conversion.
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.
6 changes: 3 additions & 3 deletions examples/json_types.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
extern crate quickxml_to_serde;
#[cfg(feature = "json_types")]
use quickxml_to_serde::{xml_string_to_json, Config, JsonType};
use quickxml_to_serde::{xml_string_to_json, Config, JsonArray, JsonType};

#[cfg(feature = "json_types")]
fn main() {
let xml = r#"<a attr1="007"><b attr1="7">true</b></a>"#;

// custom config values for 1 attribute and a text node
let conf = Config::new_with_defaults()
.add_json_type_override("/a/b/@attr1", JsonType::AlwaysString)
.add_json_type_override("/a/b", JsonType::AlwaysString);
.add_json_type_override("/a/b/@attr1", JsonArray::Infer(JsonType::AlwaysString))
.add_json_type_override("/a/b", JsonArray::Infer(JsonType::AlwaysString));
let json = xml_string_to_json(String::from(xml), &conf);
println!("{}", json.expect("Malformed XML").to_string());
}
Expand Down
100 changes: 72 additions & 28 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,11 @@
//! for some XML nodes using xPath-like notations. Example for enforcing attribute `attr2` from the snippet above
//! as JSON String regardless of its contents:
//! ```
//! use quickxml_to_serde::{Config, JsonType};
//! use quickxml_to_serde::{Config, JsonArray, JsonType};
//!
//! #[cfg(feature = "json_types")]
//! let conf = Config::new_with_defaults().add_json_type_override("/a/b/c/@attr2", JsonType::AlwaysString);
//! let conf = Config::new_with_defaults()
//! .add_json_type_override("/a/b/c/@attr2", JsonArray::Infer(JsonType::AlwaysString));
//! ```
//!
//! ## Detailed documentation
Expand Down Expand Up @@ -73,12 +74,27 @@ pub enum NullValue {
EmptyObject,
}

/// Defines how the values of this Node should be converted into a JSON array with the underlying types.
/// * `Infer` - the nodes are converted into a JSON array only if there are multiple identical elements.
/// E.g. `<a><b>1</b></a>` becomes a map `{"a": {"b": 1 }}` and `<a><b>1</b><b>2</b><b>3</b></a>` becomes
/// an array `{"a": {"b": [1, 2, 3] }}`
/// * `Always` - the nodes are converted into a JSON array regardless of how many there are.
/// E.g. `<a><b>1</b></a>` becomes an array with a single value `{"a": {"b": [1] }}` and
/// `<a><b>1</b><b>2</b><b>3</b></a>` also becomes an array `{"a": {"b": [1, 2, 3] }}`
#[derive(Debug)]
pub enum JsonArray {
/// Convert the nodes into a JSON array even if there is only one element
Always(JsonType),
/// Convert the nodes into a JSON array only if there are multiple identical elements
Infer(JsonType),
}

/// Defines which data type to apply in JSON format for consistency of output.
/// E.g., the range of XML values for the same node type may be `1234`, `001234`, `AB1234`.
/// It is impossible to guess with 100% consistency which data type to apply without seeing
/// the entire range of values. Use this enum to tell the converter which data type should
/// be applied.
#[derive(Debug, PartialEq)]
#[derive(Debug, PartialEq, Clone)]
pub enum JsonType {
/// Do not try to infer the type and convert the value to JSON string.
/// E.g. convert `<a>1234</a>` into `{"a":"1234"}` or `<a>true</a>` into `{"a":"true"}`
Expand Down Expand Up @@ -123,7 +139,7 @@ pub struct Config {
/// - path for `c`: `/a/b/@c`
/// - path for `b` text node (007): `/a/b`
#[cfg(feature = "json_types")]
pub json_type_overrides: HashMap<String, JsonType>,
pub json_type_overrides: HashMap<String, JsonArray>,
}

impl Config {
Expand Down Expand Up @@ -165,7 +181,7 @@ impl Config {
/// - path for `b` text node (007): `/a/b`
/// This function will add the leading `/` if it's missing.
#[cfg(feature = "json_types")]
pub fn add_json_type_override(self, path: &str, json_type: JsonType) -> Self {
pub fn add_json_type_override(self, path: &str, json_type: JsonArray) -> Self {
let mut conf = self;
let path = if path.starts_with("/") {
path.to_owned()
Expand Down Expand Up @@ -238,18 +254,13 @@ fn convert_node(el: &Element, config: &Config, path: &String) -> Option<Value> {
// add the current node to the path
#[cfg(feature = "json_types")]
let path = [path, "/", el.name()].concat();

// get the json_type for this node
#[cfg(feature = "json_types")]
let json_type = config
.json_type_overrides
.get(&path)
.unwrap_or(&JsonType::Infer);
#[cfg(not(feature = "json_types"))]
let json_type = &JsonType::Infer;
let (_, json_type_value) = get_json_type(config, &path);

// is it an element with text?
if el.text().trim() != "" {
// does it have attributes?
// process node's attributes, if present
if el.attrs().count() > 0 {
Some(Value::Object(
el.attrs()
Expand All @@ -259,26 +270,27 @@ fn convert_node(el: &Element, config: &Config, path: &String) -> Option<Value> {
let path = [path.clone(), "/@".to_owned(), k.to_owned()].concat();
// get the json_type for this node
#[cfg(feature = "json_types")]
let json_type = config
.json_type_overrides
.get(&path)
.unwrap_or(&JsonType::Infer);
let (_, json_type_value) = get_json_type(config, &path);
(
[config.xml_attr_prefix.clone(), k.to_owned()].concat(),
parse_text(&v, config.leading_zero_as_string, json_type),
parse_text(&v, config.leading_zero_as_string, &json_type_value),
)
})
.chain(vec![(
config.xml_text_node_prop_name.clone(),
parse_text(&el.text()[..], config.leading_zero_as_string, json_type),
parse_text(
&el.text()[..],
config.leading_zero_as_string,
&json_type_value,
),
)])
.collect(),
))
} else {
Some(parse_text(
&el.text()[..],
config.leading_zero_as_string,
json_type,
&json_type_value,
))
}
} else {
Expand All @@ -291,13 +303,10 @@ fn convert_node(el: &Element, config: &Config, path: &String) -> Option<Value> {
let path = [path.clone(), "/@".to_owned(), k.to_owned()].concat();
// get the json_type for this node
#[cfg(feature = "json_types")]
let json_type = config
.json_type_overrides
.get(&path)
.unwrap_or(&JsonType::Infer);
let (_, json_type_value) = get_json_type(config, &path);
data.insert(
[config.xml_attr_prefix.clone(), k.to_owned()].concat(),
parse_text(&v, config.leading_zero_as_string, json_type),
parse_text(&v, config.leading_zero_as_string, &json_type_value),
);
}

Expand All @@ -307,18 +316,30 @@ fn convert_node(el: &Element, config: &Config, path: &String) -> Option<Value> {
Some(val) => {
let name = &child.name().to_string();

if data.contains_key(name) {
#[cfg(feature = "json_types")]
let path = [path.clone(), "/".to_owned(), name.clone()].concat();
let (json_type_array, _) = get_json_type(config, &path);
// does it have to be an array?
if json_type_array || data.contains_key(name) {
// was this property converted to an array earlier?
if data.get(name).unwrap_or(&Value::Null).is_array() {
// add the new value to an existing array
data.get_mut(name)
.unwrap()
.as_array_mut()
.unwrap()
.push(val);
} else {
let temp = data.remove(name).unwrap();
data.insert(name.clone(), Value::Array(vec![temp, val]));
// convert the property to an array with the existing and the new values
let new_val = match data.remove(name) {
None => vec![val],
Some(temp) => vec![temp, val],
};
data.insert(name.clone(), Value::Array(new_val));
}
} else {
// this is the first time this property is encountered and it doesn't
// have to be an array, so add it as-is
data.insert(name.clone(), val);
}
}
Expand Down Expand Up @@ -354,3 +375,26 @@ pub fn xml_string_to_json(xml: String, config: &Config) -> Result<Value, Error>
let root = Element::from_str(xml.as_str())?;
Ok(xml_to_map(&root, config))
}

/// Returns a tuple for Array and Value enforcements for the current node or
/// `(false, JsonArray::Infer(JsonType::Infer)` if the current path is not found
/// in the list of paths with custom config.
#[cfg(feature = "json_types")]
#[inline]
fn get_json_type(config: &Config, path: &String) -> (bool, JsonType) {
match config
.json_type_overrides
.get(path)
.unwrap_or(&JsonArray::Infer(JsonType::Infer))
{
JsonArray::Infer(v) => (false, v.clone()),
JsonArray::Always(v) => (true, v.clone()),
}
}

/// Always returns `(false, JsonArray::Infer(JsonType::Infer)` if `json_types` feature is not enabled.
#[cfg(not(feature = "json_types"))]
#[inline]
fn get_json_type(_config: &Config, _path: &String) -> (bool, JsonType) {
(false, JsonType::Infer)
}
Loading

0 comments on commit 5c884d5

Please sign in to comment.