asp.net.netasp.net-web-apiodataasp.net-web-api-odata

How to prevent under-posting in ASP.NET Web API OData service?


I have created a very simple OData v4 controller. The controller basically contains Entity Framework-backed CRUD methods for the following Pet entity:

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

    [Required]
    public string Name { get; set; }

    public int Age { get; set; }
}

An important thing here is that Pet.Age is the non-nullable required property.

Here is the controller itself (only Post method is shown):

public class PetController : ODataController
{
    private DatabaseContext db = new DatabaseContext();

    // POST: odata/Pet
    public IHttpActionResult Post(Pet pet)
    {
        if (!ModelState.IsValid)
        {
            return BadRequest(ModelState);
        }

        db.Pet.Add(pet);
        db.SaveChanges();

        return Created(pet);
    }

    // Other controller methods go here...
}

And this is my WebApiConfig controller configuration:

ODataConventionModelBuilder builder = new ODataConventionModelBuilder();
builder.EntitySet<Pet>("Pet");
config.MapODataServiceRoute("odata", "odata", builder.GetEdmModel());

Now if I want to create a new Pet in my database, I issue a POST request like this:

POST http://localhost:8080/odata/Pet
Content-type: application/json

{ Name: "Cat", Age: 5 }

However, I can simply omit the Age property in JSON request payload, so JSON deserializer will use a default value of 0, while I want a 400 Bad Request status to be returned instead. This problem is called under-posting.

It can be easily solved when using regular WebApi controllers (the solution is described here). You just create a PetViewModel and make your controller to accept a PetViewModel instead of an actual Pet entity:

public class PetViewModel
{
    // Make the property nullable and set the Required attribute
    // to distinguish between "zero" and "not set"
    [Required]
    public int? Age { get; set; }

    // Other properties go here...
}

Then in your controller you just convert PetViewModel to Pet entity and save it to the database as usual.

Unfortunately, this approach does not work with OData controllers: if I change my Post method to accept PetViewModel instead of Pet, I receive the following error:

System.Net.Http.UnsupportedMediaTypeException: No MediaTypeFormatter is available to read an object of type 'PetViewModel' from content with media type 'application/json'.

at System.Net.Http.HttpContentExtensions.ReadAsAsync[T](HttpContent content, Type type, IEnumerable'1 formatters, IFormatterLogger formatterLogger, CancellationToken cancellationToken)

at System.Net.Http.HttpContentExtensions.ReadAsAsync(HttpContent content, Type type, IEnumerable'1 formatters, IFormatterLogger formatterLogger, CancellationToken cancellationToken)

at System.Web.Http.ModelBinding.FormatterParameterBinding.ReadContentAsync(HttpRequestMessage request, Type type, IEnumerable`1 formatters, IFormatterLogger formatterLogger, CancellationToken cancellationToken)

So, is there any way to prevent under-posting when using OData controllers?


Solution

  • After some investigation I have solved this issue. Not sure if it is an "official" or preferred way of solving underposting problem in OData, but at least it works fine for me. So, for the lack of the official information, here is my recipe:

    First, create a corresponding validation ViewModel for your OData entity:

    public class PetViewModel
    {
        public int Id { get; set; }
    
        [Required]
        [StringLength(50)]
        public string Name { get; set; }
    
        // Make the property nullable and set the Required attribute
        // to distinguish between "zero" and "not set"
        [Required]
        public new int? Age { get; set; }
    }
    

    Then, add your own ODataUnderpostingValidationAttribute. My implementation looks like this:

    public class ODataUnderpostingValidationAttribute: ActionFilterAttribute
    {
        public ODataUnderpostingValidationAttribute(Type viewModelType)
        {
            ViewModelType = viewModelType;
        }
    
        public Type ViewModelType { get; set; }
    
        public override async Task OnActionExecutingAsync(HttpActionContext actionContext, CancellationToken cancellationToken)
        {
            // Rewind requestStream so it can be read again.
            var requestStream = await actionContext.Request.Content.ReadAsStreamAsync();
            if (requestStream.CanSeek)
            {
                requestStream.Position = 0;
            }
    
            // Read the actual JSON payload.
            var json = await actionContext.Request.Content.ReadAsStringAsync();
    
            // Deserialize JSON to corresponding validation ViewModel.
            var viewModel = JsonConvert.DeserializeObject(json, ViewModelType);
            var context = new ValidationContext(viewModel);
            var results = new List<ValidationResult>();
            var isValid = Validator.TryValidateObject(viewModel, context, results);
    
            if (!isValid)
            {
                // Throw HttpResponseException instead of setting actionContext.Response, so the exception will be logged by the ExceptionLogger.
                var responseMessage = actionContext.Request.CreateErrorResponse(HttpStatusCode.BadRequest, string.Join(Environment.NewLine, results.Select(r => r.ErrorMessage)));
                throw new HttpResponseException(responseMessage);
            }
    
            await base.OnActionExecutingAsync(actionContext, cancellationToken);
        }
    }
    

    After that, apply this custom filter to your ODataController:

    [ODataUnderpostingValidation(typeof(PetViewModel))]
    public class PetController : ODataController
    { /* Implementation here */ }
    

    Voila! Now you have everything in place. Underposting validation works fine.