ASP.NET Web API - Screencast series Part 5: Custom Validation
We're continuing a six part series on ASP.NET Web API that accompanies the getting started screencast series. This is an introductory screencast series that walks through from File / New Project to some more advanced scenarios like Custom Validation and Authorization. The screencast videos are all short (3-5 minutes) and the sample code for the series is both available for download and browsable online. I did the screencasts, but the samples were written by the ASP.NET Web API team.
In Part 1 we looked at what ASP.NET Web API is, why you'd care, did the File / New Project thing, and did some basic HTTP testing using browser F12 developer tools.
In Part 2 we started to build up a sample that returns data from a repository in JSON format via GET methods.
In Part 3, we modified data on the server using DELETE and POST methods.
In Part 4, we extended on our simple querying methods form Part 2, adding in support for paging and querying.
In Part 5, we'll add on support for Data Annotation based validation using an Action Filter.
[Video and code on the ASP.NET site]
Adding Validation Rules using Data Annotations
To start with, we add some validation rules to our model class using Data Annotations - Required and StringLength in this case.
[Required] public string Text { get; set; } [Required] [StringLength(10, ErrorMessage = "Author is too long! This was validated on the server.")] public string Author { get; set; } [Required] public string Email { get; set; }
Writing an Action Filter that Enforces Validation Rules
In ASP.NET MVC, that would be it - the validation rules are enforced in controller actions, and automatically passed along as HTML5 data- attributes where they're handled via unobtrusive jQuery validation in the browser. ASP.NET Web API doesn't directly enforce those validation rules without a little more work, though. I think that might be because handling validation errors in an HTTP API isn't something you'd want to do automatically. Who knows, maybe they'll add that in later just to make me look dumb(er). But for now, it takes a bit of work to enforce those validation rules.
Fortunately, by a little more work I mean about 10-15 lines of code. It's easy to hook this kind of thing up using an ASP.NET MVC action filter. Action filters are really powerful, as they allow you to modify how controller actions work using the following methods:
- OnActionExecuting – This method is called before a controller action is executed.
- OnActionExecuted – This method is called after a controller action is executed.
- OnResultExecuting – This method is called before a controller action result is executed.
- OnResultExecuted – This method is called after a controller action result is executed.
You can apply an action filter attribute on specific actions, on an entire controller class, or globally. There are a few built-in action filters to handle common cases like custom authorization or error handling, and you can extend them if you need to handle a custom scenario.
ASP.NET Web API uses that same extensibility model via Filters. If you want ASP.NET Web API to do something that's not built in, the hook for that is very often to use an action filter. As with ASP.NET Action Filters, you can either extend on any of the in-built filters ( ResultLimit, AuthorizationFilter, ExceptionFilter) or write a custom ActionFilterAttribute.
public class ValidationActionFilter : ActionFilterAttribute { public override void OnActionExecuting(HttpActionContext context) { var modelState = context.ModelState; if (!modelState.IsValid) { dynamic errors = new JsonObject(); foreach (var key in modelState.Keys) { var state = modelState[key]; if (state.Errors.Any()) { errors[key] = state.Errors.First().ErrorMessage; } } context.Response = new HttpResponseMessage<JsonValue>(errors, HttpStatusCode.BadRequest); } } }
This is an OnActionExecuting filter, so it executes before the Action. It takes in the context (note - this is a System.Web.Http.Controllers.HttpActionContext, not an System.Web.HttpContext), which gives it access to ModelState as well as other contextual information about the request. It runs any custom logic, and has the option of directly returning the response - as it does in case validation fails.
So, at a high level, we just need to check if the model fails validation, and if so, return an appropriate response.
1. Checking Data Annotation validation rules
As mentioned earlier in the series, ASP.NET Web API uses the same model binding system that's been in ASP.NET MVC for a while, so it already knows how to check validation rules from data annotations. That makes this step really easy - we just check the context.ModelState to see if it's valid. If it is, we're done - let the Action do its work and return the result. If it fails, go on to step 2.
2. Packaging up validation errors for the client
A single request can fail multiple validation rules, so to return useful information to the client we need to package up the error information. We could build up a structured, custom error results object, but we'd be serializing it to JSON when we were done, so why not just start there? ASP.NET Web API includes some very useful JSON classes, including JsonObject. JsonObject allows us to work with a C# dynamic that will be serialized as a JSON object very easily.
3. Returning a useful error error response
Finally, when we're done wrapping up all the validation errors, we return a response to the client. We can use an HttpResponseMessage<JsonValue> to both return the results and set the HTTP Status Code (HTTP 400 Bad Request) in one line:
context.Response = new HttpResponseMessage<JsonValue>(errors, HttpStatusCode.BadRequest);
You'll remember from earlier in the series that HTTP Status Codes are very important to HTTP APIs, and that by always setting the appropriate status code our clients (be they JavaScript, .NET desktop clients, iOS devices, or aliens who natively speak HTTP) will be able to understand responses without reading a bunch of API documentation. If a client makes a bad request, we'll tell them it was a bad request, and send along validation errors in JSON format in case they want specifics.
Registering a Global Filter
As with ASP.NET MVC filters, ASP.NET Web API filters can be applied at whatever level of granularity you'd like - action, controller, or globally. In this case, we'd like the rules to be enforced on all actions in the application. We can do that by registering a global filter. That's a one-line change - we add a call in our Global.asax.cs Configure method to register the new filter:
public static void Configure(HttpConfiguration config) { config.Filters.Add(new ValidationActionFilter()); var kernel = new StandardKernel(); kernel.Bind<ICommentRepository>().ToConstant(new InitialData()); config.ServiceResolver.SetResolver( t => kernel.TryGet(t), t => kernel.GetAll(t)); }
Handling Validation Responses in a JavaScript Client
In this sample, we're working with a JavaScript client. Just for the sake of beating a dead horse a bit more, I'll remind you that JavaScript is a browser is just one possible client.
To add handle validation failures, we need to include a case for HTTP Status Code 400 in our $.ajax() jQuery call.
$.ajax({ url: '/api/comments', cache: false, type: 'POST', data: json, contentType: 'application/json; charset=utf-8', statusCode: { 201 /*Created*/: function (data) { viewModel.comments.push(data); }, 400 /* BadRequest */: function (jqxhr) { var validationResult = $.parseJSON(jqxhr.responseText); $.validator.unobtrusive.revalidate(form, validationResult); } } });
Break the rules? Talk to the HTTP 400.
So here's how this looks in action - filling out the form with an Author name that exceeds 10 characters now gets an HTTP 400 response.
The error message shown above - "Author is too long! This was validated on the server." - is perhaps a little too smug, but is derived from the JSON error information in the request body:
Onward!
That wraps up our look at supporting Data Annotation based validation using an Action Filter. In Part 6, we'll finish off the series with a look at Authorization using the built-in Authorize filter.