Search code examples
c#asp.net-coresystem.text.json

ASP.NET Core - System.Text.Json: how to reject unknown properties in payload?


Web API in ASP.NET Core 7 with System.Text.Json:

I need to reject JSON payloads on PUT/POST APIs which have additional properties specified which do not map to any properties in the model.

So if my model is

public class Person {
  public string Name { get; set; }
}

I need to reject any payloads (with a 400-Bad Request error) which look like this

{
  "name": "alice",
  "lastname": "bob"
}

How can this be achieved?


Solution

  • Update: In .NET 8 and later see this answer by Kyle McClellan. Applying [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] will tell the serializer to throw an exception when encountering unknown JSON payload properties.

    In .NET 7 and earlier, System.Text.Json does not have an option equivalent to Json.NET's MissingMemberHandling.Error functionality to force an error when the JSON being deserialized has an unmapped property. For confirmation, see:

    However, even though the official .NET 7 documentation states that there's no workaround for the missing member feature, you can make use of the the [JsonExtensionData] attribute to emulate MissingMemberHandling.Error.

    Firstly, if you only have a few types for which you want to implement MissingMemberHandling.Error, you could add an extension data dictionary then check whether it contains contents and throw an exception in an JsonOnDeserialized.OnDeserialized() callback, or in your controller as suggested by this answer by Michael Liu.

    Secondly, if you need to implement MissingMemberHandling.Error for every type, in .NET 7 and later you could add a DefaultJsonTypeInfoResolver modifier that adds a synthetic extension data property that throws an error on an unknown property.

    To do this, define the following extension method:

    public static class JsonExtensions
    {
        public static DefaultJsonTypeInfoResolver AddMissingMemberHandlingError(this DefaultJsonTypeInfoResolver resolver)
        {
            resolver.Modifiers.Add(typeInfo => 
                                   {
                                       if (typeInfo.Kind != JsonTypeInfoKind.Object)
                                           return;
                                       if (typeInfo.Properties.Any(p => p.IsExtensionData))
                                           return;
                                       var property = typeInfo.CreateJsonPropertyInfo(typeof(Dictionary<string, JsonElement>), "<>ExtensionData");
                                       property.IsExtensionData = true;
                                       property.Get = static (obj) => null;
                                       property.Set = static (obj, val) => 
                                       {
                                           var dictionary = (Dictionary<string, JsonElement>?)val;
                                           Console.WriteLine(dictionary?.Count);
                                           if (dictionary != null)
                                               throw new JsonException();
                                       };
                                       typeInfo.Properties.Add(property);
                                   });
            return resolver;
        }
    }
    

    And then configure your options as follows:

    var options = new JsonSerializerOptions
    {
        TypeInfoResolver = new DefaultJsonTypeInfoResolver()
            .AddMissingMemberHandlingError(),
    };
    

    Having done so, a JsonException will be thrown when an missing JSON property is encountered. Note however that Systen.Text.Json sets the allocated dictionary before it is populated, so you won't be able to include the missing member name in the exception message when using this workaround.

    Demo fiddle here.

    If you need to implement MissingMemberHandling.Error for every type and also need the exception error message to include the name of the unknown property, it can be done by defining a custom dictionary type that throws a custom exception whenever an attempt to add anything to the dictionary is made. Then use that custom dictionary type as the extension dictionary type in the synthetic extension property added by your contract modifier like so:

    // A JsonException subclass that allows for a custom message that includes the path, line number and byte position.
    public class JsonMissingMemberException : JsonException
    {
        readonly string? innerMessage;
        public JsonMissingMemberException() : this(null) { }
        public JsonMissingMemberException(string? innerMessage) : base(innerMessage) => this.innerMessage = innerMessage;
        public JsonMissingMemberException(string? innerMessage, Exception? innerException) : base(innerMessage, innerException) => this.innerMessage = innerMessage;
        protected JsonMissingMemberException(SerializationInfo info, StreamingContext context) : base(info, context) => this.innerMessage = (string?)info.GetValue("innerMessage", typeof(string));
        public override string Message =>
            innerMessage == null
                ? base.Message
                : String.Format("{0} Path: {1} | LineNumber: {2} | BytePositionInLine: {3}.", innerMessage, Path, LineNumber, BytePositionInLine);
        public override void GetObjectData(SerializationInfo info, StreamingContext context)
        {
            base.GetObjectData(info, context);
            info.AddValue("innerMessage", innerMessage);
        }
    }
    
    public static class JsonExtensions
    {
        class UnknownPropertyDictionary<TModel> : IDictionary<string, JsonElement>
        {       
            static JsonException CreateException(string key, JsonElement value) =>
                new JsonMissingMemberException(String.Format("Unexpected property \"{0}\" encountered while deserializing type {1}.", key, typeof(TModel).FullName));
            
            public void Add(string key, JsonElement value) => throw CreateException(key, value);
            public bool ContainsKey(string key) => false;
            public ICollection<string> Keys => Array.Empty<string>();
            public bool Remove(string key) => false; 
                                        
            public bool TryGetValue(string key, [System.Diagnostics.CodeAnalysis.MaybeNullWhen(false)] out JsonElement value) { value = default; return false; }
            public ICollection<JsonElement> Values => Array.Empty<JsonElement>();
            public JsonElement this[string key]
            {
                get => throw new KeyNotFoundException(key);
                set =>  throw CreateException(key, value);
            }
            public void Add(KeyValuePair<string, JsonElement> item) =>  throw CreateException(item.Key, item.Value);
            public void Clear() => throw new NotImplementedException();
            public bool Contains(KeyValuePair<string, JsonElement> item) => false;
            public void CopyTo(KeyValuePair<string, JsonElement>[] array, int arrayIndex) { }
            public int Count => 0;
            public bool IsReadOnly => false;
            public bool Remove(KeyValuePair<string, JsonElement> item) => false;
            public IEnumerator<KeyValuePair<string, JsonElement>> GetEnumerator() => Enumerable.Empty<KeyValuePair<string, JsonElement>>().GetEnumerator();
            System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { return GetEnumerator(); }
        }
    
        public static DefaultJsonTypeInfoResolver AddMissingMemberHandlingError(this DefaultJsonTypeInfoResolver resolver)
        {
            resolver.Modifiers.Add(typeInfo => 
                                   {
                                       if (typeInfo.Kind != JsonTypeInfoKind.Object)
                                           return;
                                       if (typeInfo.Properties.Any(p => p.IsExtensionData))
                                           return;
                                       var dictionaryType = typeof(UnknownPropertyDictionary<>).MakeGenericType(typeInfo.Type);
                                       JsonPropertyInfo property = typeInfo.CreateJsonPropertyInfo(dictionaryType, "<>ExtensionData");
                                       property.IsExtensionData = true;
                                       property.Get = (obj) => Activator.CreateInstance(dictionaryType);
                                       property.Set = static (obj, val) => { };
                                       typeInfo.Properties.Add(property);
                                   });
            return resolver;
        }
    }
    

    Then if I attempt to deserialize JSON with an unknown property to a model that does not contain that property, the following exception is thrown:

    JsonMissingMemberException: Unexpected property "Unknown" encountered while deserializing type Model. Path: $.Unknown | LineNumber: 6 | BytePositionInLine: 16.
       at JsonExtensions.UnknownPropertyDictionary`1.set_Item(String key, JsonElement value)
       at System.Text.Json.Serialization.Metadata.JsonPropertyInfo.ReadJsonAndAddExtensionProperty(Object obj, ReadStack& state, Utf8JsonReader& reader)
    

    Notes:

    • A custom subclass of JsonException is required to include both the custom message and the path, line number and byte position.

    • Only the name of the first unknown property is included in the exception message.

    Demo #2 here.