Differentiating between explicit 'null' and 'not specified' in ASP.NET Core ApiController
Asked Answered
A

2

10

This is my very first question after many years of lurking here, so I hope I don't break any rules.

In some of my ASP.NET Core API's POST methods, I'd like to make it possible for clients to provide only the properties they want to update in the body of their POST request.

Here's a simplified version of my code:

[Route("api/v{version:apiVersion}/[controller]")]
[ApiController]
public sealed class FooController : ControllerBase
{
    public async Task<IActionResult> UpdateFooAsync(Guid fooGuid, [FromBody]UpdateFooModel model)
    {
        ... Apply updates for specified properties, checking for authorization where needed...

        return Ok();
    }
}

public sealed class UpdateFooModel
{
    [BindProperty] public int? MaxFoo { get; set; }
    [BindProperty] public int? MaxBar { get; set; }
}

public sealed class Foo
{
    public int? MaxFoo { get; set; }
    public int? MaxBar { get; set; }
}

MaxBar and MaxFoo both are nullable integer values, where the null value signifies there's no maximum.

I'm trying to make it possible to let clients send e.g. the following to this endpoint:

  • Setting MaxBar to null, and setting MaxFoo to 10

    {
        "maxBar": null,
        "maxFoo": 10
    }
    
  • Setting MaxBar to null, not touching MaxFoo

    { "maxBar": null }
    
  • Update MaxBar to 5, not touching MaxFoo

    { "maxBar": 5 }
    

In my method UpdateFooAsync, I want to update only the properties that have been specified in the request.

However, when model binding occurs, unspecified properties are set to their default values (null for nullable types).

What would be the best way to find out if a value was explicitly set to null (it should be set to null), or was just not present in the request (it should not be updated)?

I've tried checking the ModelState, but it contained no keys for the 'model', only for the Guid typed parameter.

Any other way to solve the core problem would be welcome as well, of course.

Thanks!

Airglow answered 24/9, 2019 at 14:38 Comment(6)
See answers given here, they may put you on the right path.Acrodrome
Have you tried "dynamic" instead of "UpdateFooModel" in your Put request? You can then parse the dynamic json and update the model.Chadburn
Just to make it clear the behavior you are describing is the correct normal behavior. So to do what you want... May be you could just check the raw body of the request and write your own parsing logic.Hyperesthesia
@PeterB, thanks for the link to the other question, it is very relevant. I have considered using JSON Patch (forgot to mention that here), but have some concerns mainly about the checking for authorization where needed part in my example code: Not every user will be authorized to update all properties It also is slightly more complex for the client, while I only want to support 'replace' operations.Airglow
@AzharKhorasany: I try to stay away from dynamic typing wherever possible. Also, when parsing the JSON myself, I also lose the framework's support for e.g. validationAirglow
@Darkonekt, seems to be about the same as AzharKhorasany's proposal?Airglow
A
2

Answering my own question here, based on @russ-w 's suggestion (thanks!): By marking a bool property in each optional property's setter, we can find out if it was provided or not.

public sealed class UpdateFooModel
{
    private int? _maxFoo;
    private int? _maxBar;

    [BindProperty] 
    public int? MaxFoo
    { 
        get => _maxFoo;
        set
        {
            _maxFoo = value;
            MaxFooSet = true;
        }
    }

    public bool MaxFooSet { get; private set; }

    [BindProperty] 
    public int? MaxBar
    { 
        get => _maxBar;
        set
        {
            _maxBar = value;
            MaxBarSet = true;
        }
    }

    public bool MaxBarSet { get; private set; }
}

Further improvements or other solutions are still welcome of course!

Airglow answered 25/9, 2019 at 7:57 Comment(2)
so your model is now exposing 4 properties instead of 2? So I can now pass a payload where I can set the MaxFooSet and MaxBarSet to be true and then get confused as internally you are setting them to false if the other properties are not provided.Chadburn
@AzharKhorasany, these are readonly properties, so the model binder won't be able to set them. How would the clients find out these properties exist at all? When generating documentation through e.g. Swashbuckle it's trivial to annotate these properties to keep them out of the generated document.Airglow
C
1

Here's a potential solution. Use logic in the setters of the UpdateFooModel class to check for null and assign a different value, such as Int32.MaxValue. The setters are only called when the parameter is passed in. In the example, if null is explicitly passed in, it would be converted to Int32.MaxValue. If the parameter is not specified, the value would remain null. An alternative to using setters would be to use a default constructor and add some logic to set different values based on whether or not parameters were specified. Example:

public sealed class UpdateFooModel
{
    private int? _maxFoo;
    public int? MaxFoo
    {
        get
        {
            return _maxFoo;
        }

        set
        {
            _maxFoo = (value == null) ? Int32.MaxValue : value;
        }
    }

    private int? _maxBar;
    public int? MaxBar
    {
        get
        {
            return _maxBar;
        }

        set
        {
            _maxBar = (value == null) ? Int32.MaxValue : value;
        }
    }
}
Career answered 24/9, 2019 at 17:14 Comment(3)
Not a good solution. Int32.MaxValue is a potential value passed into the model by the user.Inch
@russ-w, thanks for your suggestion. It actually led me to a solution which I think will fit my needs: I'm using a separate bool property for each BindProperty property, which I set to true in the setter.Airglow
@Storm-BE, glad it helped! I like the solution you came up with. I only used Int32.MaxValue as a simple example to demonstrate the technique while making as little changes as possible to your original code.Career

© 2022 - 2024 — McMap. All rights reserved.