ASP .NET WebApi: Model validation

Published on 08 February 2020

You may know about ValidationAttribute. First appeared with .net farmework 3.5 it is still used for validation. This article is about data model validation in ASP.NET WebApi.

Using the validation mechanism we can make our code safer and cleaner. The easiest way - start with attributes, that declare some rules for data. For example, Required attribute validates that model property is not null.

Let's make all attributes of POST model as required:

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

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

ApiController contains ModelState property that allows us to check passed model:

[HttpPost]
public IHttpActionResult Post([FromBody]PostModel model)
{
    if (!this.ModelState.IsValid)
    {
        return this.BadRequest();
    }

    // some logic
    
    return this.Ok();
}

Sending POST request without required attributes will lead to BadRequest result:

{
    "name" : "Kate"
}

Besides attributes starting with .net framework 4.0 System.ComponentModel namespace contains interface IValidatableObject. We can create more complex ones. Assume, a client can set only Name or SecondName, but not both at the same time:

public class PostModel : IValidatableObject
{
    public string Name { get; set; }

    public string SecondName { get; set; }

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

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if ((string.IsNullOrWhiteSpace(Name) && string.IsNullOrWhiteSpace(SecondName)) || (!string.IsNullOrWhiteSpace(Name) && !string.IsNullOrWhiteSpace(SecondName)))
        {
            yield return new ValidationResult("Name or SecondName must be set, not both");
        }
    }
}

The next request is invalid now:

{
    "name" : "Kate",
    "secondName" : "Alice",
    "phoneNumber" : "1234567"
}

Now we have two options for data validation: attributes and interface implementing. But they won't work by default. We need to check ModelState when we want to validate the incoming model. This problem can be solved with a special filter.

Validation filter

We extend ActionFilterAttribute to validate model on executing controller action:

public sealed class ValidationFilter : ActionFilterAttribute
{
    public override Task OnActionExecutingAsync(HttpActionContext actionContext, CancellationToken cancellationToken)
    {
        var modelState = actionContext.ModelState;

        if (!modelState.IsValid)
        {
            actionContext.Response = actionContext.Request.CreateErrorResponse(HttpStatusCode.BadRequest, "Invalid model state");
        }

        return base.OnActionExecutingAsync(actionContext, cancellationToken);
    }
}

Do not forget to add new filter to configuration in startup class:

config.Filters.Add(new ValidationFilter());

And now, we can remove checks from all actions:

[HttpPost]
public IHttpActionResult Post([FromBody]PostModel model)
{
    // some logic
    return this.Ok();
}

Now actions only contain business logic, and code became cleaner. Our server will response with BadRequest and our message:

"Invalid model state"

As we see, this message is not informative. We don't know, what is wrong with our request. It gets better with information about validation errors:

public sealed class ValidationFilter : ActionFilterAttribute
{
    public override Task OnActionExecutingAsync(HttpActionContext actionContext, CancellationToken cancellationToken)
    {
        var modelState = actionContext.ModelState;

        if (!modelState.IsValid)
        {
            var errors = modelState.Where(o => o.Value.Errors.Count != 0).SelectMany(o => o.Value.Errors, (a, b) => new { a.Key, Error = b }).Select(o => new ServiceError
            {
                Target = o.Key,
                Message = !string.IsNullOrWhiteSpace(o.Error.ErrorMessage) ? o.Error.ErrorMessage : o.Error.Exception?.Message
            }).ToArray();

            actionContext.Response = actionContext.Request.CreateResponse(HttpStatusCode.BadRequest, new ServiceError { Message = "Bad request", Errors = errors });
        }

        return base.OnActionExecutingAsync(actionContext, cancellationToken);
    }

    private class ServiceError
    {
        public string Target { get; set; }
        public string Message { get; set; }
        public ServiceError[] Errors { get; set; }

    }
}

Let's send invalid request:

{
    "name" : "Kate",
    "secondName" : "Alice",
    "phoneNumber" : "1234567"
}

The server returns BadRequest:

{
  "Target": null,
  "Message": "Bad request",
  "Errors": [
    {
      "Target": "model",
      "Message": "Name or SecondName must be set, not both",
      "Errors": null
    }
  ]
}

Now we see our validation message from the model. But there is one non-obvious feature of validation mechanism. To demonstrate it, let's send the request without required field phoneNumber:

{
    "name" : "Kate",
    "secondName" : "Alice"
}

We can expect two errors:

  • No required field phoneNumber
  • Name or SecondName must be set, not both

But actual response contains only the first error:

{
  "Target": null,
  "Message": "Bad request",
  "Errors": [
    {
      "Target": "model.PhoneNumber",
      "Message": "The PhoneNumber field is required.",
      "Errors": null
    }
  ]
}

The reason is that class level validation is called only if all property-level validations are valid.

Action parameters validation

Want to know the best part? Requests can be sent without a body. And if we do not expect it, we must validate it directly:

[HttpPost]
public IHttpActionResult Post([FromBody]PostModel model)
{
    if (model == null)
    {
        return this.BadRequest("Request body is empty");
    }

    // some logic
    return this.Ok();
}

It would be great to use same attributes, and just set [Required] to action parameter like this:

[HttpPost]
public IHttpActionResult Post([FromBody][Required]PostModel model)
{
    // some logic
    return this.Ok();
}

But WebApi does not validate action parameters, we must do it ourselves. As before, we can write another filter:

public sealed class ValidateActionParametersFilter : ActionFilterAttribute
{
    public override Task OnActionExecutingAsync(HttpActionContext actionContext, CancellationToken cancellationToken)
    {
        if (actionContext.ActionDescriptor is ReflectedHttpActionDescriptor descriptor)
        {
            var parameters = descriptor.MethodInfo.GetParameters();

            foreach (var parameter in parameters)
            {
                var argument = actionContext.ActionArguments.ContainsKey(parameter.Name) ?
                    actionContext.ActionArguments[parameter.Name] : null;

                EvaluateValidationAttributes(parameter, argument, actionContext.ModelState);
            }
        }

        return base.OnActionExecutingAsync(actionContext, cancellationToken);
    }

    private static void EvaluateValidationAttributes(ParameterInfo parameter, object argument, ModelStateDictionary modelState)
    {
        var attributes = parameter.CustomAttributes;

        foreach (var attributeData in attributes)
        {
            var attributeInstance = parameter.GetCustomAttribute(attributeData.AttributeType);

            if (attributeInstance is ValidationAttribute validationAttribute)
            {
                var isValid = validationAttribute.IsValid(argument);
                if (!isValid)
                {
                    modelState.AddModelError(parameter.Name, validationAttribute.FormatErrorMessage(parameter.Name));
                }
            }
        }
    }
}

We must add this filter before our ValidationFilter:

config.Filters.Add(new ValidateActionParametersFilter());
config.Filters.Add(new ValidationFilter());

Now, our action parameters are validated, and the empty request body will be rejected with error:

{
  "Target": null,
  "Message": "bad request",
  "Errors": [
    {
      "Target": "model",
      "Message": "The model field is required.",
      "Errors": null
    }
  ]
}

Links

blog comments powered by Disqus