Json.More.Net aims to fill gaps left by System.Text.Json
. To this end, it supplies four additional functions.
Equality comparison
Sadly, it seems equality was considered unnecessary. To remedy that, the .IsEquivalentTo()
extension method is supplied for JsonDocument
, JsonElement
, and JsonNode
.
This extension method calculates the JSON-equality of the nodes. This means that objects are key-matched (unordered) and arrays are sequence-matched (ordered).
From json.org:
An object is an unordered set of name/value pairs.
and
An array is an ordered collection of values.
Additionally, an IEqualityComparer<JsonNode>
is supplied (JsonNodeEqualityComparer
) which has a convenient static Instance
property.
Comparers are also supplied for
JsonDocument
andJsonElement
.
Explicitly specifying JSON null with JsonNull
As of Json.More.Net v6, the
JsonNull
type has been removed. I still believe there needs to be a distinction between .Net null and JSONnull
, but trying to inject that distinction into a system that doesn’t use it creates confusion. I recommend using aTry...(..., out JsonNode? value)
pattern instead.
Because JsonNode
was designed to unify .Net null with JSON null, it’s difficult (and sometimes impossible) to determine when a JSON null is explicitly provided vs when it is merely the result of a missing property. Ordinarily (e.g. during deserialization) this isn’t much of a problem.
However, in the case you do need to distinguish between them, you can use the JsonNull.SignalNode
to indicate that JSON null has been explicitly provided.
Under the covers, it’s just a singleton JsonValue<JsonNull>
. Use ReferenceEquals(JsonNull.SignalNode, value)
to identify it.
This is provided exclusively as a signal. It is not intended to be saved. Best practice is to continue to save null. See the code for an example of proper usage.
Enum serialization
The EnumStringConverter<T>
class enables string encoding of enum values. T
is the enum type.
The default behavior is to simply encode the C# enum value name. This can be overridden with the System.ComponentModel.DescriptionAttribute
.
1
2
3
4
5
6
public enum MyEnum
{
Foo, // serializes as "Foo"
[Description("bar")]
Bar // serializes as "bar"
}
It also supports [Flags]
enums by encoding the base values into an array. Composite values will be serialized into an array of their respective components.
1
2
3
4
5
6
7
8
[Flags]
public enum MyFlagsEnum
{
Default,
Foo = 1,
Bar = 2,
Composite = 3 // serializes as ["Foo", "Bar"]
}
If your flags enum defines a name for the default (0) value, the converter will exclude it when there are other names present. For example,
Default
in the example above is omitted from the serialization ofComposite
.
To use this converter, apply the [JsonConverter(typeof(EnumStringConverter<T>))]
to either the enum or an enum-valued property.
Building better converters
Unfortunately, the most obvious way to deserialize nested properties inside a custom converter isn’t the recommended approach.
1
2
3
4
5
6
7
8
9
10
11
class MyJsonConverter : JsonConverter<MyClass>
{
public override MyClass Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var result = new MyClass();
// ...
result.Foo = JsonSerializer.Deserialize<Foo>(ref reader, options);
// ...
return result;
}
}
Calling JsonSerializer.Deserialize<T>(ref reader, options)
has a side effect of ruining the line number and position information that would be included in a JsonException
if something went wrong.
Instead, we’re supposed to get the appropriate converter through the options
parameter and invoke that directly.
1
2
3
4
5
6
7
8
9
10
11
12
class MyJsonConverter : JsonConverter<MyClass>
{
public override MyClass Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var result = new MyClass();
// ...
var fooConverter = (JsonConverter<Foo>)options.GetConverter(typeof(Foo));
result.Foo = fooConverter.Read(ref reader, typeof(Foo), options);
// ...
return result;
}
}
But that’s not so nice to read, and you don’t want to have to remember to do that in every converter.
To make our converters prettier, this library defines a couple extension methods on JsonSerialierOptions
to help:
.GetConverter<T>()
- returns the converter, already typed and ready to use..Read<T>()
- gets the converter and performs the read.
1
2
3
4
5
6
7
8
9
10
11
class MyJsonConverter : JsonConverter<MyClass>
{
public override MyClass Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var result = new MyClass();
// ...
result.Foo = options.Read(ref reader, typeof(Foo));
// ...
return result;
}
}
Much nicer!
Ahead of Time (AOT) compilation support
Building on the above JsonSerializerOptions
extensions, there are a number of serialization extensions that were added to support Native AOT applications.
When serializing under AOT, the recommended approach is to have source generation create a JsonTypeInfo<T>
object that you pass to the serializer. The JsonTypeInfo<T>
object has all of the information that would normally be discover at runtime through reflection, except that all of the introspection is done at compile time. That recommended serializer call looks like this:
1
JsonSerializer.Serialize(writer, value, MySerializerContext.Default.MyValue);
where MyValue
is the type of value
, and the property on the serializer context is generated code.
Conspicuously, the options are not passed into this call, which means any user preferences are ignored, and there are no overloads which allow passing the options.
In order to mitigate this, more .Read()
and .Write()
extensions have been created on JsonSerializerOptions
that use the JsonTypeInfo<T>
to fetch the appropriate converter, then call the appropriate method on the converter directly, passing in the options.
So the above code can be rewritten as:
1
options.Write(writer, value, MySerializerContext.Default.MyValue);
Working around a known System.Text.Json bug
There is a known issue in System.Text.Json. JsonTypeInfo
is strongly linked to the JsonSerializerOptions
that it’s generated with. The issue reports that attempting to use the type info with a different options object throws an exception.
There are a few variations of the .Read()
and .Write()
extensions that help work around this issue by handling arrays/lists and string-keyed dictionaries. We recommend using these extensions over adding the array/list and dictionary types to your serializer context.
So where you would have:
1
2
3
4
5
6
[JsonSerializable(typeof(MyType))]
[JsonSerializable(typeof(MyType[]))]
internal partial class MySerializerContext : JsonSerializerContext;
// later
options.Write(writer, value, MySerializerContext.Default.MyTypeArray);
use this instead:
1
2
3
4
5
[JsonSerializable(typeof(MyType))]
internal partial class MySerializerContext : JsonSerializerContext;
// later
options.WriteArray(writer, value, MySerializerContext.Default.MyType);
Weakly-typed serialization
The occasion may arise where you need to deserialize a value, but you don’t know what type the value is at compile time. Out of the box, this isn’t supported when requiring AOT-compatible serialization. The primary impediment to this is that JsonConverter
, the non-generic base for all JSON converters, doesn’t itself define a .Read()
or .Write()
method.
To address this, Json.More.Net provides the WeaklyTypedJsonConverter<T>
which you can use as a base class in your converter instead of just JsonConverter<T>
. This new converter base implements a new interface IWeaklyTypedJsonConverter<T>
that defines these non-generic methods.
You’ll likely only need to use this if you’re extending
json-everything
functionality, like making custom JSON Schema keywords or custom JSON Logic rules.
AOT-incompatibility
There is some functionality that could not be made AOT-compatible. All of this functionality has been appropriately marked with attributes that will generate compiler warnings in an AOT-required context.
Value tuple serialization
The JsonArrayTupleConverter
will handle any size ValueTuple<T>
/ValueType<T1, T2>
/etc. by serializing the values to and from an array.
Even though the
ValueTuple<T1, T2, ...>
classes only have up to eight generic parameters, the C# syntax(v1, v2, ...)
does support tuples of any size. This is accomplished by stuffing values past the first seven into anotherValueTuple<...>
as the eighth value. Just like the compiler, the converter will automatically handle this for you.
Using the converter is simple:
1
2
3
4
5
6
var options = new JsonSerializerOptions
{
Converters = { JsonArrayTupleConverter.Instance }
};
var json = JsonSerializer.Serialize((1, "string"), options); // [1,"string"]
var tuple = JsonSerializer.Deserialize<(int, string)>(json, options);
When deserializing, if the value isn’t an array or if array isn’t the right length for the requested tuple type, a JsonException
will be thrown.
Data conversions
.AsNode()
extension
Previous versions of the libraries in the json-everything
suite were built on JsonElement
. They have since been migrated to support JsonNode
directly.
While .Net provides ways to convert value directly into JsonObject
, JsonArray
, and JsonValue
, they neglected to provide a single way to convert any value into a JsonNode
, their base class. This extension provides that.
1
JsonNode? node = element.AsNode();
Note that this does potentially return null to handle the JSON null case.
.ToJsonArray()
extension
.Net provided JsonArray
with a constructor that takes an array of JsonNode?
, however they don’t support converting any enumerable of nodes into an array. This extension will handle that for you.
1
JsonArray array = new List<JsonNode?>{ 1, null, false }.ToJsonArray();
.AsJsonElement()
extension
Sometimes you just want a JsonElement
that represents a simple value, like a string, boolean, or number. This library exposes several overloads of the .AsJsonElement()
extension that can do this for you.
Supported types are:
bool
- Numeric types (e.g.
double
,decimal
,int
, etc.) string
IEnumerable<JsonElement>
(for arrays)IDicationary<string, JsonElement>
(for objects)
For example, to create an empty array, you can use
1
var emptyArray = new JsonElement[0].AsJsonElement();
To create an object with an 6
in the myInt
property:
1
2
3
var obj = new Dictionary<string, JsonElement>{
["myInt"] = 6.AsJsonElement()
}
Making methods that require JsonElement
easier to call
If you’re using
JsonNode
, you shouldn’t need this as it already defines implicit casts from the appropriate types.
The JsonElementProxy
class allows the client to define methods that expect a JsonElement
to be called with native types by defining implicit casts from those types into the JsonElementProxy
and then also an implicit cast from the proxy into JsonElement
.
Suppose you have this method:
1
2
3
4
5
6
void SomeMethod(JsonElement element)
{
...
DoSomething(element);
...
}
The only way to call this is by passing a JsonElement
directly. If you want to call it with a string
or int
, you have to resort to converting it with the .AsJsonElement()
extension method:
1
2
myObject.SomeMethod(1.AsJsonElement());
myObject.SomeMethod("string".AsJsonElement());
This gets noisy pretty quickly. But now we can define an overload that takes a JsonElementProxy
argument instead:
1
2
3
4
5
6
void SomeMethod(JsonElementProxy element)
{
...
DoSomething(element); // still only accepts JsonElement; doesn't need an overload
...
}
to allow callers to just use the raw value:
1
2
myObject.SomeMethod(1);
myObject.SomeMethod("string");
To achieve this without JsonElementProxy
, you could also create overloads for short
, int
, long
, float
, double
, decimal
, string
, and bool
.
JSON model serialization
The .Net team did a great job of supporting fast serialization, but for whatever reason they didn’t implement serializing their data model. The Utf8JsonWriterExtensions
class fills that gap.
This provides an extension method that writes a JsonElement
to the stream.
Building better converters
Unfortunately, the most obvious way to deserialize nested properties inside a custom converter isn’t the recommended approach.
1
2
3
4
5
6
7
8
9
10
11
class MyJsonConverter : JsonConverter<MyClass>
{
public override MyClass Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var result = new MyClass();
// ...
result.Foo = JsonSerializer.Deserialize<Foo>(ref reader, options);
// ...
return result;
}
}
Calling JsonSerializer.Deserialize<T>(ref reader, options)
has a side effect of ruining the line number and position information that would be included in a JsonException
if something went wrong.
Instead, we’re supposed to get the appropriate converter through the options
parameter and invoke that directly.
1
2
3
4
5
6
7
8
9
10
11
12
class MyJsonConverter : JsonConverter<MyClass>
{
public override MyClass Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var result = new MyClass();
// ...
var fooConverter = (JsonConverter<Foo>)options.GetConverter(typeof(Foo));
result.Foo = fooConverter.Read(ref reader, typeof(Foo), options);
// ...
return result;
}
}
But that’s not so nice to read, and you don’t want to have to remember to do that in every converter.
To make our converters prettier, this library defines a couple extension methods on JsonSerialierOptions
to help:
.GetConverter<T>()
- returns the converter, already typed and ready to use..Read<T>()
- gets the converter and performs the read.
1
2
3
4
5
6
7
8
9
10
11
class MyJsonConverter : JsonConverter<MyClass>
{
public override MyClass Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var result = new MyClass();
// ...
result.Foo = options.Read(ref reader, typeof(Foo));
// ...
return result;
}
}
Much nicer!