Search code examples
c#jsonjson.netasp.net-core-webapi

How can I force an exception to be thrown when deserializing a dictionary with duplicated keys from JSON?


I have a data model with a Dictionary<string, string> of attributes as follows:

public class Model
{
    public string Name { get; set; }
    public Dictionary<string, string> Attributes { get; set; }
}

Under certain rare circumstances, I am receiving JSON with duplicated property names for Attributes, e.g.:

{
   "name":"Object Name",
   "attributes":{
      "key1":"adfadfd",
      "key1":"adfadfadf"
   }
}

I would like for an exception to be thrown in such a situation, however when I deserialize with Json.NET there is no error and the dictionary instead contains the last value encountered. How can I force an error in such a situation?


As a workaround, I am currently declaring attributes as a list of key/value pairs:

    public List<KeyValuePair<string, string>> Attributes { get; set; 

This requires me to serialize the attributes in the following format:

"attributes": [
    {
        "key": "key1",
        "value": "adfadfd"
    },
    {
        "key": "key1",
        "value": "adfadfadf"
    }
]

Then later I can detect the duplicate. However, I would prefer to use the more compact JSON object syntax rather than the JSON array syntax, and declare Attributes as a dictionary.


Solution

  • It seems that, when deserializing a dictionary from a JSON object with duplicated property names, Json.NET (and also System.Text.Json) silently populate the dictionary with the value from the last duplicated key. (Demo here.) This is not entirely surprising, as JSON RFC 8259 states:

    When the names within an object are not unique, the behavior of software that receives such an object is unpredictable. Many implementations report the last name/value pair only...

    Since you don't want that, you can create a custom JsonConverter that throws an error in the event of duplicated property names:

    public class NoDuplicateKeysDictionaryConverter<TValue> : NoDuplicateKeysDictionaryConverter<Dictionary<string, TValue>, TValue> 
    {
    }
    
    public class NoDuplicateKeysDictionaryConverter<TDictionary, TValue> : JsonConverter<TDictionary> where TDictionary : IDictionary<string, TValue>
    {
        public override TDictionary ReadJson(JsonReader reader, Type objectType, TDictionary existingValue, bool hasExistingValue, JsonSerializer serializer)
        {
            if (reader.MoveToContentAndAssert().TokenType == JsonToken.Null)
                return typeof(TDictionary).IsValueType && Nullable.GetUnderlyingType(typeof(TDictionary)) == null ? throw new JsonSerializationException("null value") : default;
            reader.AssertTokenType(JsonToken.StartObject);
            var dictionary = existingValue ?? (TDictionary)serializer.ContractResolver.ResolveContract(typeof(TDictionary)).DefaultCreator();
            // Todo: decide whether you want to clear the incoming dictionary.
            while (reader.ReadToContentAndAssert().TokenType != JsonToken.EndObject)
            {
                var key = (string)reader.AssertTokenType(JsonToken.PropertyName).Value;
                var value = serializer.Deserialize<TValue>(reader.ReadToContentAndAssert());
                // https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.idictionary-2.add#exceptions
                // Add() will throw an ArgumentException when an element with the same key already exists in the IDictionary<TKey,TValue>.
                dictionary.Add(key, value);
            }
            return dictionary;
        }
    
        public override bool CanWrite => false;
        public override void WriteJson(JsonWriter writer, TDictionary value, JsonSerializer serializer) => throw new NotImplementedException();
    }
    
    public static partial class JsonExtensions
    {
        public static JsonReader AssertTokenType(this JsonReader reader, JsonToken tokenType) => 
            reader.TokenType == tokenType ? reader : throw new JsonSerializationException(string.Format("Unexpected token {0}, expected {1}", reader.TokenType, tokenType));
        
        public static JsonReader ReadToContentAndAssert(this JsonReader reader) =>
            reader.ReadAndAssert().MoveToContentAndAssert();
    
        public static JsonReader MoveToContentAndAssert(this JsonReader reader)
        {
            if (reader == null)
                throw new ArgumentNullException();
            if (reader.TokenType == JsonToken.None)       // Skip past beginning of stream.
                reader.ReadAndAssert();
            while (reader.TokenType == JsonToken.Comment) // Skip past comments.
                reader.ReadAndAssert();
            return reader;
        }
    
        public static JsonReader ReadAndAssert(this JsonReader reader)
        {
            if (reader == null)
                throw new ArgumentNullException();
            if (!reader.Read())
                throw new JsonReaderException("Unexpected end of JSON stream.");
            return reader;
        }
    }
    

    Then add it to your model as follows:

        [Newtonsoft.Json.JsonConverter(typeof(NoDuplicateKeysDictionaryConverter<string>))]
        public Dictionary<string, string> Attributes { get; set; }
    

    And an ArgumentException will be thrown whenever an attempt is made to add duplicated keys to the dictionary.

    Demo fidde here.