3
votes

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?

2
In this instance you could use a RangeAttribute and specify it at 1 to 999. The ModelState.IsValid should then catch that value 0 is not within the range and return a BadRequest status. The other option is to create a custom filter and manually parse the incoming JSON before it is mapped to the model but that seems like overkill.Igor
@Igor I have solved the problem using the second approach because the generic solution to distinguish between default and null values was required. Have a look at the answer if you are interested. Thank you for your help!Sergey Kolodiy

2 Answers

2
votes

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.

0
votes

You've got a couple options as I see it:

First In your controller you can check the integer value and if its below a certain value return 404.

if (Age <= 0)
   return NotFound();

This could be labor intensive and if you're doing it for every controller method it's not very DRY.

Second in your Pet class you can use the DataAnnotations Attribute Range e.g.

[Range(0, 80, ErrorMessage = "Value for {0} must be between {1} and {2}")]
public int Age { get; set; }

Where Age can be a maximum of 80. https://msdn.microsoft.com/en-us/library/system.componentmodel.dataannotations.rangeattribute(v=vs.110).aspx

Lastly I think your a more permanent solution for you would be to create your own validation:

public class AgeValidation : ValidationAttribute {
public override bool IsValid(object value) {
    if (Object.Equals(value, null)) {
        return false;
    }
    int getage;
    if (int.TryParse(value.ToString(), out getage)) {

        if (getage == 0)
            return false;

        if (getage > 0)
            return true;
    }
    return false;
}

}

Then in your Pet class add:

[AgeValidation(ErrorMessage = "Age is wack")]
public int Age { get; set; }

Borrowed from How to do Integer model validation in asp.net mvc 2