From c26521b6c1ae5bc160a7d6907ef3b49c63b6b22c Mon Sep 17 00:00:00 2001 From: jefim Date: Wed, 27 May 2026 16:21:32 +0300 Subject: [PATCH 1/6] feat: add XML value type correction (xsi:type / XSD) [2.0.0] Add Options.TypeCorrection (None/Attributes/Schema) to convert numeric and boolean XML values into native JSON types, driven either by inline xsi:type attributes or by the supplied XSD. BREAKING: the XSD parameter moves from the Input tab to the Options tab and is only used in Schema mode. migration.json copies Input.XSD -> Options.XSD and sets TypeCorrection=Schema; tenants without migration support must apply the manual upgrade path documented in CHANGELOG. Schema mode with a blank XSD is a graceful no-op rather than an error. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CHANGELOG.md | 10 + .../UnitTests.cs | 190 +++++++++++++- .../ConvertXMLStringToJToken.cs | 240 ++++++++++++++++-- .../Definitions/Input.cs | 9 - .../Definitions/Options.cs | 52 ++++ ...rends.JSON.ConvertXMLStringToJToken.csproj | 4 +- .../migration.json | 23 ++ 7 files changed, 498 insertions(+), 30 deletions(-) create mode 100644 Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/Definitions/Options.cs create mode 100644 Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/migration.json 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..44cf723 100644 --- a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.Tests/UnitTests.cs +++ b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.Tests/UnitTests.cs @@ -25,7 +25,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 +40,12 @@ public void ShouldUseXsdToMapSingleElementAsArray() Alan - ", + " + }; + + var options = new Options() + { + TypeCorrection = TypeCorrectionMode.Schema, XSD = @" @@ -59,7 +64,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 +78,183 @@ 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 }); + var price = ((JObject)result.Jtoken)["root"]?["price"]; + + // Default behaviour is unchanged: the xsi:type wrapper and string value are preserved. + Assert.IsInstanceOfType(price, typeof(JObject)); + 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_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); + 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(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 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 }); + var price = ((JObject)result.Jtoken)["root"]?["price"]; + + Assert.IsTrue(result.Success); + Assert.AreEqual(JTokenType.String, price.Type); + Assert.AreEqual("12.5", price.ToString()); + } } diff --git a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/ConvertXMLStringToJToken.cs b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/ConvertXMLStringToJToken.cs index e9731f4..cc8f60d 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,63 @@ 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 XsiTypePropertyName = "@xsi:type"; + + /// + /// 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); + + if (options.TypeCorrection != TypeCorrectionMode.None) + CorrectTypes(token); + + return new Result(true, token); } private static XmlDocument LoadXmlDocument(string xml) @@ -40,7 +86,7 @@ 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); @@ -56,7 +102,7 @@ private static XmlDocument LoadXmlDocumentWithSchemaHints(string xml, string xsd }, true); - AddJsonArrayAttributesFromSchema(xDocument); + AddSchemaHints(xDocument, typeCorrection); var xmlDocument = new XmlDocument(); @@ -75,35 +121,195 @@ 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) + { + switch (token) + { + case JArray array: + foreach (var item in array.Children().ToList()) + CorrectTypes(item); + break; + + case JObject obj: + foreach (var value in obj.PropertyValues().ToList()) + CorrectTypes(value); + ApplyTypeHint(obj); + break; } + } - var existing = document.Root.Attributes() - .FirstOrDefault(a => - a.IsNamespaceDeclaration && - a.Value == JsonNamespace); + private static void ApplyTypeHint(JObject obj) + { + var typeProperty = obj.Property(XsiTypePropertyName, StringComparison.Ordinal); + 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 converted = convert(textProperty.Value.ToString()); + if (converted == null) + return; - if (hasArray && existing == null) + 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 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) + { + switch (value.Trim()) { - document.Root.SetAttributeValue( - XNamespace.Xmlns + "json", - JsonNamespace); + case "true": + case "1": + return new JValue(true); + case "false": + case "0": + return new JValue(false); + default: + 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..efe07ee --- /dev/null +++ b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/Definitions/Options.cs @@ -0,0 +1,52 @@ +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, +} + +/// +/// 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; } +} 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" + } + ] + } + ] + } +] From 976139b58026b481612e02f27526693bc8703fde Mon Sep 17 00:00:00 2001 From: jefim Date: Thu, 28 May 2026 15:39:45 +0300 Subject: [PATCH 2/6] feat: add ActionOnBadValues option for strict type correction Adds a BadValueAction enum (Ignore | Throw) shown via UIHint when TypeCorrection is Attributes or Schema. When Throw, an unparseable value with a known numeric/boolean xsi:type raises a FormatException identifying the element path and target type. Default stays Ignore to preserve existing behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../UnitTests.cs | 120 ++++++++++++++++++ .../ConvertXMLStringToJToken.cs | 20 ++- .../Definitions/Options.cs | 28 ++++ 3 files changed, 161 insertions(+), 7 deletions(-) diff --git a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.Tests/UnitTests.cs b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.Tests/UnitTests.cs index 44cf723..d4c2bce 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; @@ -240,6 +241,125 @@ public void SchemaMode_ShouldConvertSingleElementArraysToTypedArrays() 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_WithoutXsd_ShouldNoOpAndKeepStrings() { diff --git a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/ConvertXMLStringToJToken.cs b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/ConvertXMLStringToJToken.cs index cc8f60d..b747567 100644 --- a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/ConvertXMLStringToJToken.cs +++ b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/ConvertXMLStringToJToken.cs @@ -74,7 +74,7 @@ public static Result ConvertXMLStringToJToken([PropertyTab] Input input, [Proper var token = JToken.Parse(jsonString); if (options.TypeCorrection != TypeCorrectionMode.None) - CorrectTypes(token); + CorrectTypes(token, options.ActionOnBadValues); return new Result(true, token); } @@ -206,24 +206,24 @@ or XmlTypeCode.UnsignedByte /// removing the consumed xsi:type attribute and collapsing the wrapper object when only the /// converted value remains. /// - private static void CorrectTypes(JToken token) + private static void CorrectTypes(JToken token, BadValueAction actionOnBadValues) { switch (token) { case JArray array: foreach (var item in array.Children().ToList()) - CorrectTypes(item); + CorrectTypes(item, actionOnBadValues); break; case JObject obj: foreach (var value in obj.PropertyValues().ToList()) - CorrectTypes(value); - ApplyTypeHint(obj); + CorrectTypes(value, actionOnBadValues); + ApplyTypeHint(obj, actionOnBadValues); break; } } - private static void ApplyTypeHint(JObject obj) + private static void ApplyTypeHint(JObject obj, BadValueAction actionOnBadValues) { var typeProperty = obj.Property(XsiTypePropertyName, StringComparison.Ordinal); if (typeProperty == null) @@ -237,9 +237,15 @@ private static void ApplyTypeHint(JObject obj) if (textProperty == null || textProperty.Value.Type != JTokenType.String) return; - var converted = convert(textProperty.Value.ToString()); + 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(); diff --git a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/Definitions/Options.cs b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/Definitions/Options.cs index efe07ee..41abe0b 100644 --- a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/Definitions/Options.cs +++ b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/Definitions/Options.cs @@ -26,6 +26,23 @@ public enum TypeCorrectionMode 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. /// @@ -49,4 +66,15 @@ public class Options /// [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; } From 3c7dca745e739f082e653201817d3f98d771ab13 Mon Sep 17 00:00:00 2001 From: jefim Date: Thu, 28 May 2026 16:04:09 +0300 Subject: [PATCH 3/6] fix: address PR #37 review feedback - ParseBoolean is now case-insensitive for 'true'/'false' (Matteo). - Schema-without-XSD is a true no-op: CorrectTypes is no longer invoked, so inline xsi:type can no longer sneak through (CodeRabbit). - In Schema mode, inline xsi:type attributes are stripped from the document before validation, so XSD is the single source of truth and an unqualified xsi:type can't trip the validator (Matteo). - ApplyTypeHint resolves @:type against the active xmlns scope, so authors using xmlns:i (instead of the conventional xsi) get their type hints honoured in both Attributes and Schema modes (CodeRabbit). - Tightened nullable guards in older tests; added 9 new tests covering the above (case-insensitive booleans, schema-no-op-with-inline, schema-wins, schema-strips-on-string, aliased-prefix Attributes/Schema, unrelated-prefix ignored, prefix-on-inner-element). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../UnitTests.cs | 232 +++++++++++++++++- .../ConvertXMLStringToJToken.cs | 128 ++++++++-- 2 files changed, 336 insertions(+), 24 deletions(-) diff --git a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.Tests/UnitTests.cs b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.Tests/UnitTests.cs index d4c2bce..195f0a1 100644 --- a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.Tests/UnitTests.cs +++ b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.Tests/UnitTests.cs @@ -91,10 +91,12 @@ public void NoneMode_ShouldKeepValuesAsStrings() }; var result = JSON.ConvertXMLStringToJToken(input, new Options { TypeCorrection = TypeCorrectionMode.None }); - var price = ((JObject)result.Jtoken)["root"]?["price"]; + 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.IsInstanceOfType(price, typeof(JObject)); + Assert.IsNotNull(price); Assert.AreEqual(JTokenType.String, price["#text"].Type); Assert.AreEqual("12.5", price["#text"].ToString()); } @@ -148,6 +150,97 @@ public void AttributesMode_ShouldKeepOtherAttributesWhileTypingText() 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 AttributesMode_ShouldLeaveUnparseableValuesAsStrings() { @@ -197,9 +290,11 @@ public void SchemaMode_ShouldConvertValuesUsingXsdTypes() }; var result = JSON.ConvertXMLStringToJToken(input, options); - var root = (JObject)((JObject)result.Jtoken)["root"]; - 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); @@ -360,6 +455,107 @@ public void AttributesMode_ThrowAction_ShouldNotThrowOnUnknownXsiType() 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() { @@ -371,10 +567,34 @@ public void SchemaMode_WithoutXsd_ShouldNoOpAndKeepStrings() // 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 }); - var price = ((JObject)result.Jtoken)["root"]?["price"]; - 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()); + } } diff --git a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/ConvertXMLStringToJToken.cs b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/ConvertXMLStringToJToken.cs index b747567..7834e45 100644 --- a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/ConvertXMLStringToJToken.cs +++ b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/ConvertXMLStringToJToken.cs @@ -23,7 +23,10 @@ public class JSON private const string XsiNamespace = "http://www.w3.org/2001/XMLSchema-instance"; private const string TextPropertyName = "#text"; - private const string XsiTypePropertyName = "@xsi:type"; + 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. @@ -73,7 +76,10 @@ public static Result ConvertXMLStringToJToken([PropertyTab] Input input, [Proper var jsonString = JsonConvert.SerializeXmlNode(doc); var token = JToken.Parse(jsonString); - if (options.TypeCorrection != TypeCorrectionMode.None) + // 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. + if (options.TypeCorrection == TypeCorrectionMode.Attributes || useSchema) CorrectTypes(token, options.ActionOnBadValues); return new Result(true, token); @@ -92,6 +98,12 @@ private static XmlDocument LoadXmlDocumentWithSchemaHints(string xml, string 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) => @@ -112,6 +124,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(); @@ -207,25 +235,61 @@ or XmlTypeCode.UnsignedByte /// converted value remains. /// private static void CorrectTypes(JToken token, BadValueAction actionOnBadValues) + { + CorrectTypes(token, actionOnBadValues, EmptyXmlnsScope); + } + + private static void CorrectTypes( + JToken token, + BadValueAction actionOnBadValues, + IReadOnlyDictionary xmlnsScope) { switch (token) { case JArray array: foreach (var item in array.Children().ToList()) - CorrectTypes(item, actionOnBadValues); + CorrectTypes(item, actionOnBadValues, xmlnsScope); break; case JObject obj: + var childScope = ExtendXmlnsScope(obj, xmlnsScope); foreach (var value in obj.PropertyValues().ToList()) - CorrectTypes(value, actionOnBadValues); - ApplyTypeHint(obj, actionOnBadValues); + CorrectTypes(value, actionOnBadValues, childScope); + ApplyTypeHint(obj, actionOnBadValues, childScope); break; } } - private static void ApplyTypeHint(JObject obj, BadValueAction actionOnBadValues) + /// + /// 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) { - var typeProperty = obj.Property(XsiTypePropertyName, StringComparison.Ordinal); + Dictionary extended = null; + + foreach (var prop in obj.Properties()) + { + 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; @@ -261,6 +325,36 @@ private static void ApplyTypeHint(JObject obj, BadValueAction actionOnBadValues) textProperty.Value = converted; } + /// + /// Finds the first attribute on this JObject named "@<prefix>:type" 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 hints honoured. + /// + private static JProperty FindXsiTypeProperty(JObject obj, 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, "type", 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; + } + private static bool IsXsiNamespaceDeclaration(JProperty property) { return property.Name.StartsWith("@xmlns", StringComparison.Ordinal) && @@ -306,16 +400,14 @@ private static JValue ParseInteger(string value) private static JValue ParseBoolean(string value) { - switch (value.Trim()) - { - case "true": - case "1": - return new JValue(true); - case "false": - case "0": - return new JValue(false); - default: - return null; - } + 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; } } From a87ef9f50ccdcfb4451721f78f95a776c9508a5b Mon Sep 17 00:00:00 2001 From: jefim Date: Fri, 29 May 2026 12:24:18 +0300 Subject: [PATCH 4/6] feat: add xsi:nil support for empty-element handling Newtonsoft preserves the wrapper for both xsi:nil="true" and "false" (it does not auto-collapse nil=true to null), so the task now post-processes the JSON tree to give authors control over how empty elements appear: - xsi:nil="true" empty -> JSON null (collapsing Newtonsoft's wrapper) - xsi:nil="true"+content -> content wins, nil flag stripped - xsi:nil="false" empty -> default(T) for the xsi:type or schema type (long 0 / double 0 / decimal 0 / bool false / "" for string) - xsi:nil="false"+content-> nil stripped, content flows through normally - xsi:nil="false" empty with no type info -> no-op (Attributes mode); for Schema mode the schema is itself type info, so empty xs:string yields "" Lookups are prefix-agnostic (xmlns:i + i:nil works the same as xsi:nil), threaded through an active xmlns scope that mirrors XML namespace scoping. None mode is untouched - xsi:nil wrappers pass through verbatim. Adds 12 unit tests covering each mode + edge case (33 tests total). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../UnitTests.cs | 224 ++++++++++++++++++ .../ConvertXMLStringToJToken.cs | 208 +++++++++++++++- 2 files changed, 420 insertions(+), 12 deletions(-) diff --git a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.Tests/UnitTests.cs b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.Tests/UnitTests.cs index 195f0a1..e63cefd 100644 --- a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.Tests/UnitTests.cs +++ b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.Tests/UnitTests.cs @@ -241,6 +241,230 @@ public void AttributesMode_ShouldResolvePrefixDeclaredOnInnerElement() 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()); + } + + [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() { diff --git a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/ConvertXMLStringToJToken.cs b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/ConvertXMLStringToJToken.cs index 7834e45..e203d34 100644 --- a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/ConvertXMLStringToJToken.cs +++ b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken/ConvertXMLStringToJToken.cs @@ -79,8 +79,14 @@ public static Result ConvertXMLStringToJToken([PropertyTab] Input input, [Proper // 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. - if (options.TypeCorrection == TypeCorrectionMode.Attributes || useSchema) - CorrectTypes(token, options.ActionOnBadValues); + 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); } @@ -234,27 +240,34 @@ or XmlTypeCode.UnsignedByte /// 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) + private static void CorrectTypes(JToken token, BadValueAction actionOnBadValues, TypeCorrectionMode mode) { - CorrectTypes(token, actionOnBadValues, EmptyXmlnsScope); + 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, xmlnsScope); + CorrectTypes(item, actionOnBadValues, mode, xmlnsScope); break; case JObject obj: var childScope = ExtendXmlnsScope(obj, xmlnsScope); foreach (var value in obj.PropertyValues().ToList()) - CorrectTypes(value, actionOnBadValues, childScope); + 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; } @@ -325,13 +338,22 @@ private static void ApplyTypeHint( 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>:type" 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 hints honoured. + /// 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 FindXsiTypeProperty(JObject obj, IReadOnlyDictionary xmlnsScope) + private static JProperty FindXsiNamespacedAttribute( + JObject obj, + string localName, + IReadOnlyDictionary xmlnsScope) { foreach (var prop in obj.Properties()) { @@ -343,7 +365,7 @@ private static JProperty FindXsiTypeProperty(JObject obj, IReadOnlyDictionary + /// 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) && From 434961844caafb23d5fe4a0f1b5772a84d3a363c Mon Sep 17 00:00:00 2001 From: jefim Date: Fri, 29 May 2026 12:39:05 +0300 Subject: [PATCH 5/6] feat: add string type handling for XML element in unit tests --- .../Frends.JSON.ConvertXMLStringToJToken.Tests/UnitTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.Tests/UnitTests.cs b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.Tests/UnitTests.cs index e63cefd..10022fb 100644 --- a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.Tests/UnitTests.cs +++ b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.Tests/UnitTests.cs @@ -310,6 +310,7 @@ public void AttributesMode_XsiNilFalse_EmptyElement_ShouldUseTypeDefault() + " }; @@ -322,6 +323,8 @@ public void AttributesMode_XsiNilFalse_EmptyElement_ShouldUseTypeDefault() 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] @@ -821,4 +824,4 @@ public void SchemaMode_WithoutXsd_ShouldNotConvertInlineXsiTypes() Assert.AreEqual(JTokenType.String, price["#text"].Type); Assert.AreEqual("12.5", price["#text"].ToString()); } -} +} \ No newline at end of file From 73dd783d9370d4cec4a215868916029db3cc21a2 Mon Sep 17 00:00:00 2001 From: jefim Date: Fri, 29 May 2026 12:42:46 +0300 Subject: [PATCH 6/6] fix: correct formatting in unit test assertions for XML string values --- .../Frends.JSON.ConvertXMLStringToJToken.Tests/UnitTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.Tests/UnitTests.cs b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.Tests/UnitTests.cs index 10022fb..384f0ec 100644 --- a/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.Tests/UnitTests.cs +++ b/Frends.JSON.ConvertXMLStringToJToken/Frends.JSON.ConvertXMLStringToJToken.Tests/UnitTests.cs @@ -324,7 +324,7 @@ public void AttributesMode_XsiNilFalse_EmptyElement_ShouldUseTypeDefault() 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()); + Assert.AreEqual(string.Empty, root["str"].Value()); } [TestMethod]