6

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?

silent
  • 14,494
  • 4
  • 46
  • 86
  • Not sure if this is doable with System.Text.Json alone. Maybe it can be done with NewtonSoft's Json schema validation... (?) – Fildor Jan 03 '23 at 15:26
  • @Fildor I'd really want to avoid adding Json.NET again :/ – silent Jan 03 '23 at 15:27
  • I feel you, but that's the trade-off: Json.Net = features, Text.Json = fast ... I found these: https://www.newtonsoft.com/jsonschema/help/html/ValidatingJson.htm , https://www.newtonsoft.com/jsonschema and https://stackoverflow.com/a/58036918/982149 – Fildor Jan 03 '23 at 15:29
  • It's paid, too... meh. – Fildor Jan 03 '23 at 15:31
  • MIT-Licensed Alternative https://github.com/gregsdennis/json-everything was listed at https://json-schema.org/implementations.html#validator-dotnet – Fildor Jan 03 '23 at 15:34
  • You can use `JsonDocument.Parse` and compare `JsonElement` and `classProperties` using reflection. This is a crude solution but might work. – ShubhamWagh Jan 03 '23 at 15:37
  • I was thinking in a similar direction but with source generators. Schema seems to allow for checking that incoming json _has_ certain properties and etc. but not that it doesn't have anything _BUT_ a specific set. So, I was thinking it should be possible to write code that writes code which checks a JSON tree for superfluent members ... – Fildor Jan 03 '23 at 15:58

2 Answers2

4

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

Update

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.

dbc
  • 104,963
  • 20
  • 228
  • 340
  • awesome, thanks for the details! the only downside is indeed this, as I would have liked to inform the user what they should remove... "you won't be able to include the missing member name in the exception message " – silent Jan 03 '23 at 17:15
  • @silent - OK, I figured out a trick to include the missing member name. It's a bit involved but does work. – dbc Jan 03 '23 at 18:58
  • 1
    Also funny coincidence that just a few days the PR was opened to add such a feature for .NET8 :) https://github.com/dotnet/runtime/pull/79945 – silent Jan 03 '23 at 21:43
3

If you're willing to pollute your model class, you can add an extension data property that collects all extraneous properties in the payload:

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

    [JsonExtensionData] // using System.Text.Json.Serialization;
    public IDictionary<string, JsonElement> ExtensionData { get; set; }
}

Then in your controller, check whether person.ExtensionData is non-null:

if (person.ExtensionData != null) {
    return BadRequest();
}

If you have numerous model classes and controllers, I'd define an interface for the ExtensionData property that each model class implements, and install a global filter that validates the ExtensionData property.

Michael Liu
  • 52,147
  • 13
  • 117
  • 150
  • I went with this approach for now and it works quite nicely. The only thing is that Swagger generates now `"additionalProp1": "string", "additionalProp2": "string", "additionalProp3": "string"`. I already tried to ignore the ExtensionData property using this but no luck: https://stackoverflow.com/a/54480742/1537195 – silent Jan 04 '23 at 13:34
  • Using ASP.NET Core 7.0.1 and Swashbuckle.AspNetCore 6.4.0, I don't see any additional properties in the rendered documentation. (The swagger.json file includes `"additionalProperties": {}` but this is apparently ignored.) – Michael Liu Jan 04 '23 at 16:15