diff --git a/Frends.JSON.ConvertXMLStringToJToken/CHANGELOG.md b/Frends.JSON.ConvertXMLStringToJToken/CHANGELOG.md
index c0835d4..7d06d2c 100644
--- a/Frends.JSON.ConvertXMLStringToJToken/CHANGELOG.md
+++ b/Frends.JSON.ConvertXMLStringToJToken/CHANGELOG.md
@@ -1,5 +1,15 @@
# Changelog
+## [2.0.0] - 2026-05-27
+### Breaking
+- The `XSD` parameter has moved from the **Input** tab to the **Options** tab and is now only used (and shown) when `TypeCorrection` is `Schema`.
+ - **Why this is breaking:** the XSD is no longer bound where existing process references expect it. A migration (`migration.json`) ships with the package to copy `Input.XSD` → `Options.XSD` and set `TypeCorrection = Schema` automatically, but **tenants that do not apply migration.json will not migrate automatically**. On those tenants the old XSD value is dropped, so XSD-driven array mapping (added in 1.2.0) silently stops working.
+ - **Upgrade path (manual, for tenants without migration support):** after updating each affected process step, open the **Options** tab, set `TypeCorrection = Schema`, and paste your XSD into `Options.XSD`. This restores the previous array-mapping behaviour. Steps that never used an XSD need no changes — leave `TypeCorrection = None`.
+### Added
+- Added `Options` parameter with `TypeCorrection` (`None`/`Attributes`/`Schema`) to convert numeric and boolean XML values into native JSON types, via inline `xsi:type` attributes (`Attributes`) or the supplied XSD (`Schema`). Default is `None`.
+### Changed
+- In `Schema` mode the XSD now drives both array mapping and value typing. `Schema` mode without an XSD is a graceful no-op (returns string values) rather than an error.
+
## [1.2.0] - 2026-05-07
### Added
- Added optional XSD input support to ensure consistent array/object mapping
diff --git a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.Tests/UnitTests.cs b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.Tests/UnitTests.cs
index 9149b9f..384f0ec 100644
--- a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.Tests/UnitTests.cs
+++ b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.Tests/UnitTests.cs
@@ -1,3 +1,4 @@
+using System;
using Frends.JSON.ConvertXMLStringToJToken.Definitions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json.Linq;
@@ -25,7 +26,7 @@ public void ShouldConvertXmlStringToJToken()
"
};
- var result = JSON.ConvertXMLStringToJToken(input);
+ var result = JSON.ConvertXMLStringToJToken(input, new Options());
Assert.IsTrue(result.Success);
Assert.IsInstanceOfType(result.Jtoken, typeof(JObject));
}
@@ -40,7 +41,12 @@ public void ShouldUseXsdToMapSingleElementAsArray()
Alan
- ",
+ "
+ };
+
+ var options = new Options()
+ {
+ TypeCorrection = TypeCorrectionMode.Schema,
XSD = @"
@@ -59,7 +65,7 @@ public void ShouldUseXsdToMapSingleElementAsArray()
"
};
- var result = JSON.ConvertXMLStringToJToken(input);
+ var result = JSON.ConvertXMLStringToJToken(input, options);
var root = ((JObject)result.Jtoken)["root"];
Assert.IsTrue(result.Success);
@@ -73,4 +79,749 @@ public void ShouldUseXsdToMapSingleElementAsArray()
Assert.AreEqual(1, persons.Count);
Assert.AreEqual("Alan", persons[0]["name"]?.ToString());
}
-}
+
+ [TestMethod]
+ public void NoneMode_ShouldKeepValuesAsStrings()
+ {
+ var input = new Input()
+ {
+ XML = @"
+ 12.5
+ "
+ };
+
+ var result = JSON.ConvertXMLStringToJToken(input, new Options { TypeCorrection = TypeCorrectionMode.None });
+ Assert.IsTrue(result.Success);
+
+ var price = ((JObject)result.Jtoken)["root"]?["price"] as JObject;
+
+ // Default behaviour is unchanged: the xsi:type wrapper and string value are preserved.
+ Assert.IsNotNull(price);
+ Assert.AreEqual(JTokenType.String, price["#text"].Type);
+ Assert.AreEqual("12.5", price["#text"].ToString());
+ }
+
+ [TestMethod]
+ public void AttributesMode_ShouldConvertNumericAndBooleanValues()
+ {
+ var input = new Input()
+ {
+ XML = @"
+ 12.5
+ 3
+ 123456789012345678901234567890
+ true
+ Widget
+ "
+ };
+
+ var result = JSON.ConvertXMLStringToJToken(input, new Options { TypeCorrection = TypeCorrectionMode.Attributes });
+ var root = (JObject)((JObject)result.Jtoken)["root"];
+
+ Assert.IsTrue(result.Success);
+ Assert.AreEqual(JTokenType.Float, root["price"].Type);
+ Assert.AreEqual(12.5, root["price"].Value());
+ Assert.AreEqual(JTokenType.Integer, root["quantity"].Type);
+ Assert.AreEqual(3, root["quantity"].Value());
+ Assert.AreEqual(JTokenType.Integer, root["huge"].Type);
+ Assert.AreEqual(JTokenType.Boolean, root["inStock"].Type);
+ Assert.IsTrue(root["inStock"].Value());
+ Assert.AreEqual(JTokenType.String, root["name"].Type);
+ }
+
+ [TestMethod]
+ public void AttributesMode_ShouldKeepOtherAttributesWhileTypingText()
+ {
+ var input = new Input()
+ {
+ XML = @"
+ 12.5
+ "
+ };
+
+ var result = JSON.ConvertXMLStringToJToken(input, new Options { TypeCorrection = TypeCorrectionMode.Attributes });
+ var price = ((JObject)result.Jtoken)["root"]?["price"];
+
+ // Element carries another attribute, so the wrapper is preserved but #text becomes a number.
+ Assert.IsInstanceOfType(price, typeof(JObject));
+ Assert.AreEqual("EUR", price["@currency"].ToString());
+ Assert.IsNull(price["@xsi:type"]);
+ Assert.AreEqual(JTokenType.Float, price["#text"].Type);
+ Assert.AreEqual(12.5, price["#text"].Value());
+ }
+
+ [TestMethod]
+ public void AttributesMode_BooleanParsing_ShouldBeCaseSensitive()
+ {
+ var input = new Input()
+ {
+ XML = @"
+ true
+ TRUE
+ True
+ "
+ };
+
+ var result = JSON.ConvertXMLStringToJToken(input, new Options { TypeCorrection = TypeCorrectionMode.Attributes });
+ var root = (JObject)((JObject)result.Jtoken)["root"];
+
+ Assert.IsTrue(result.Success);
+ Assert.AreEqual(JTokenType.Boolean, root["lower"].Type);
+ Assert.IsTrue(root["lower"].Value());
+
+ Assert.AreEqual(JTokenType.Boolean, root["upper"].Type);
+ Assert.IsTrue(root["upper"].Value());
+
+ Assert.AreEqual(JTokenType.Boolean, root["camel"].Type);
+ Assert.IsTrue(root["camel"].Value());
+ }
+
+ [TestMethod]
+ public void AttributesMode_ShouldHonourAliasedXsiNamespacePrefix()
+ {
+ var input = new Input()
+ {
+ XML = @"
+ 12.5
+ true
+ "
+ };
+
+ // Author used 'i' instead of the conventional 'xsi' prefix; conversion must still fire
+ // because the prefix is resolved against the XML Schema Instance namespace URI.
+ var result = JSON.ConvertXMLStringToJToken(input, new Options { TypeCorrection = TypeCorrectionMode.Attributes });
+ var root = (JObject)((JObject)result.Jtoken)["root"];
+
+ Assert.IsTrue(result.Success);
+ Assert.AreEqual(JTokenType.Float, root["price"].Type);
+ Assert.AreEqual(12.5, root["price"].Value());
+ Assert.AreEqual(JTokenType.Boolean, root["inStock"].Type);
+ Assert.IsTrue(root["inStock"].Value());
+ }
+
+ [TestMethod]
+ public void AttributesMode_ShouldIgnoreTypePropertyWhenPrefixNotBoundToXsiNamespace()
+ {
+ var input = new Input()
+ {
+ XML = @"
+ 12.5
+ "
+ };
+
+ // 'cust' is bound to an unrelated namespace, so cust:type is just an arbitrary attribute
+ // and must not trigger conversion.
+ var result = JSON.ConvertXMLStringToJToken(input, new Options { TypeCorrection = TypeCorrectionMode.Attributes });
+ var price = ((JObject)result.Jtoken)["root"]?["price"];
+
+ Assert.IsInstanceOfType(price, typeof(JObject));
+ Assert.AreEqual("float", price["@cust:type"]?.ToString());
+ Assert.AreEqual(JTokenType.String, price["#text"].Type);
+ Assert.AreEqual("12.5", price["#text"].ToString());
+ }
+
+ [TestMethod]
+ public void AttributesMode_ShouldResolvePrefixDeclaredOnInnerElement()
+ {
+ var input = new Input()
+ {
+ XML = @"
+
+ 12.5
+
+ "
+ };
+
+ // The xsi-equivalent prefix is bound on , not on the root — the scope must
+ // still be visible to the inner element when we resolve i:type.
+ var result = JSON.ConvertXMLStringToJToken(input, new Options { TypeCorrection = TypeCorrectionMode.Attributes });
+ var price = ((JObject)result.Jtoken)["root"]?["wrapper"]?["price"];
+
+ Assert.AreEqual(JTokenType.Float, price.Type);
+ Assert.AreEqual(12.5, price.Value());
+ }
+
+ [TestMethod]
+ public void NoneMode_ShouldNotTouchXsiNilWrappers()
+ {
+ var input = new Input()
+ {
+ XML = @"
+
+
+ "
+ };
+
+ // In None mode the task does no type/nullability processing, so Newtonsoft's raw
+ // shape (wrapper objects holding @xsi:nil) is preserved verbatim. Anyone relying on
+ // the pre-2.0.0 output stays unaffected by the xsi:nil feature.
+ var result = JSON.ConvertXMLStringToJToken(input, new Options { TypeCorrection = TypeCorrectionMode.None });
+ var root = (JObject)((JObject)result.Jtoken)["root"];
+
+ Assert.AreEqual("true", root["fax"]?["@xsi:nil"]?.ToString());
+ Assert.AreEqual("false", root["phone"]?["@xsi:nil"]?.ToString());
+ }
+
+ [TestMethod]
+ public void AttributesMode_XsiNilTrue_EmptyElement_ShouldBecomeNull()
+ {
+ var input = new Input()
+ {
+ XML = @"
+
+ "
+ };
+
+ var result = JSON.ConvertXMLStringToJToken(input, new Options { TypeCorrection = TypeCorrectionMode.Attributes });
+ var fax = ((JObject)result.Jtoken)["root"]?["fax"];
+
+ Assert.IsNotNull(fax);
+ Assert.AreEqual(JTokenType.Null, fax.Type);
+ }
+
+ [TestMethod]
+ public void AttributesMode_XsiNilTrue_WithContent_ShouldUseContent()
+ {
+ var input = new Input()
+ {
+ XML = @"
+ 123
+ 42
+ "
+ };
+
+ // Content wins over the nil flag: the string "123" survives, and the typed element
+ // still converts to integer 42 via the normal xsi:type path.
+ var result = JSON.ConvertXMLStringToJToken(input, new Options { TypeCorrection = TypeCorrectionMode.Attributes });
+ var root = (JObject)((JObject)result.Jtoken)["root"];
+
+ Assert.AreEqual(JTokenType.String, root["fax"].Type);
+ Assert.AreEqual("123", root["fax"].ToString());
+ Assert.AreEqual(JTokenType.Integer, root["count"].Type);
+ Assert.AreEqual(42, root["count"].Value());
+ }
+
+ [TestMethod]
+ public void AttributesMode_XsiNilFalse_EmptyElement_ShouldUseTypeDefault()
+ {
+ var input = new Input()
+ {
+ XML = @"
+
+
+
+
+ "
+ };
+
+ var result = JSON.ConvertXMLStringToJToken(input, new Options { TypeCorrection = TypeCorrectionMode.Attributes });
+ var root = (JObject)((JObject)result.Jtoken)["root"];
+
+ Assert.AreEqual(JTokenType.Integer, root["count"].Type);
+ Assert.AreEqual(0L, root["count"].Value());
+ Assert.AreEqual(JTokenType.Float, root["ratio"].Type);
+ Assert.AreEqual(0d, root["ratio"].Value());
+ Assert.AreEqual(JTokenType.Boolean, root["inStock"].Type);
+ Assert.IsFalse(root["inStock"].Value());
+ Assert.AreEqual(JTokenType.String, root["str"].Type);
+ Assert.AreEqual(string.Empty, root["str"].Value());
+ }
+
+ [TestMethod]
+ public void AttributesMode_XsiNilFalse_WithContent_ShouldStripNilAndKeepContent()
+ {
+ var input = new Input()
+ {
+ XML = @"
+ 5
+ "
+ };
+
+ var result = JSON.ConvertXMLStringToJToken(input, new Options { TypeCorrection = TypeCorrectionMode.Attributes });
+ var count = ((JObject)result.Jtoken)["root"]?["count"];
+
+ Assert.AreEqual(JTokenType.Integer, count.Type);
+ Assert.AreEqual(5, count.Value());
+ }
+
+ [TestMethod]
+ public void AttributesMode_XsiNilFalse_WithoutTypeInfo_ShouldBeNoOp()
+ {
+ var input = new Input()
+ {
+ XML = @"
+
+ "
+ };
+
+ // No xsi:type → we have no type info to coerce against. Per spec, leave the wrapper
+ // alone (xsi:nil attribute stays so the caller can see the marker if they want).
+ var result = JSON.ConvertXMLStringToJToken(input, new Options { TypeCorrection = TypeCorrectionMode.Attributes });
+ var fax = ((JObject)result.Jtoken)["root"]?["fax"];
+
+ Assert.IsInstanceOfType(fax, typeof(JObject));
+ Assert.AreEqual("false", fax["@xsi:nil"].ToString());
+ }
+
+ [TestMethod]
+ public void AttributesMode_XsiNilFalse_PreservesOtherAttributes()
+ {
+ var input = new Input()
+ {
+ XML = @"
+
+ "
+ };
+
+ // Element carries another data attribute, so the wrapper must survive. xsi:nil and
+ // xsi:type are consumed; #text becomes the typed default; @currency stays untouched.
+ var result = JSON.ConvertXMLStringToJToken(input, new Options { TypeCorrection = TypeCorrectionMode.Attributes });
+ var price = ((JObject)result.Jtoken)["root"]?["price"];
+
+ Assert.IsInstanceOfType(price, typeof(JObject));
+ Assert.AreEqual("EUR", price["@currency"].ToString());
+ Assert.IsNull(price["@xsi:type"]);
+ Assert.IsNull(price["@xsi:nil"]);
+ Assert.AreEqual(JTokenType.Float, price["#text"].Type);
+ Assert.AreEqual(0d, price["#text"].Value());
+ }
+
+ [TestMethod]
+ public void AttributesMode_XsiNilFalse_HonoursAliasedPrefix()
+ {
+ var input = new Input()
+ {
+ XML = @"
+
+ "
+ };
+
+ var result = JSON.ConvertXMLStringToJToken(input, new Options { TypeCorrection = TypeCorrectionMode.Attributes });
+ var count = ((JObject)result.Jtoken)["root"]?["count"];
+
+ Assert.AreEqual(JTokenType.Integer, count.Type);
+ Assert.AreEqual(0L, count.Value());
+ }
+
+ [TestMethod]
+ public void SchemaMode_XsiNilFalse_EmptyStringElement_ShouldBecomeEmptyString()
+ {
+ var input = new Input()
+ {
+ XML = @"
+
+ "
+ };
+
+ var options = new Options()
+ {
+ TypeCorrection = TypeCorrectionMode.Schema,
+ XSD = @"
+
+
+
+
+
+
+
+ "
+ };
+
+ // Schema declares the element as xs:string. xsi:nil='false' on an empty element means
+ // "do not allow null" — for a string that's an empty string.
+ var result = JSON.ConvertXMLStringToJToken(input, options);
+ var name = ((JObject)result.Jtoken)["root"]?["name"];
+
+ Assert.IsNotNull(name);
+ Assert.AreEqual(JTokenType.String, name.Type);
+ Assert.AreEqual(string.Empty, name.ToString());
+ }
+
+ [TestMethod]
+ public void SchemaMode_XsiNilTrue_EmptyNillableElement_ShouldBecomeNull()
+ {
+ var input = new Input()
+ {
+ XML = @"
+
+ "
+ };
+
+ var options = new Options()
+ {
+ TypeCorrection = TypeCorrectionMode.Schema,
+ XSD = @"
+
+
+
+
+
+
+
+ "
+ };
+
+ var result = JSON.ConvertXMLStringToJToken(input, options);
+ var fax = ((JObject)result.Jtoken)["root"]?["fax"];
+
+ Assert.IsNotNull(fax);
+ Assert.AreEqual(JTokenType.Null, fax.Type);
+ }
+
+ [TestMethod]
+ public void AttributesMode_ShouldLeaveUnparseableValuesAsStrings()
+ {
+ var input = new Input()
+ {
+ XML = @"
+ not-a-number
+ "
+ };
+
+ var result = JSON.ConvertXMLStringToJToken(input, new Options { TypeCorrection = TypeCorrectionMode.Attributes });
+ var price = ((JObject)result.Jtoken)["root"]?["price"];
+
+ Assert.IsInstanceOfType(price, typeof(JObject));
+ Assert.AreEqual(JTokenType.String, price["#text"].Type);
+ Assert.AreEqual("not-a-number", price["#text"].ToString());
+ }
+
+ [TestMethod]
+ public void SchemaMode_ShouldConvertValuesUsingXsdTypes()
+ {
+ var input = new Input()
+ {
+ XML = @"
+ 12.5
+ 3
+ true
+ Widget
+ "
+ };
+
+ var options = new Options()
+ {
+ TypeCorrection = TypeCorrectionMode.Schema,
+ XSD = @"
+
+
+
+
+
+
+
+
+
+
+ "
+ };
+
+ var result = JSON.ConvertXMLStringToJToken(input, options);
+ Assert.IsTrue(result.Success);
+
+ var root = ((JObject)result.Jtoken)["root"] as JObject;
+ Assert.IsNotNull(root);
+
+ Assert.AreEqual(JTokenType.Float, root["price"].Type);
+ Assert.AreEqual(12.5, root["price"].Value());
+ Assert.AreEqual(JTokenType.Integer, root["quantity"].Type);
+ Assert.AreEqual(JTokenType.Boolean, root["inStock"].Type);
+ Assert.IsTrue(root["inStock"].Value());
+ Assert.AreEqual(JTokenType.String, root["name"].Type);
+ }
+
+ [TestMethod]
+ public void SchemaMode_ShouldConvertSingleElementArraysToTypedArrays()
+ {
+ var input = new Input()
+ {
+ XML = @"
+ 9.5
+ "
+ };
+
+ var options = new Options()
+ {
+ TypeCorrection = TypeCorrectionMode.Schema,
+ XSD = @"
+
+
+
+
+
+
+
+ "
+ };
+
+ var result = JSON.ConvertXMLStringToJToken(input, options);
+ var scores = ((JObject)result.Jtoken)["root"]?["score"] as JArray;
+
+ Assert.IsNotNull(scores);
+ Assert.AreEqual(1, scores.Count);
+ Assert.AreEqual(JTokenType.Float, scores[0].Type);
+ Assert.AreEqual(9.5, scores[0].Value());
+ }
+
+ [TestMethod]
+ public void AttributesMode_ThrowAction_ShouldThrowOnUnparseableValue()
+ {
+ var input = new Input()
+ {
+ XML = @"
+ not-a-number
+ "
+ };
+
+ var options = new Options
+ {
+ TypeCorrection = TypeCorrectionMode.Attributes,
+ ActionOnBadValues = BadValueAction.Throw,
+ };
+
+ var ex = Assert.ThrowsException(
+ () => JSON.ConvertXMLStringToJToken(input, options));
+
+ StringAssert.Contains(ex.Message, "not-a-number");
+ StringAssert.Contains(ex.Message, "float");
+ }
+
+ [TestMethod]
+ public void AttributesMode_ThrowAction_ShouldNotThrowWhenAllValuesParse()
+ {
+ var input = new Input()
+ {
+ XML = @"
+ 12.5
+ true
+ "
+ };
+
+ var options = new Options
+ {
+ TypeCorrection = TypeCorrectionMode.Attributes,
+ ActionOnBadValues = BadValueAction.Throw,
+ };
+
+ var result = JSON.ConvertXMLStringToJToken(input, options);
+ var root = (JObject)((JObject)result.Jtoken)["root"];
+
+ Assert.IsTrue(result.Success);
+ Assert.AreEqual(JTokenType.Float, root["price"].Type);
+ Assert.AreEqual(JTokenType.Boolean, root["inStock"].Type);
+ }
+
+ [TestMethod]
+ public void AttributesMode_ThrowAction_ShouldThrowOnUnparseableBoolean()
+ {
+ var input = new Input()
+ {
+ XML = @"
+ maybe
+ "
+ };
+
+ var options = new Options
+ {
+ TypeCorrection = TypeCorrectionMode.Attributes,
+ ActionOnBadValues = BadValueAction.Throw,
+ };
+
+ var ex = Assert.ThrowsException(
+ () => JSON.ConvertXMLStringToJToken(input, options));
+
+ StringAssert.Contains(ex.Message, "maybe");
+ StringAssert.Contains(ex.Message, "boolean");
+ }
+
+ [TestMethod]
+ public void AttributesMode_IgnoreAction_IsDefaultAndKeepsUnparseableAsString()
+ {
+ var input = new Input()
+ {
+ XML = @"
+ not-a-number
+ "
+ };
+
+ var options = new Options
+ {
+ TypeCorrection = TypeCorrectionMode.Attributes,
+ };
+
+ Assert.AreEqual(BadValueAction.Ignore, options.ActionOnBadValues);
+
+ var result = JSON.ConvertXMLStringToJToken(input, options);
+ var price = ((JObject)result.Jtoken)["root"]?["price"];
+
+ Assert.IsInstanceOfType(price, typeof(JObject));
+ Assert.AreEqual(JTokenType.String, price["#text"].Type);
+ Assert.AreEqual("not-a-number", price["#text"].ToString());
+ }
+
+ [TestMethod]
+ public void AttributesMode_ThrowAction_ShouldNotThrowOnUnknownXsiType()
+ {
+ var input = new Input()
+ {
+ XML = @"
+ DEADBEEF
+ "
+ };
+
+ var options = new Options
+ {
+ TypeCorrection = TypeCorrectionMode.Attributes,
+ ActionOnBadValues = BadValueAction.Throw,
+ };
+
+ // xsi:types we don't know how to convert (e.g. hexBinary) are not treated as "bad" —
+ // they're simply left alone regardless of the action setting.
+ var result = JSON.ConvertXMLStringToJToken(input, options);
+
+ Assert.IsTrue(result.Success);
+ }
+
+ [TestMethod]
+ public void SchemaMode_ShouldHonourAliasedXsiNamespacePrefix()
+ {
+ var input = new Input()
+ {
+ XML = @"
+ 12.5
+ "
+ };
+
+ var options = new Options()
+ {
+ TypeCorrection = TypeCorrectionMode.Schema,
+ XSD = @"
+
+
+
+
+
+
+
+ "
+ };
+
+ // Root already binds the XSD-instance namespace under prefix 'i'. The schema-injected
+ // type attribute should serialize as @i:type and the prefix-agnostic lookup must still
+ // resolve it for conversion.
+ var result = JSON.ConvertXMLStringToJToken(input, options);
+ var price = ((JObject)result.Jtoken)["root"]?["price"];
+
+ Assert.IsTrue(result.Success);
+ Assert.AreEqual(JTokenType.Float, price.Type);
+ Assert.AreEqual(12.5, price.Value());
+ }
+
+ [TestMethod]
+ public void SchemaMode_ShouldOverrideInlineXsiTypeWithSchemaType()
+ {
+ var input = new Input()
+ {
+ XML = @"
+ 12.5
+ "
+ };
+
+ var options = new Options()
+ {
+ TypeCorrection = TypeCorrectionMode.Schema,
+ XSD = @"
+
+
+
+
+
+
+
+ "
+ };
+
+ // The author lied with xsi:type='int' on a 12.5 value; schema says float. XSD wins.
+ var result = JSON.ConvertXMLStringToJToken(input, options);
+ var price = ((JObject)result.Jtoken)["root"]?["price"];
+
+ Assert.IsTrue(result.Success);
+ Assert.AreEqual(JTokenType.Float, price.Type);
+ Assert.AreEqual(12.5, price.Value());
+ }
+
+ [TestMethod]
+ public void SchemaMode_ShouldStripInlineXsiTypeWhenSchemaTypeIsString()
+ {
+ var input = new Input()
+ {
+ XML = @"
+ 42
+ "
+ };
+
+ var options = new Options()
+ {
+ TypeCorrection = TypeCorrectionMode.Schema,
+ XSD = @"
+
+
+
+
+
+
+
+ "
+ };
+
+ // Inline xsi:type='int' must not override the schema's xs:string declaration.
+ var result = JSON.ConvertXMLStringToJToken(input, options);
+ var name = ((JObject)result.Jtoken)["root"]?["name"];
+
+ Assert.IsTrue(result.Success);
+ Assert.AreEqual(JTokenType.String, name.Type);
+ Assert.AreEqual("42", name.ToString());
+ }
+
+ [TestMethod]
+ public void SchemaMode_WithoutXsd_ShouldNoOpAndKeepStrings()
+ {
+ var input = new Input()
+ {
+ XML = "12.5"
+ };
+
+ // Schema mode without an XSD is a graceful no-op (not an error), so migrated
+ // processes that land in Schema mode without a schema keep their previous output.
+ var result = JSON.ConvertXMLStringToJToken(input, new Options { TypeCorrection = TypeCorrectionMode.Schema });
+ Assert.IsTrue(result.Success);
+
+ var price = ((JObject)result.Jtoken)["root"]?["price"];
+ Assert.IsNotNull(price);
+ Assert.AreEqual(JTokenType.String, price.Type);
+ Assert.AreEqual("12.5", price.ToString());
+ }
+
+ [TestMethod]
+ public void SchemaMode_WithoutXsd_ShouldNotConvertInlineXsiTypes()
+ {
+ var input = new Input()
+ {
+ XML = @"
+ 12.5
+ "
+ };
+
+ // Schema-without-XSD is a true no-op: inline xsi:type must not bleed through and
+ // start converting values either. Tenants who land here via migration with no XSD
+ // should see exactly the pre-2.0.0 output shape.
+ var result = JSON.ConvertXMLStringToJToken(input, new Options { TypeCorrection = TypeCorrectionMode.Schema });
+ var price = ((JObject)result.Jtoken)["root"]?["price"];
+
+ Assert.IsTrue(result.Success);
+ Assert.IsInstanceOfType(price, typeof(JObject));
+ Assert.AreEqual("float", price["@xsi:type"]?.ToString());
+ Assert.AreEqual(JTokenType.String, price["#text"].Type);
+ Assert.AreEqual("12.5", price["#text"].ToString());
+ }
+}
\ No newline at end of file
diff --git a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/ConvertXMLStringToJToken.cs b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/ConvertXMLStringToJToken.cs
index e9731f4..e203d34 100644
--- a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/ConvertXMLStringToJToken.cs
+++ b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/ConvertXMLStringToJToken.cs
@@ -1,9 +1,13 @@
using Frends.JSON.ConvertXMLStringToJToken.Definitions;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
+using System;
+using System.Collections.Generic;
using System.ComponentModel;
+using System.Globalization;
using System.IO;
using System.Linq;
+using System.Numerics;
using System.Xml;
using System.Xml.Linq;
using System.Xml.Schema;
@@ -16,21 +20,75 @@ namespace Frends.JSON.ConvertXMLStringToJToken;
public class JSON
{
private const string JsonNamespace = "http://james.newtonking.com/projects/json";
+ private const string XsiNamespace = "http://www.w3.org/2001/XMLSchema-instance";
+
+ private const string TextPropertyName = "#text";
+ private const string XmlnsPrefixSeparator = "@xmlns:";
+
+ private static readonly IReadOnlyDictionary EmptyXmlnsScope =
+ new Dictionary(0, StringComparer.Ordinal);
+
+ ///
+ /// Maps an XSD type local name to a parser producing a native JSON value, or null when unparseable.
+ ///
+ private static readonly Dictionary> TypeConverters =
+ new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["float"] = ParseFloatingPoint,
+ ["double"] = ParseFloatingPoint,
+ ["decimal"] = ParseDecimal,
+ ["int"] = ParseInteger,
+ ["integer"] = ParseInteger,
+ ["long"] = ParseInteger,
+ ["short"] = ParseInteger,
+ ["byte"] = ParseInteger,
+ ["unsignedInt"] = ParseInteger,
+ ["unsignedLong"] = ParseInteger,
+ ["unsignedShort"] = ParseInteger,
+ ["unsignedByte"] = ParseInteger,
+ ["nonNegativeInteger"] = ParseInteger,
+ ["positiveInteger"] = ParseInteger,
+ ["nonPositiveInteger"] = ParseInteger,
+ ["negativeInteger"] = ParseInteger,
+ ["boolean"] = ParseBoolean,
+ };
///
/// Convert XML string to JToken.
/// [Documentation](https://tasks.frends.com/tasks/frends-tasks/Frends.JSON.ConvertXMLStringToJToken)
///
/// Input parameters
+ /// Optional parameters
/// Object { bool Success, object Jtoken }
- public static Result ConvertXMLStringToJToken([PropertyTab] Input input)
+ public static Result ConvertXMLStringToJToken([PropertyTab] Input input, [PropertyTab] Options options)
{
- var doc = string.IsNullOrWhiteSpace(input.XSD)
- ? LoadXmlDocument(input.XML)
- : LoadXmlDocumentWithSchemaHints(input.XML, input.XSD);
+ options ??= new Options();
+
+ // Schema mode without an XSD is treated as a no-op rather than an error so that
+ // processes migrated into Schema mode without a schema keep their previous output.
+ var useSchema = options.TypeCorrection == TypeCorrectionMode.Schema
+ && !string.IsNullOrWhiteSpace(options.XSD);
+
+ var doc = useSchema
+ ? LoadXmlDocumentWithSchemaHints(input.XML, options.XSD, options.TypeCorrection)
+ : LoadXmlDocument(input.XML);
var jsonString = JsonConvert.SerializeXmlNode(doc);
- return new Result(true, JToken.Parse(jsonString));
+ var token = JToken.Parse(jsonString);
+
+ // Attributes always runs; Schema mode only runs when it has an XSD to derive types from,
+ // so picking Schema with a blank XSD stays a true no-op even if the input XML happens to
+ // carry inline xsi:type attributes.
+ var effectiveMode = useSchema
+ ? TypeCorrectionMode.Schema
+ : options.TypeCorrection == TypeCorrectionMode.Attributes
+ ? TypeCorrectionMode.Attributes
+ : TypeCorrectionMode.None;
+
+ if (effectiveMode != TypeCorrectionMode.None)
+ CorrectTypes(token, options.ActionOnBadValues, effectiveMode);
+
+ return new Result(true, token);
}
private static XmlDocument LoadXmlDocument(string xml)
@@ -40,12 +98,18 @@ private static XmlDocument LoadXmlDocument(string xml)
return doc;
}
- private static XmlDocument LoadXmlDocumentWithSchemaHints(string xml, string xsd)
+ private static XmlDocument LoadXmlDocumentWithSchemaHints(string xml, string xsd, TypeCorrectionMode typeCorrection)
{
var schemaSet = CreateSchemaSet(xsd);
var xDocument = XDocument.Parse(xml);
+ // In Schema mode the XSD is the single source of truth; pull any author-written
+ // xsi:type off the document up front so it can't trip the validator (an unqualified
+ // xsi:type='int' is otherwise rejected before we ever get to AddSchemaHints) or shadow
+ // a schema-derived hint.
+ RemoveInlineXsiTypeAttributes(xDocument);
+
xDocument.Validate(
schemaSet,
(sender, args) =>
@@ -56,7 +120,7 @@ private static XmlDocument LoadXmlDocumentWithSchemaHints(string xml, string xsd
},
true);
- AddJsonArrayAttributesFromSchema(xDocument);
+ AddSchemaHints(xDocument, typeCorrection);
var xmlDocument = new XmlDocument();
@@ -66,6 +130,22 @@ private static XmlDocument LoadXmlDocumentWithSchemaHints(string xml, string xsd
return xmlDocument;
}
+ ///
+ /// Removes any xsi:type attribute (under any prefix bound to the XML Schema Instance
+ /// namespace) from every element in the document.
+ ///
+ private static void RemoveInlineXsiTypeAttributes(XDocument document)
+ {
+ if (document.Root == null)
+ return;
+
+ XNamespace xsiNs = XsiNamespace;
+ var xsiType = xsiNs + "type";
+
+ foreach (var element in document.Root.DescendantsAndSelf())
+ element.Attribute(xsiType)?.Remove();
+ }
+
private static XmlSchemaSet CreateSchemaSet(string xsd)
{
var schemaSet = new XmlSchemaSet();
@@ -75,35 +155,443 @@ private static XmlSchemaSet CreateSchemaSet(string xsd)
return schemaSet;
}
- private static void AddJsonArrayAttributesFromSchema(XDocument document)
+ ///
+ /// Walks the validated document once, tagging elements that the schema declares as
+ /// repeatable with json:Array, and (when requested) numeric/boolean elements with xsi:type
+ /// so they can be converted to native JSON types after serialization.
+ ///
+ private static void AddSchemaHints(XDocument document, TypeCorrectionMode typeCorrection)
{
if (document.Root == null)
return;
XNamespace jsonNs = JsonNamespace;
+ XNamespace xsiNs = XsiNamespace;
var hasArray = false;
+ var hasType = false;
foreach (var element in document.Root.DescendantsAndSelf())
{
- var schemaElement = element.GetSchemaInfo()?.SchemaElement;
+ var schemaInfo = element.GetSchemaInfo();
+ var schemaElement = schemaInfo?.SchemaElement;
if (schemaElement?.MaxOccurs > 1m)
{
element.SetAttributeValue(jsonNs + "Array", "true");
hasArray = true;
}
+
+ if (typeCorrection == TypeCorrectionMode.Schema)
+ {
+ var typeName = GetConvertibleTypeName(schemaInfo?.SchemaType);
+ if (typeName != null)
+ {
+ element.SetAttributeValue(xsiNs + "type", typeName);
+ hasType = true;
+ }
+ }
+ }
+
+ if (hasArray && !IsNamespaceDeclared(document.Root, JsonNamespace))
+ document.Root.SetAttributeValue(XNamespace.Xmlns + "json", JsonNamespace);
+
+ // Pin the "xsi" prefix so injected type hints serialize as @xsi:type rather than an
+ // auto-generated prefix that the post-serialization conversion would not recognise.
+ if (hasType && !IsNamespaceDeclared(document.Root, XsiNamespace))
+ document.Root.SetAttributeValue(XNamespace.Xmlns + "xsi", XsiNamespace);
+ }
+
+ private static bool IsNamespaceDeclared(XElement root, string namespaceName)
+ {
+ return root.Attributes().Any(a => a.IsNamespaceDeclaration && a.Value == namespaceName);
+ }
+
+ ///
+ /// Returns a canonical XSD type name (matching ) for numeric/boolean
+ /// schema types, or null for types that should remain strings.
+ ///
+ private static string GetConvertibleTypeName(XmlSchemaType schemaType)
+ {
+ return schemaType?.TypeCode switch
+ {
+ XmlTypeCode.Float => "float",
+ XmlTypeCode.Double => "double",
+ XmlTypeCode.Decimal => "decimal",
+ XmlTypeCode.Boolean => "boolean",
+ XmlTypeCode.Integer
+ or XmlTypeCode.NonPositiveInteger
+ or XmlTypeCode.NegativeInteger
+ or XmlTypeCode.Long
+ or XmlTypeCode.Int
+ or XmlTypeCode.Short
+ or XmlTypeCode.Byte
+ or XmlTypeCode.NonNegativeInteger
+ or XmlTypeCode.UnsignedLong
+ or XmlTypeCode.UnsignedInt
+ or XmlTypeCode.UnsignedShort
+ or XmlTypeCode.UnsignedByte
+ or XmlTypeCode.PositiveInteger => "integer",
+ _ => null,
+ };
+ }
+
+ ///
+ /// Recursively converts values carrying an xsi:type hint into native JSON numbers/booleans,
+ /// removing the consumed xsi:type attribute and collapsing the wrapper object when only the
+ /// converted value remains.
+ ///
+ private static void CorrectTypes(JToken token, BadValueAction actionOnBadValues, TypeCorrectionMode mode)
+ {
+ CorrectTypes(token, actionOnBadValues, mode, EmptyXmlnsScope);
+ }
+
+ private static void CorrectTypes(
+ JToken token,
+ BadValueAction actionOnBadValues,
+ TypeCorrectionMode mode,
+ IReadOnlyDictionary xmlnsScope)
+ {
+ switch (token)
+ {
+ case JArray array:
+ foreach (var item in array.Children().ToList())
+ CorrectTypes(item, actionOnBadValues, mode, xmlnsScope);
+ break;
+
+ case JObject obj:
+ var childScope = ExtendXmlnsScope(obj, xmlnsScope);
+ foreach (var value in obj.PropertyValues().ToList())
+ CorrectTypes(value, actionOnBadValues, mode, childScope);
+
+ // xsi:nil handling runs first so it can collapse the wrapper to a default/null
+ // (or strip the attribute so ApplyTypeHint sees a clean element).
+ if (ApplyXsiNil(obj, mode, childScope))
+ break;
+
+ ApplyTypeHint(obj, actionOnBadValues, childScope);
+ break;
}
+ }
- var existing = document.Root.Attributes()
- .FirstOrDefault(a =>
- a.IsNamespaceDeclaration &&
- a.Value == JsonNamespace);
+ ///
+ /// Extends an xmlns prefix-to-namespace map with any @xmlns:* declarations carried on this
+ /// JObject, so that nested elements can resolve type-hint prefixes (e.g. xmlns:i bound here,
+ /// i:type used on a descendant). Inner declarations shadow outer ones, matching XML scope.
+ ///
+ private static IReadOnlyDictionary ExtendXmlnsScope(
+ JObject obj,
+ IReadOnlyDictionary parentScope)
+ {
+ Dictionary extended = null;
- if (hasArray && existing == null)
+ foreach (var prop in obj.Properties())
{
- document.Root.SetAttributeValue(
- XNamespace.Xmlns + "json",
- JsonNamespace);
+ if (!prop.Name.StartsWith(XmlnsPrefixSeparator, StringComparison.Ordinal))
+ continue;
+
+ extended ??= new Dictionary(parentScope, StringComparer.Ordinal);
+ var prefix = prop.Name.Substring(XmlnsPrefixSeparator.Length);
+ extended[prefix] = prop.Value.ToString();
}
+
+ return extended ?? parentScope;
+ }
+
+ private static void ApplyTypeHint(
+ JObject obj,
+ BadValueAction actionOnBadValues,
+ IReadOnlyDictionary xmlnsScope)
+ {
+ var typeProperty = FindXsiTypeProperty(obj, xmlnsScope);
+ if (typeProperty == null)
+ return;
+
+ var typeName = LocalName(typeProperty.Value.ToString());
+ if (!TypeConverters.TryGetValue(typeName, out var convert))
+ return;
+
+ var textProperty = obj.Property(TextPropertyName, StringComparison.Ordinal);
+ if (textProperty == null || textProperty.Value.Type != JTokenType.String)
+ return;
+
+ var rawValue = textProperty.Value.ToString();
+ var converted = convert(rawValue);
+ if (converted == null)
+ {
+ if (actionOnBadValues == BadValueAction.Throw)
+ throw new FormatException(
+ $"Value '{rawValue}' on <{obj.Path}> could not be converted to '{typeName}'.");
+ return;
+ }
+
+ typeProperty.Remove();
+
+ var remaining = obj.Properties().ToList();
+ var onlyText = remaining.Count == 1 && remaining[0].Name == TextPropertyName;
+ var onlyTextAndXsiNs = remaining.Count == 2 &&
+ remaining.Any(p => p.Name == TextPropertyName) &&
+ remaining.Any(IsXsiNamespaceDeclaration);
+
+ if ((onlyText || onlyTextAndXsiNs) && obj.Parent != null)
+ obj.Replace(converted);
+ else
+ textProperty.Value = converted;
+ }
+
+ private static JProperty FindXsiTypeProperty(JObject obj, IReadOnlyDictionary xmlnsScope) =>
+ FindXsiNamespacedAttribute(obj, "type", xmlnsScope);
+
+ private static JProperty FindXsiNilProperty(JObject obj, IReadOnlyDictionary xmlnsScope) =>
+ FindXsiNamespacedAttribute(obj, "nil", xmlnsScope);
+
+ ///
+ /// Finds the first attribute on this JObject named "@<prefix>:<localName>" where
+ /// <prefix> is bound (in the active xmlns scope) to the XML Schema Instance namespace.
+ /// Newtonsoft preserves the source prefix, so authors using xmlns:i (instead of the
+ /// conventional xsi) still get their type/nil hints honoured.
+ ///
+ private static JProperty FindXsiNamespacedAttribute(
+ JObject obj,
+ string localName,
+ IReadOnlyDictionary xmlnsScope)
+ {
+ foreach (var prop in obj.Properties())
+ {
+ if (prop.Name.Length < 2 || prop.Name[0] != '@')
+ continue;
+
+ var colonIndex = prop.Name.IndexOf(':');
+ if (colonIndex < 0)
+ continue;
+
+ var local = prop.Name.Substring(colonIndex + 1);
+ if (!string.Equals(local, localName, StringComparison.Ordinal))
+ continue;
+
+ var prefix = prop.Name.Substring(1, colonIndex - 1);
+ if (xmlnsScope.TryGetValue(prefix, out var ns) &&
+ string.Equals(ns, XsiNamespace, StringComparison.Ordinal))
+ return prop;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Applies xsi:nil semantics. Returns true when the JObject was replaced/finalised and no
+ /// further type-hint processing is needed on it.
+ /// nil="true" empty → JSON null (replacing the wrapper).
+ /// nil="true" with content → strip the attribute, let content flow through.
+ /// nil="false" empty → coerce to default(T) (T from xsi:type, or "" when Schema mode
+ /// validated the element without providing a convertible type; Attributes mode without
+ /// xsi:type is left alone).
+ /// nil="false" with content → strip the attribute, let content flow through.
+ ///
+ private static bool ApplyXsiNil(
+ JObject obj,
+ TypeCorrectionMode mode,
+ IReadOnlyDictionary xmlnsScope)
+ {
+ var nilProperty = FindXsiNilProperty(obj, xmlnsScope);
+ if (nilProperty == null)
+ return false;
+
+ var nilFlag = ParseNilFlag(nilProperty.Value.ToString());
+ if (nilFlag == null)
+ return false;
+
+ var textProperty = obj.Property(TextPropertyName, StringComparison.Ordinal);
+ if (HasContent(obj, textProperty))
+ {
+ // Content wins — drop the nil attribute. If the wrapper now has nothing else
+ // meaningful to carry (no xsi:type for further conversion, no other attributes),
+ // collapse it to the bare text value so the shape matches an authored element
+ // that never had xsi:nil.
+ nilProperty.Remove();
+
+ var typeProperty = FindXsiTypeProperty(obj, xmlnsScope);
+ if (typeProperty == null && textProperty != null &&
+ obj.Parent != null && OnlyTextOrNamespaceDeclarations(obj))
+ {
+ obj.Replace(textProperty.Value);
+ return true;
+ }
+
+ return false;
+ }
+
+ // Empty element — handle the nil flag.
+ if (nilFlag == true)
+ {
+ nilProperty.Remove();
+ ReplaceOrClearWrapper(obj, textProperty, JValue.CreateNull());
+ return true;
+ }
+
+ // nil = false on an empty element: coerce to default(T), but only when we actually
+ // have type info to derive T from.
+ var typePropertyForDefault = FindXsiTypeProperty(obj, xmlnsScope);
+ JValue defaultValue;
+
+ if (typePropertyForDefault != null)
+ {
+ defaultValue = DefaultValueForTypeName(LocalName(typePropertyForDefault.Value.ToString()));
+ typePropertyForDefault.Remove();
+ }
+ else if (mode == TypeCorrectionMode.Schema)
+ {
+ // The schema validated this element; a missing xsi:type means the schema's type is
+ // a non-convertible one (xs:string or similar). Default to an empty string.
+ defaultValue = new JValue(string.Empty);
+ }
+ else
+ {
+ // Attributes mode with no inline xsi:type: per design, only act when type info is
+ // present. Leave xsi:nil in place so the caller can still see the marker.
+ return false;
+ }
+
+ nilProperty.Remove();
+ ReplaceOrClearWrapper(obj, textProperty, defaultValue);
+ return true;
+ }
+
+ private static bool OnlyTextOrNamespaceDeclarations(JObject obj)
+ {
+ foreach (var prop in obj.Properties())
+ {
+ if (prop.Name == TextPropertyName)
+ continue;
+ if (prop.Name.StartsWith("@xmlns", StringComparison.Ordinal))
+ continue;
+ return false;
+ }
+ return true;
+ }
+
+ private static bool? ParseNilFlag(string value)
+ {
+ var trimmed = value?.Trim() ?? string.Empty;
+
+ if (trimmed == "1" || string.Equals(trimmed, "true", StringComparison.OrdinalIgnoreCase))
+ return true;
+
+ if (trimmed == "0" || string.Equals(trimmed, "false", StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ return null;
+ }
+
+ private static bool HasContent(JObject obj, JProperty textProperty)
+ {
+ if (textProperty != null && textProperty.Value.Type == JTokenType.String &&
+ !string.IsNullOrEmpty(textProperty.Value.ToString()))
+ return true;
+
+ foreach (var prop in obj.Properties())
+ {
+ if (prop.Name.Length == 0 || prop.Name[0] == '@' || prop.Name == TextPropertyName)
+ continue;
+
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// When the wrapper's only remaining properties are namespace declarations, collapse it to
+ /// the new scalar value. Otherwise preserve the wrapper (other attributes are still
+ /// meaningful) and assign the value to #text.
+ ///
+ private static void ReplaceOrClearWrapper(JObject obj, JProperty existingTextProperty, JValue newValue)
+ {
+ var canCollapse = obj.Parent != null &&
+ obj.Properties().All(p => p.Name.StartsWith("@xmlns", StringComparison.Ordinal));
+
+ if (canCollapse)
+ {
+ obj.Replace(newValue);
+ return;
+ }
+
+ if (existingTextProperty != null)
+ existingTextProperty.Value = newValue;
+ else
+ obj[TextPropertyName] = newValue;
+ }
+
+ ///
+ /// Mirrors C# default(T) for the recognized XSD-ish type names; unknown types fall back to
+ /// an empty string so callers always get a usable JSON value.
+ ///
+ private static JValue DefaultValueForTypeName(string typeName)
+ {
+ if (string.IsNullOrEmpty(typeName) || !TypeConverters.ContainsKey(typeName))
+ return new JValue(string.Empty);
+
+ return typeName.ToLowerInvariant() switch
+ {
+ "float" or "double" => new JValue(0d),
+ "decimal" => new JValue(0m),
+ "boolean" => new JValue(false),
+ _ => new JValue(0L),
+ };
+ }
+
+ private static bool IsXsiNamespaceDeclaration(JProperty property)
+ {
+ return property.Name.StartsWith("@xmlns", StringComparison.Ordinal) &&
+ string.Equals(property.Value.ToString(), XsiNamespace, StringComparison.Ordinal);
+ }
+
+ private static string LocalName(string value)
+ {
+ var index = value.IndexOf(':');
+ return index >= 0 ? value[(index + 1)..] : value;
+ }
+
+ private static JValue ParseFloatingPoint(string value)
+ {
+ switch (value.Trim())
+ {
+ case "INF": return new JValue(double.PositiveInfinity);
+ case "-INF": return new JValue(double.NegativeInfinity);
+ case "NaN": return new JValue(double.NaN);
+ }
+
+ return double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)
+ ? new JValue(parsed)
+ : null;
+ }
+
+ private static JValue ParseDecimal(string value)
+ {
+ return decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)
+ ? new JValue(parsed)
+ : null;
+ }
+
+ private static JValue ParseInteger(string value)
+ {
+ if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLong))
+ return new JValue(parsedLong);
+
+ return BigInteger.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedBig)
+ ? new JValue(parsedBig)
+ : null;
+ }
+
+ private static JValue ParseBoolean(string value)
+ {
+ var trimmed = value.Trim();
+
+ if (trimmed == "1" || string.Equals(trimmed, "true", StringComparison.OrdinalIgnoreCase))
+ return new JValue(true);
+
+ if (trimmed == "0" || string.Equals(trimmed, "false", StringComparison.OrdinalIgnoreCase))
+ return new JValue(false);
+
+ return null;
}
}
diff --git a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/Definitions/Input.cs b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/Definitions/Input.cs
index 2f21d84..70116d6 100644
--- a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/Definitions/Input.cs
+++ b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/Definitions/Input.cs
@@ -10,13 +10,4 @@ public class Input
///
/// <?xml version='1.0' standalone='no'?><root><foos id = '1' ><foo>bar</name></foos></root>
public string XML { get; set; }
-
- ///
- /// Optional XSD schema used for XML validation and for determining
- /// whether XML elements should be serialized as JSON arrays.
- ///
- ///
- /// <xs:schema xmlns:xs='http://www.w3.org/2001/XMLSchema'>...</xs:schema>
- ///
- public string XSD { get; set; }
}
diff --git a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/Definitions/Options.cs b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/Definitions/Options.cs
new file mode 100644
index 0000000..41abe0b
--- /dev/null
+++ b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/Definitions/Options.cs
@@ -0,0 +1,80 @@
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Frends.JSON.ConvertXMLStringToJToken.Definitions;
+
+///
+/// Controls how XML values are mapped to native JSON types.
+///
+public enum TypeCorrectionMode
+{
+ ///
+ /// No type correction. Every value is emitted as a JSON string (default, backwards compatible).
+ ///
+ None,
+
+ ///
+ /// Read inline xsi:type attributes from the XML (e.g. xsi:type="float") to convert
+ /// numeric and boolean values into native JSON types.
+ ///
+ Attributes,
+
+ ///
+ /// Derive value types from the provided XSD schema (xs:float, xs:int, xs:boolean, ...)
+ /// to convert numeric and boolean values into native JSON types. Requires an XSD.
+ ///
+ Schema,
+}
+
+///
+/// Controls what happens when a value carries a known numeric/boolean type hint but cannot
+/// be parsed as that type (e.g. xsi:type="int" with value "abc").
+///
+public enum BadValueAction
+{
+ ///
+ /// Leave the unparseable value as a JSON string (default, backwards compatible).
+ ///
+ Ignore,
+
+ ///
+ /// Throw an exception identifying the element and value that failed to convert.
+ ///
+ Throw,
+}
+
+///
+/// Optional parameters.
+///
+public class Options
+{
+ ///
+ /// Whether and how to convert string values into native JSON numbers and booleans.
+ /// None leaves all values as strings (default). Attributes reads inline xsi:type hints
+ /// from the XML. Schema derives the types from the supplied XSD.
+ ///
+ /// TypeCorrectionMode.Attributes
+ [DefaultValue(TypeCorrectionMode.None)]
+ public TypeCorrectionMode TypeCorrection { get; set; } = TypeCorrectionMode.None;
+
+ ///
+ /// XSD schema used to validate the XML and to derive value types and array mapping.
+ /// Only used (and shown) when TypeCorrection is Schema.
+ ///
+ ///
+ /// <xs:schema xmlns:xs='http://www.w3.org/2001/XMLSchema'>...</xs:schema>
+ ///
+ [UIHint(nameof(TypeCorrection), "", TypeCorrectionMode.Schema)]
+ public string XSD { get; set; }
+
+ ///
+ /// What to do when a value carries a recognized numeric/boolean type hint but cannot be
+ /// parsed as that type (e.g. xsi:type="int" with value "abc"). Ignore (default) keeps the
+ /// unparseable value as a JSON string. Throw raises an exception identifying the element
+ /// and value. Only used (and shown) when TypeCorrection is Attributes or Schema.
+ ///
+ /// BadValueAction.Throw
+ [DefaultValue(BadValueAction.Ignore)]
+ [UIHint(nameof(TypeCorrection), "", TypeCorrectionMode.Attributes, TypeCorrectionMode.Schema)]
+ public BadValueAction ActionOnBadValues { get; set; } = BadValueAction.Ignore;
+}
diff --git a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.csproj b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.csproj
index a79bb1a..feb23d9 100644
--- a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.csproj
+++ b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.csproj
@@ -2,7 +2,7 @@
net6.0
- 1.2.0
+ 2.0.0
Frends
Frends
Frends
@@ -19,6 +19,8 @@
PreserveNewest
+
+
diff --git a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/migration.json b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/migration.json
new file mode 100644
index 0000000..fed46ef
--- /dev/null
+++ b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/migration.json
@@ -0,0 +1,23 @@
+[
+ {
+ "Task": "Frends.JSON.ConvertXMLStringToJToken.JSON.ConvertXMLStringToJToken",
+ "Migrations": [
+ {
+ "Version": "2.0.0",
+ "Description": "Moved the XSD parameter from the Input tab to the Options tab.",
+ "Migration": [
+ {
+ "Type": "Copy",
+ "Source": "Input.XSD",
+ "Target": "Options.XSD"
+ },
+ {
+ "Type": "Set",
+ "Target": "Options.TypeCorrection",
+ "Value": "Schema"
+ }
+ ]
+ }
+ ]
+ }
+]