Client and Server Validation with Web API and Knockout

In this post I’m going to demonstrate how to implement both client and server side validation with ASP.NET Web API and Knockout. In particular I’m going to demonstrate how to convey server side binding and validation errors from the controller to the view model, and how to utilize Knockout client side validation features to display both client and server side errors in the view.

Model

My model comprises of 2 domain entities, Product and ProductCategory, with a one-to-many relationship between them.

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace WebAPIKo.Models
{
    public class Product
    {
        [Key]
        [DatabaseGeneratedAttribute(DatabaseGeneratedOption.Identity)]
        public int ProductId { get; set; }

        [Required]
        [StringLength(100)]
        public string Name { get; set; }

        [Required]
        [StringLength(1000)]
        public string Description { get; set; }

        [Required]
        public int ProductCategoryId { get; set; }

        // Navigation properties
        public virtual ProductCategory ProductCategory { get; set; }
    }
}
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace WebAPIKo.Models
{
    public class ProductCategory
    {
        [Key]
        [DatabaseGeneratedAttribute(DatabaseGeneratedOption.Identity)]
        public int ProductCategoryId { get; set; }

        [Required]
        [StringLength(100)]
        public string Name { get; set; }

        // Navigational Properties
        public virtual ICollection<Product> Products { get; set; }
    }
}

I am using code first Entity Framework as my ORM. Here’s the DbContext containing DbSets for the aforementioned entities –

using System.Data.Entity;

namespace WebAPIKo.Models
{
    public class WebAPIKoDbContext : DbContext
    {
        public virtual DbSet<Product> Products { get; set; }
        public virtual DbSet<ProductCategory> ProductCategories { get; set; }
    }
}

View

My view is a simple “Create Product” form with product name and description text boxes, and a select list for product categories.

create project 2

Here’s the HTML –

<div class="row">
    <div class="col-md-12">
        <div class="panel panel-info" id="add-panel">
            <div class="panel-heading">
                <h2 class="panel-title">Create Product</h2>
            </div>
            <div class="panel-body">
                <form role="form" data-bind="validationOptions: validationOptions">
                    <div class="form-group">
                        <label for="name">Name</label>
                        <input id="name" type="text" class="form-control" data-bind="value: product.name" placeholder="Product Name" />
                    </div>
                    <div class="form-group">
                        <label for="description">Description</label>
                        <input id="description" class="form-control" data-bind="value: product.description" placeholder="Description" />
                    </div>
                    <div class="form-group">
                        <label for="product-category">Product Category</label>
                        <select id="product-category"
                                data-bind="options: productCategories, optionsText: 'name', optionsValue: 'productCategoryId',
                           optionsCaption: '-- Select a Product Category --', value: product.productCategoryId"></select>
                    </div>
                    <button type="button" class="btn btn-primary" data-bind="click: save, enable: canSave">
                        <span class="glyphicon glyphicon-save" aria-hidden="true"></span> Add
                    </button>
                    <ul id="errors" class="error-message" data-bind="foreach: generalErrors">
                        <li><span data-bind="text: $data"></span></li>
                    </ul>
                </form>
            </div>
        </div>
    </div>
</div>

Knockout data-bind attributes are used to declare the binding of the controls to the view model.

Of note are the following –

  • The <form> has a data-bind=”validationOptions: validationOptions” attribute, which applies global validation options to all validatable controls in the form.
  • There is a “errors” <ul> element at the bottom of the form, which has a data-bind=”foreach: generalErrors” attribute.  The <ul> contains a <li> element which in turn contains a <span> with a data-bind=”text: $data” attribute. A <li> will therefore be rendered for each error message in the view model’s generalErrors ko.observableArray property. I am using this to display any errors that are not specific to a field.

Here’s some styles to accompany the form –

<style type="text/css">
    select {
        height: 30px;
        display: block;
    }

    .panel {
        width: 450px;
        margin-top: 15px;
    }

    .panel-heading {
        padding: 10px 20px;
    }

    .panel-body {
        padding: 10px 20px 10px 20px;
    }

    .error-message {
        color: red;
    }

    .error-element {
        border-color: red;
    }

    #errors {
        padding-left: 0;
        padding-top: 5px;
    }

        #errors li {
            list-style-type: none;
        }
</style>

Controller

Before I present the view model I want to discuss Web API controller actions, and in particular the HTTP response header and body that will be returned depending on whether a post/put is successful or not.

With Visual Studio 2013 we have a number of Web API controller scaffolding extensions which provide basic implementation for Web API 2 controllers and actions.

newcontroller

createcontroller2

I have selected the “Web API Controller with actions, using Entity Framework” scaffolder. I am prompted to provide the model class – Product – and the data context class – WebAPIKoDbContext. And a controller is generated with working implementations of for get, post, put, and delete actions.

Here’s the basic implementation for the post action (used for creating new products) –

// POST: api/Product
[ResponseType(typeof(Product))]
public IHttpActionResult PostProduct([FromBody]Product product)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }

    db.Products.Add(product);
    db.SaveChanges();

    return CreatedAtRoute("DefaultApi", new { id = product.ProductId }, product);
}

This action uses Web API model binding to bind request parameters, from the body of the request, to an instance of the Product entity.

The Product entity has a number of validation attributes, which are assessed during model binding. Any validation errors are added to the ModelState object, which is an instance of System.Web.Http.ModelBinding.ModelStateDictionary. For each validation error, the property name is added to the ModelState’s Keys collection and the error description is added to the ModelState’s Values collection. Each item in the Values collection is actually an array of strings, thus enabling more than one error description per Key.

If there are model binding or validation errors, the action returns BadRequest(ModelState). This results in a HTTP 400 response being sent to the client, along with the response body containing a JSON serialization of a Message string and the ModelState object. Here are some examples of the response body –

In the first example the model binding was unsuccessful as the parameters in the request body did not match any of the Product properties. The model binder was not able to generate the product object.

{   "Message":"The request is invalid.",
    "ModelState":{
        "product":["Error converting value \"ww\" to type 'WebAPIKo.Models.Product'. Path '', line 1, position 4."]}
}

In this second example, the model binder was able to generate the product object, but the name and description properties failed validation as no values were posted for them, whilst they are required.

{   "Message":"The request is invalid.",
    "ModelState":{
        "product.Name":["The Name field is required."],
        "product.Description":["The Description field is required."]}}

In addition to performing this validation on the server, I will also be performing model validation on the client. Validation is one example of where it is necessary to deviate from the DRY principal. If we were only to validate on the server then the user would have to submit the form and wait for a response before being notified of validation errors. This could be very frustrating for the user, and so it is better to have the browser perform validation whilst the user is interacting with the form. But the client side validation should be an addition to rather than a replacement for the server side validation. Firstly, because we cannot guarantee that the request will be correctly formed, and secondly, because not all validation can easily be performed on the client.

For example, if we have such a business rule stating that product names must be unique, and we have many products then we may need to validate the product against this rule on the server. If the product fails this rule we need to communicate this back to the client, and we can utilize the ModelState object to do this. We can also utilize the ModelState object to convey non-field-specific validation errors as shown below.

ModelState.AddModelError("product.Name", "Product name is already in use");

ModelState.AddModelError("General", "General error 1");
ModelState.AddModelError("General", "General error 2");

And resulting in the following response body –

{   "Message":"The request is invalid.",
    "ModelState":{
        "product.Name":["Product name is already in use"],
        "General":["General error 1","General error 2"]}}

Of note is that because we added 2 model errors with the “General” key, then we have 2 items in the array of error descriptions.

One further thing we need to account for in handling errors, is that it is possible that an error occurs on the server during the processing of the request. For example, there may be a network issue with the application temporarily not being able to connect to the database. In this case a HTTP 500 response will be generated with a response body similar to the following –

{   "Message":"An error has occurred.",
    "ExceptionMessage":"An exception has occurred",
    "ExceptionType":"System.Exception"}

So when we have a validation error or exception then we get a HTTP 400 or 500 response with a response body that always contains a “Message” property, and optionally contains a “ModelState” object. Our client code will therefore need to trap HTTP 400 and 500 responses, and parse the “ModelState” object if one exists. And if not, display the “Message”.

View Model

ko.observable.appendError

Knockout validation, by default, appends validation attributes and an error message span after each input control that is bound to a validateable property. For example, the view incorporates the following input control which is bound to the product.name property –

<input id="name" type="text" class="form-control" data-bind="value: product.name" placeholder="Product Name" />

After the bindings are applied, the following HTML is generated by Knockout –

<input id="name" type="text" class="form-control" data-bind="value: name" placeholder="Product Name" title="This field is required." data-orig-title="">
<span class="error-message" style="display: none;"></span>

When the user interacts with the view, entering or modifying data, the view model’s ko.observable properties are automatically updated. The Knockout validation library extends the ko.observable type to include an error property, and a setError method. Each validateable ko.observable property is validated according to it’s validation rules, and the setError method is used to assign a validation error to the error property. The setError method overwrites the value of the error property, and assigns the ko.observable as being not valid.

The mechanics of displaying client validation errors in the view is therefore available out-of-the-box with Knockout validation. We can also hook into these mechanics in order to display validation errors returned from the server.

The first thing I have done to hook into these mechanics is to extend the ko.observable type to include an appendError function, which appends new errors onto existing ones rather than overwriting them. This is needed because there is the possibility of more than one error per property being returned from the server.

///////////////////////////////////////////////////////////////////
// ko.observable.appendError

ko.observable.fn.appendError = function (error) {
    var thisError = this.error();
    if (thisError) {
        this.setError(thisError += '. ' + error);
    }
    else {
        this.setError(error)
    };
};

ProductCategory and ProductCategoryList

The productCategory and productCategoryList types contain properties and functions for the retrieval of a list of product categories from the server. The product categories are used to populate the form’s product category select control.

///////////////////////////////////////////////////////////////////
// ProductCategory

var productCategory = function (productCategoryId, name) {
    var self = this;

    // Properties
    self.productCategoryId = productCategoryId;
    self.name = name;
}

var productCategoryList = function () {
    var self = this;

    self.productCategories = ko.observableArray([]);

    self.get = function (callBack) {
        $.ajax({
            url: '/api/productCategory',
            type: 'get',
            contentType: 'application/json; charset=utf-8',
            success: function (data) {
                $.each(data, function (key, value) {
                    self.productCategories.push(new productCategory(value.ProductCategoryId, value.Name));
                });

                if (typeof callBack !== "undefined") {
                    callBack();
                };
            }
        });
    }
}

ProductVM

I will be binding an instance of ProductVM to the create product view. It is our view model object.

ProductVM  has a self.product object, which is a wrapper for the name, description, and productCategoryId ko.observable properties. I am using product as a wrapper for these properties as it makes the properties easier to work with as a group.

self.product = {
    name: ko.observable(null),
    description: ko.observable(null),
    productCategoryId: ko.observable(0)
}

ProductVM also has self.productCategories which is a ko.observableArray property for storing product categories. An array of product categories is passed into the ProductVM’s constructor during initialization, and this array is in turn passed into the ko.observableArray’s constructor. self.productCategories is used to populate the view’s product category select list.

self.productCategories = ko.observableArray(productCategories);

Next I have the assignment of Knockout validation validation rules to the ko.observable product properties. And the creation of a validation group, which enables us to display, hide, or remove all validation errors.

self.product.name.extend({
    required: true,
    maxLength: 100
});

self.product.description.extend({
    required: true,
    maxLength: 1000
});

self.product.productCategoryId.extend({
    required: true
});

self.errors = ko.validation.group(self.product);

I then define self.generalErrors which is a ko.observableArray that will store all validation errors that are not specific to one of the validatable properties. This array is bound to the errors list towards the bottom of the view using a foreach binding. As such, when it is populated with one or more validation errors, a list item element is created for each, and the messages are displayed.

self.generalErrors = ko.observableArray([]);

The next thing I do is define some validation options. These options are bound, using a validationOptions binding to the form. They are therefore applicable to all input controls in the form.

self.validationOptions = {
    decorateInputElement: true,
    errorElementClass: 'error-element',
    errorMessageClass: 'error-message'
};

Next we have some properties that manage state. The first of these, self.isSaving, is set to true at the start of a save, and then set to false once the save is complete. The next, self.isValid, is a ko.computed property that is set to true when there are no validation error in the errors group, and false when there are. The third property, self.canSave, is a ko.computed property that is set to true when self.isValid, and !self.isSaving. self.canSave is bound to the enable attribute of the view’s add button.

self.isSaving = ko.observable(false);

self.isValid = ko.computed(function () {
    return self.errors().length == 0;
});

self.canSave = ko.computed(function () {
    return self.isValid() && !self.isSaving();
});

The final thing we have in the ProductVM is the save method.

If there are no validation errors the save method sets self.isSaving to true, which has the effect of disabling the view’s add button. This stops the user from invoking concurrent saves. The save method then uses ko.toJSON to generate a JSON string containing the product properties.

The save method then performs an ajax POST to the /api/products url, passing the JSON string in the request body, and if the ajax request is successful then self.isSaving to false.

However, if the ajax request is not successful, and a HTTP 400 response is returned from the server then the following logic is performed –

  • Check to see if there is a ModelState object in the response body.
  • If there is a ModelState object then loop through the keys, and for each key loop through the errors.
  • For each error, if the key matches a validateable property name then append the error to the property.
  • If the key does not match a property name then push the error to the generalErrors observableArray.
  • If the response body does not contain a ModelState object – which is optional – then push the Message string – which is required – to the generalErrors array.

And if a HTTP 500 response is returned then the following logic is performed –

  • The response body will not contain a ModelState object. Push the Message string to the generalErrors array.
self.save = function () {
    if (self.errors().length == 0) {
        self.isSaving(true);

        var dataObject = ko.toJSON(self.product);

        $.ajax({
            url: '/api/products',
            type: 'post',
            data: dataObject,
            dataType: 'json',
            contentType: 'application/json; charset=utf-8',
            success: function (data) {
                self.isSaving(false);
            },
            statusCode: {
                400: function (data) {
                    if (typeof data.responseJSON.ModelState !== 'undefined') {
                        $.each(data.responseJSON.ModelState, function (key, errors) {
                            $.each(errors, function (index, error) {
                                switch (key) {
                                    case 'Name':
                                        self.product.name.appendError(error);
                                        break;
                                    case 'Description':
                                        self.product.description.appendError(error);
                                        break;
                                    case 'ProductCategory':
                                        self.product.productCategoryId.appendError(error);
                                        break;
                                    default:
                                        self.generalErrors.push(error);
                                        break;
                                };
                            });
                        });
                    }
                    else {
                        self.generalErrors.push(data.responseJSON.Message);
                    };
                },
                500: function (data) {
                    self.generalErrors.push(data.statusText + '. Please try again.');
                }
            }
        });
    }
    else {
        self.errors.showAllMessages(true);
    }
};

Because we are appending and pushing the errors to the validatable ko.observable properties and the generalErrors ko.observableArray, and these are bound to the form, then the validation errors will automatically be displayed in the appropriate places in the view.

This is what we will see.

createformerrors

Kick-off

The final thing I have in the view model is the code to kick it all off –

/////////////////////////////////////////////////////////////////
// Let's kick it all off

var productCategoryList = new productCategoryList();

productCategoryList.get(function () {
    var vm = new productVM(productCategoryList.productCategories());
    ko.applyBindings(vm, $addPanel[0]);
    vm.errors.showAllMessages(false);
});

 

For completeness, here’s the full view model.

<script type="text/javascript">
    ///////////////////////////////////////////////////////////////////
    // ko.observable.appendError

    ko.observable.fn.appendError = function (error) {
        var thisError = this.error();
        if (thisError) {
            this.setError(thisError += '. ' + error);
        }
        else {
            this.setError(error)
        };
    };

    $(function () {
        var $addPanel = $('#add-panel');
        ///////////////////////////////////////////////////////////////////
        // ProductCategory

        var productCategory = function (productCategoryId, name) {
            var self = this;

            // Properties
            self.productCategoryId = productCategoryId;
            self.name = name;
        }

        var productCategoryList = function () {
            var self = this;

            self.productCategories = ko.observableArray([]);

            self.get = function (callBack) {
                $.ajax({
                    url: '/api/productCategory',
                    type: 'get',
                    contentType: 'application/json; charset=utf-8',
                    success: function (data) {
                        $.each(data, function (key, value) {
                            self.productCategories.push(new productCategory(value.ProductCategoryId, value.Name));
                        });

                        if (typeof callBack !== "undefined") {
                            callBack();
                        };
                    }
                });
            }
        }

        ///////////////////////////////////////////////////////////////////
        // ProductVM

        var productVM = function (productCategories) {
            var self = this;

            // Properties

            self.product = {
                name: ko.observable(null),
                description: ko.observable(null),
                productCategoryId: ko.observable(0)
            }

            self.productCategories = ko.observableArray(productCategories);

            // Validation

            self.product.name.extend({
                required: true,
                maxLength: 100
            });

            self.product.description.extend({
                required: true,
                maxLength: 1000
            });

            self.product.productCategoryId.extend({
                required: true
            });

            self.errors = ko.validation.group(self.product);

            self.generalErrors = ko.observableArray([]);

            self.validationOptions = {
                decorateInputElement: true,
                errorElementClass: 'error-element',
                errorMessageClass: 'error-message'
            };

            // State

            self.isSaving = ko.observable(false);

            self.isValid = ko.computed(function () {
                return self.errors().length == 0;
            });

            self.canSave = ko.computed(function () {
                return self.isValid() && !self.isSaving();
            });

            // Methods

            self.save = function () {
                if (self.errors().length == 0) {
                    self.isSaving(true);

                    var dataObject = ko.toJSON(self.product);

                    $.ajax({
                        url: '/api/products',
                        type: 'post',
                        data: dataObject,
                        dataType: 'json',
                        contentType: 'application/json; charset=utf-8',
                        success: function (data) {
                            self.isSaving(false);
                        },
                        statusCode: {
                            400: function (data) {
                                if (typeof data.responseJSON.ModelState !== 'undefined') {
                                    $.each(data.responseJSON.ModelState, function (key, errors) {
                                        $.each(errors, function (index, error) {
                                            switch (key) {
                                                case 'product.Name':
                                                    self.product.name.appendError(error);
                                                    break;
                                                case 'product.Description':
                                                    self.product.description.appendError(error);
                                                    break;
                                                case 'product.ProductCategory':
                                                    self.product.productCategoryId.appendError(error);
                                                    break;
                                                default:
                                                    self.generalErrors.push(error);
                                                    break;
                                            };
                                        });
                                    });
                                }
                                else {
                                    self.generalErrors.push(data.responseJSON.Message);
                                };
                            },
                            500: function (data) {
                                self.generalErrors.push(data.statusText + '. Please try again.');
                            }
                        }
                    });
                }
                else {
                    self.errors.showAllMessages(true);
                }
            };
        }

        /////////////////////////////////////////////////////////////////
        // Let's kick it all off

        var productCategoryList = new productCategoryList();

        productCategoryList.get(function () {
            var vm = new productVM(productCategoryList.productCategories());
            ko.applyBindings(vm, $addPanel[0]);
            vm.errors.showAllMessages(false);
        });
    });
</script>