Umair
Umair

Reputation: 3253

Asp.net core not binding post model if invalid property values are present

I am migrating an application from legacy asp.net webapi to asp.net core mvc. I have noticed an issue. For some requests, we send partial or even invalid values in the POST body. And asp.net core is refusing to deserialize it.

E.g. post model

public class PostModel
{
    public int Id { get; set; }

    public Category? Category { get; set; }
}

public enum Category
{
    Public,
    Personal
}

action

[HttpPost]
public async Task<Response> Post([FromBody]PostModel model)
    => this.Service.Execute(model);

for the following sample request

POST /endpoint
{
    id: 3,
    category: "all"
}

The ModelState collection records an error - indicating that all is an invalid category, and the PostModel argument model is null. Is it possible to disable this behaviour and just attempt to bind all properties that are possible from the post body, and ignoring the ones it can't bind? This is how it was done for us in our legacy api and for now, I need to port this across.

Disabling the model validation did not help for us. The model argument is still null.

Upvotes: 2

Views: 1941

Answers (3)

Edward
Edward

Reputation: 30056

For FromBody, it will bind the request body to Model by JsonInputFormatter.

For JsonInputFormatter, it will call return InputFormatterResult.Success(model) when there is no error, and call return InputFormatterResult.Failure(); when there is any error. For return InputFormatterResult.Failure();, it will not bind the valid property.

For a solution, you could implement custom formatter to return return InputFormatterResult.Success(model).

  1. Implement custom formatter CustomFormatter based on JsonInputFormatter.
  2. Replace InputFormatterResult.Failure() with InputFormatterResult.Success(model).

                    if (!(exception is JsonException || exception is OverflowException))
                {
                    var exceptionDispatchInfo = ExceptionDispatchInfo.Capture(exception);
                    exceptionDispatchInfo.Throw();
                }
                return InputFormatterResult.Success(model);
    
  3. Inject CustomFormatter in Startup.cs

            services.AddMvc(o =>
        {
            var serviceProvider = services.BuildServiceProvider();
            var customJsonInputFormatter = new CustomFormatter(
                     serviceProvider.GetRequiredService<ILoggerFactory>().CreateLogger<CustomFormatter>(),
                     serviceProvider.GetRequiredService<IOptions<MvcJsonOptions>>().Value.SerializerSettings,
                     serviceProvider.GetRequiredService<ArrayPool<char>>(),
                     serviceProvider.GetRequiredService<ObjectPoolProvider>(),
                     o,
                     serviceProvider.GetRequiredService<IOptions<MvcJsonOptions>>().Value
                );
    
            o.InputFormatters.Insert(0, customJsonInputFormatter);
        }).SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
    

Upvotes: 1

Alex Riabov
Alex Riabov

Reputation: 9205

Actually, your problem is related to Data Binding, not to validation, that's why disabling the model validation did not help. You can implement custom Binder and configure it to manually bind your properties, e.g.:

public class PostModelBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        if (bindingContext == null)
        {
            throw new ArgumentNullException(nameof(bindingContext));
        }

        string valueFromBody = string.Empty;

        using (var sr = new StreamReader(bindingContext.HttpContext.Request.Body))
        {
            valueFromBody = sr.ReadToEnd();
        }

        if (string.IsNullOrEmpty(valueFromBody))
        {
            return Task.CompletedTask;
        }

        string idString = Convert.ToString(((JValue)JObject.Parse(valueFromBody)["id"]).Value);
        string categoryString = Convert.ToString(((JValue)JObject.Parse(valueFromBody)["category"]).Value);

        if (string.IsNullOrEmpty(idString) || !int.TryParse(idString, out int id))
        {
            return Task.CompletedTask;
        }

        Category? category = null;

        if(Enum.TryParse(categoryString, out Category parsedCategory))
        {
            category = parsedCategory;
        }

        bindingContext.Result = ModelBindingResult.Success(new PostModel()
        {
            Id = id,
            Category = category
        });

        return Task.CompletedTask;
    }
}

Then you can apply this binder to your class:

[ModelBinder(BinderType = typeof(PostModelBinder))]
public class PostModel
{
    public int Id { get; set; }

    public Category? Category { get; set; }
}

or to action:

[HttpPost]
public async Task<Response> Post([ModelBinder(BinderType = typeof(PostModelBinder))][FromBody]PostModel model)
    => this.Service.Execute(model);

or create CustomModelBinderProvider:

public class CustomModelBinderProvider : IModelBinderProvider  
{  
    public IModelBinder GetBinder(ModelBinderProviderContext context)  
    {  
        if (context.Metadata.ModelType == typeof(PostModel))  
            return new PostModelBinder();  

        return null;  
    }  
}

and register it in ConfigureServices methods of Startup class:

public void ConfigureServices(IServiceCollection services)  
{  
    ...
    services.AddMvc(  
        config => config.ModelBinderProviders.Insert(0, new CustomModelBinderProvider())  
    ); 
    ... 
} 

Upvotes: 1

Rahul
Rahul

Reputation: 77934

No you can't since the property tied to an enum. if you really want to be what you posted then change the model to be

public class PostModel
{
    public int Id { get; set; }

    public string Category { get; set; }
}

Then in your endpoint parse the string to enum like

Enum.TryParse("All", out Category cat);

Upvotes: 0

Related Questions