Regarding your first question:
How to define the model above, policy, or converter in System.Text.Json to be flexible enough to correctly map variable JSON formats above to the same client model?
To make deserialization support both title and camel case, set JsonSerializerOptions.PropertyNameCaseInsensitive = true
.
To make deserialization support both title/camel and snake case, in .NET 7 and later you can use contract customization to add a modifier that adds synthetic set-only properties with names defined by some alternate naming policy.
First, define the following modifier:
public static partial class JsonExtensions
{
public static Action<JsonTypeInfo> AddAlternateNamingPolicy(JsonNamingPolicy namingPolicy) => typeInfo =>
{
if (typeInfo.Kind != JsonTypeInfoKind.Object)
return;
var allNames = typeInfo.Properties.Select(p => p.Name).ToHashSet();
for (int i = 0, n = typeInfo.Properties.Count; i < n; i++)
{
var property = typeInfo.Properties[i];
if (property.Set == null || property.IsExtensionData || !(property.GetMemberName() is {} name))
continue;
var jsonpropertynameattribute = property.AttributeProvider?.GetCustomAttributes(typeof(JsonPropertyNameAttribute), true).Cast<JsonPropertyNameAttribute>().SingleOrDefault();
if (jsonpropertynameattribute != null && jsonpropertynameattribute.Name != null)
{
// TODO: decide what you want to do if there is a [JsonPropertyName(name)] attribute applied to the property:
// 1. Skip the property since the override name should be used as-is without applying a naming policy (the current System.Text.Json behavior).
// 2. Apply the namingPolicy to the original member name.
// 2. Apply the namingPolicy to the override name.
}
var alternateName = namingPolicy.ConvertName(name);
if (alternateName == property.Name || allNames.Contains(alternateName))
continue;
var alternateProperty = typeInfo.CreateJsonPropertyInfo(property.PropertyType, alternateName);
alternateProperty.Set = property.Set;
alternateProperty.ShouldSerialize = static (_, _) => false;
alternateProperty.AttributeProvider = property.AttributeProvider;
alternateProperty.CustomConverter = property.CustomConverter;
alternateProperty.IsRequired = false;
alternateProperty.NumberHandling = property.NumberHandling;
typeInfo.Properties.Add(alternateProperty);
allNames.Add(alternateName);
}
};
public static string? GetMemberName(this JsonPropertyInfo property) => (property.AttributeProvider as MemberInfo)?.Name;
}
// SnakeCaseNamingPolicy and StringUtils taken from this answer https://stackoverflow.com/a/58576400
// By https://stackoverflow.com/users/4913418/muhammad-hannan
// To https://stackoverflow.com/questions/58570189/is-there-a-built-in-way-of-using-snake-case-as-the-naming-policy-for-json-in-asp
public class SnakeCaseNamingPolicy : JsonNamingPolicy
{
public static SnakeCaseNamingPolicy Instance { get; } = new SnakeCaseNamingPolicy();
public override string ConvertName(string name) =>
// Conversion to other naming convention goes here. Like SnakeCase, KebabCase etc.
name.ToSnakeCase();
}
public static class StringUtils
{
public static string ToSnakeCase(this string str) =>
string.Concat(str.Select((x, i) => i > 0 && char.IsUpper(x) ? "_" + x.ToString() : x.ToString())).ToLower();
}
And now you can deserialize with the following options:
var options = new JsonSerializerOptions
{
// Handle both camel case and title case by making property name deserialization case insensitive:
PropertyNameCaseInsensitive = true,
// Handle snake case also:
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers = { JsonExtensions.AddAlternateNamingPolicy(SnakeCaseNamingPolicy.Instance) },
},
// Other options as required.
NumberHandling = JsonNumberHandling.AllowReadingFromString, // Necessary to deserialize "5.0" to a double
Converters = { new BoolConverter() }, // Necessary to deserialize "true" to a bool
};
var demo = JsonSerializer.Deserialize<Demo>(json, options);
Notes:
In AddAlternateNamingPolicy()
you need to decide how to handle properties that have [JsonPropertyName(name)]
applied. Options include:
- Skipping the property since the override name should be used as-is without applying a naming policy (the current System.Text.Json behavior).
- Applying the naming policy to the original member name.
- Applying the naming policy to the override name.
JsonNumberHandling.AllowReadingFromString
is required to deserialize "5.0"
as a double
.
BoolConverter
from this answer to Automatic conversion of numbers to bools - migrating from Newtonsoft to System.Text.Json is required to deserialize "true"
to a bool
.
The properties of your Demo
type are all private. They need to be made public to be serialized via System.Text.Json.
Your second question How to make sure that method DeserializeAsync
always returns a model with default values instead of NULL when serialization didn't go well? is unrelated and should be asked separately.
Demo fiddle here.