0

There is a client app sending requests to some service, maybe even several ones. The service is a 3rd party, not very reliable, and can make changes in the field names often. Client model.

class Demo 
{
  string SomeName { get; set; }
  double SomeValue { get; set; }
  bool HasValue { get; set; }
}

Possible variations of JSON coming from the service.

{ "SomeName": "X", "SomeValue": "5.0", "HasValue": "true" } // TitleCase
or
{ "someName": "X", "someValue": "5.0", "hasValue": "true" } // camelCase
or
{ "some_name": "X", "some_value": "5.0", "has_value": "true" } // snake_case
or
{ "SomeName": "X", "someValue": "5.0", "has_value": "true" } // mix
  1. 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?
  2. How to make sure that method DeserializeAsync always returns a model with default values instead of NULL when serialization didn't go well?

P.S. The question is only about parsing / reading / deserializing JSON. Serialization / writing is not needed.

dbc
  • 104,963
  • 20
  • 228
  • 340
Anonymous
  • 1,823
  • 2
  • 35
  • 74
  • It is better to use Newtonsoft.Json since you need to convert not only names, but values too. Text.Json will be the mess. – Serge Mar 18 '23 at 16:47
  • As it is, this question may be too broad for stack overflow. The preferred format here is [one question per post](https://meta.stackexchange.com/q/222735) but you are asking two unrelated question, one about handling camel case and snake case simultaneously, and one about initializing properties to default values. You're more likely to get an answer to one of these two questions if you split your post into two. – dbc Mar 18 '23 at 17:10
  • For instance, I could answer the first question, but I don't currently have an answer for the second. – dbc Mar 18 '23 at 18:04

4 Answers4

1

Quick and dirty approach would combining case-insensitive deserialization with "duplicate" properties for snake-case names:

class SomeNameHolder
{
    public string SomeName { get; set; }
    
    [JsonPropertyName("some_name")]
    public string SomeName2 { set => SomeName = value; }
}

var js = """
[
    { "SomeName": "X"},
    { "someName": "X"} ,
    { "some_name": "X"}
]
""";

var someNameHolders = JsonSerializer.Deserialize<SomeNameHolder[]>(js, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });

Console.WriteLine(someNameHolders.All(smh => smh.SomeName == "X")); // prints "True"

Note that this potentially can be a bit brittle (i.e. it will ignore casing completely making somename, SoMeNaMe, etc. will be deserialized to the same property)

Another approach could be to write custom converter.

Guru Stron
  • 102,774
  • 10
  • 95
  • 132
  • Thanks, it definitely makes sense for small models, but duplicating each field with a wrapper in a big model might be an [overkill](https://github.com/NVentimiglia/TDAmeritrade.DotNetCore/blob/main/TDAmeritrade/Models/TDOptionChainModels.cs) – Anonymous Mar 20 '23 at 00:39
1

Sorry, bu I am using Newtonsoft.Json since I am not a masochist. At first I am fixing a json string

public string GetFixedJson(string json)
{
    var sb = new StringBuilder();
    using (var sw = new StringWriter(sb))
    using (var jsonWriter = new JsonTextWriter(sw))
    {
        jsonWriter.Formatting = Newtonsoft.Json.Formatting.Indented;
        using (var reader = new JsonTextReader(new StringReader(json)))
        {
            while (reader.Read())
            {
                if (reader.TokenType.ToString() == "StartObject")
                    jsonWriter.WriteStartObject();
                else if (reader.TokenType.ToString() == "EndObject")
                    jsonWriter.WriteEndObject();
               // else if ..."StartArray" // if you have
                else
                {
                    if (reader.TokenType.ToString() == "PropertyName")
                        jsonWriter.WritePropertyName(ConvertName((string)reader.Value));
                    else
                        jsonWriter.WriteValue(reader.Value);
                }
            }
            reader.Close();
        }
        jsonWriter.Close();
    }

    return sb.ToString();
}

public string ConvertName(string name)
{
    if (name.IndexOf("_") > 0)
    {
        var n = name.ToLower().Replace("_", " ");
        TextInfo info = CultureInfo.CurrentCulture.TextInfo;
        name = info.ToTitleCase(n).Replace(" ", string.Empty);
    }
    else
    if (name[0] == char.ToLower(name[0])) name = char.ToUpper(name[0]) + name.Substring(1);
    return name;
}

now you have this fixed json string

{
  "SomeName": "X",
  "SomeValue": "5.0",
  "HasValue": "true"
}

after this I am using Newtonsoft.Json to deserialize fixed json to a c#. You can use a Text.Json if you want, but you will need to write a dozen custom converters, to convert string values to c#

string json = "{ \"SomeName\": \"X\", \"someValue\": \"5.0\", \"has_value\": \"true\" }";

Demo demo = JsonConvert.DeserializeObject<Demo>(GetFixedJson(json));
Serge
  • 40,935
  • 4
  • 18
  • 45
1

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:

  1. 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.
  2. JsonNumberHandling.AllowReadingFromString is required to deserialize "5.0" as a double.

  3. 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.

  4. The properties of your Demo type are all private. They need to be made public to be serialized via System.Text.Json.

  5. 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.

dbc
  • 104,963
  • 20
  • 228
  • 340
0

For customizing JSON deserialization you can use JsonConverter class. So any further changes in the api spec can be handled in one location.

you can read more about customizing json serialization/deserialization in this article from Microsfot Learning: https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/converters-how-to?pivots=dotnet-7-0

As mentioned in that content:

You can also write custom converters to customize or extend System.Text.Json with functionality not included in the current release. The following scenarios are covered later in this article:

Deserialize inferred types to object properties. Support polymorphic deserialization. Support round-trip for Stack. Support enum string value deserialization. Use default system converter.