Reputation: 35963
I am using JsonNode.ToJsonString(...) in my unit tests. Unfortunately the property order in the serialized string seems to be depend the runtime order how the child nodes and value nodes were added.
However to phrase a unit test expectation the order would be deterministic to allow to be equal to the expectation.
I reviewed the serializer options but did not find anything related
Question
Is there any way to JsonNode.ToJsonString(...) produce properties in alphabetic order?
Upvotes: 2
Views: 667
Reputation: 3361
Normalize()
I wanted to compare JsonNodes so I wrote a "Normalize()" extension method (and then used JsonNode.DeepEquals()
). I'm using ASP.NET 8.
This could be used for serialization purposes.
/// Returns a new JsonNode that will compare and serialize consistently independent of object property ordering.
public static JsonNode? Normalize(this JsonNode? node)
{
return node switch
{
JsonArray arr => NormArr(arr),
JsonObject obj => NormObj(obj),
_ => node?.DeepClone()
};
JsonObject NormObj(JsonObject obj)
{
var result = new SortedList<string, KeyValuePair<string, JsonNode?>>(obj.Count);
foreach (var kv in obj)
{
var normNode = Normalize(kv.Value);
result.Add(kv.Key, new KeyValuePair<string, JsonNode?>(kv.Key, normNode));
}
return new JsonObject(result.Values);
}
JsonArray NormArr(JsonArray arr)
{
var result = new JsonNode?[arr.Count];
for (var i = result.Length - 1; i >= 0; i--)
{
result[i] = Normalize(arr[i]);
}
return new JsonArray(result);
}
}
Some NUnit tests FWIW:
[TestCase("""{"a":"x"}""", """{"a":"x"}""", ExpectedResult = true)]
[TestCase("""{"a":"x", "b": "y"}""", """{"b": "y","a":"x"}""", ExpectedResult = true)]
[TestCase("""[{"a":"x", "b": "y"}]""", """[{"b": "y","a":"x"}]""", ExpectedResult = true)]
[TestCase("""[{"a":{"l": 2, "m": 3}, "b": "y"}]""", """[{"b": "y","a":{"m": 3,"l": 2}}]""", ExpectedResult = true)]
[TestCase("""[{"a":"x", "b": "y"}, null]""", """[{"b": "y","a":"x"}, null]""", ExpectedResult = true)]
[TestCase("""[null, {"a":"x", "b": "y"}]""", """[{"b": "y","a":"x"}, null]""", ExpectedResult = false)]
public bool NormalizeTests(string json1, string json2)
{
var node1 = JsonSerializer.Deserialize<JsonNode>(json1).Normalize();
var node2 = JsonSerializer.Deserialize<JsonNode>(json2).Normalize();
return JsonNode.DeepEquals(node1, node2);
}
Upvotes: 0
Reputation: 117190
There is no built-in functionality to do this, however you could create a custom JsonConverter<JsonNode>
that alphabetizes the properties as they are written. Then you could serialize your node to JSON using JsonSerializer.Serialize(node, options)
with the converter added to JsonSerializerOptions.Converters
.
First, define the following extension method and converter:
public static partial class JsonExtensions
{
readonly static JsonSerializerOptions defaultOptions = new () { Converters = { AlphabeticJsonNodeConverter.Instance } };
public static string ToAlphabeticJsonString(this JsonNode? node, JsonSerializerOptions? options = default)
{
if (options == null)
options = defaultOptions;
else
{
options = new JsonSerializerOptions(options);
options.Converters.Insert(0, AlphabeticJsonNodeConverter.Instance);
}
return JsonSerializer.Serialize(node, options);
}
}
public class AlphabeticJsonNodeConverter : JsonConverter<JsonNode>
{
public static AlphabeticJsonNodeConverter Instance { get; } = new AlphabeticJsonNodeConverter();
public override bool CanConvert(Type typeToConvert) => typeof(JsonNode).IsAssignableFrom(typeToConvert) && typeToConvert != typeof(JsonValue);
public override void Write(Utf8JsonWriter writer, JsonNode? value, JsonSerializerOptions options)
{
switch (value)
{
case JsonObject obj:
writer.WriteStartObject();
foreach (var pair in obj.OrderBy(p => p.Key, StringComparer.Ordinal))
{
writer.WritePropertyName(pair.Key);
Write(writer, pair.Value, options);
}
writer.WriteEndObject();
break;
case JsonArray array: // We need to handle JsonArray explicitly to ensure that objects inside arrays are alphabetized
writer.WriteStartArray();
foreach (var item in array)
Write(writer, item, options);
writer.WriteEndArray();
break;
case null:
writer.WriteNullValue();
break;
default: // JsonValue
value.WriteTo(writer, options);
break;
}
}
public override JsonNode? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => JsonNode.Parse(ref reader);
}
And now you will be able to do:
var options = new JsonSerializerOptions
{
WriteIndented = true, // Or false, if you prefer
};
var json = node.ToAlphabeticJsonString(options);
Notes:
I used StringComparer.Ordinal
because it seems to corresponds to the sorting requirement from RFC 8785: JSON Canonicalization Scheme (JCS).
While the above converter will work with any JsonNode
returned from JsonNode.Parse()
, created from a JsonElement
, or built up from primitive keys and values as shown in the doc example, it is also possible to create a JsonValue
with any POCO at all as its value, e.g.:
var node = JsonValue.Create(new { ZProperty = "foo", AProperty = "bar" })!;
Such cases get formatted to JSON as objects, not a primitive values:
{ "ZProperty": "foo", "AProperty": "bar" }
I don't know why Microsoft provided such functionality, but when formatting such a node to JSON the code invokes the serializer to serialize its contents. Thus the converter above will not be invoked to alphabetize properties in such a strange case.
As an alternative to alphabetizing JsonNode
properties while writing, you could use JsonElementComparer
from this answer to What is equivalent in JToken.DeepEquals in System.Text.Json? to compare JSON elements and ignore property order.
Demo fiddle here.
Upvotes: 4