Source Allies Logo

Sharing Our Passion for Technology

& Continuous Learning

<   Back to Blog

Node Reference - Validation

Teammates sitting together and looking at code

Prerequisites

This article builds on the prior article: Node Reference - Put.

Add model validation

We now have a service that allows us to create product records but does not provide any mechanism for ensuring those products contain all the required data we need in the correct format. For this, we need data model validation.

While you could certainly implement validation logic by hand using a series of "if" statements, that approach can quickly become surprisingly complicated. Remember that most validators not only need to determine if a model is valid, but also all of the reasons it might not be valid. When looking for a validation library there are three things we like to consider:

  1. Does this library have a good collection of out-of-the-box validation rules that we might need on this project? Remember that things like email and phone validation are more complicated than they seem. (Checkout out some of these valid email addresses on Wikipedia{:target="_blank"}).
  2. Does it have good documentation on how to write a "custom" validation rule? Not all rules are going to be covered by the built-in validators, so it should be clear how to hook in our own logic.
  3. How are custom rules that are asynchronous handled? A rule that checks the database for duplicates or queries a remote service to determine if something is valid should allow the rule to return a Promise{:target="_blank"} or invoke a callback{:target="_blank"}.

For this project we'll be using Joi{:target="_blank"}. While not known for having great support for async{:target="_blank"} validators, it has a large library of built-in validation rules. Run npm install --save joi to install it and then create a products/validateProduct.spec.js file with these contents for our tests:

describe('validateProduct', function () {
    beforeEach(function () {
        this.validProduct = {
            name: 'widget',
            imageURL: 'https://example.com/widget.jpg'
        };
        this.validateProduct = require('./validateProduct');
    });

    it('should return nothing if the product is valid', function() {
        const result = this.validateProduct(this.validProduct);
        expect(result).not.toBeDefined();
    });

    describe('name', function() {
      it('should return invalid if name is undefined', function() {
          delete this.validProduct.name;
          const result = this.validateProduct(this.validProduct);
          expect(result['/name']).toContain('"name" is required');
      });

      it('should return invalid if name is an empty string', function() {
          this.validProduct.name = '';
          const result = this.validateProduct(this.validProduct);
          expect(result['/name']).toContain('"name" is not allowed to be empty');
      });

      it('should return invalid if name is a blank string', function() {
          this.validProduct.name = '   ';
          const result = this.validateProduct(this.validProduct);
          expect(result['/name']).toContain('"name" is not allowed to be empty');
      });

      it('should return valid if name has a space', function() {
          this.validProduct.name = 'test product';
          const result = this.validateProduct(this.validProduct);
          expect(result).not.toBeDefined();
      });
    });

    describe('imageURL', function() {
        it('should return invalid if undefined', function() {
            delete this.validProduct.imageURL;
            const result = this.validateProduct(this.validProduct);
            expect(result['/imageURL']).toContain('"imageURL" is required');
        });

        it('should return invalid if an empty string', function() {
            this.validProduct.imageURL = 'abc';
            const result = this.validateProduct(this.validProduct);
            expect(result['/imageURL']).toContain('"imageURL" must be a valid uri');
        });
    });
});

You may have noticed that the format of the return value of the validate function is a bit odd. If there are no validation errors, we are returning undefined. Otherwise, we are returning an object where each key of the object is a slash-separated property path and the value is an array of errors.

The reason we are doing this is because the validation errors are going to ultimately be returned to the client of our service. We will need to document the contract of what the client should expect back from our endpoints, in the body, when an HTTP 400 status code is received.

We could have used simple property names for our keys, but over time it is reasonable to assume that our model will grow to contain arrays, nested objects and other non-flat structures. How do we represent errors at these lower levels? How do we tell the client that the "startDate" field on the 3rd item in an array is at fault? When faced with these kinds of questions, it can help to leverage an existing specification if that specification can be implemented without undue burden. In this case, we can leverage JSON pointer{:target="_blank"}. Because we plan on using JSON Patch{:target="_blank"} to handle model updates, both endpoints will have a consistent way of referencing model fields/properties.

We can implement the validator by creating products/validateProduct.js with these contents:

const Joi = require('joi');

const schema = Joi.object({
    id: Joi.string(),
    name: Joi.string()
        .required()
        .trim(),
    imageURL: Joi.string()
        .required()
        .trim()
        .uri(),
    lastModified: Joi.string()
        .isoDate()
});

const options = {
    abortEarly: false,
    allowUnknown: true,
    stripUnknown: { objects: true}
};

module.exports = function validateProduct(product) {
    let validationResults = schema.validate(product, options);
    let errors = validationResults.error && validationResults.error.details;
    if (errors && errors.length) {
        const collapsedErrors = {};
        errors.forEach(err => {
            let jsonPointer = '/' + err.path.join('/');
            let existingErrorsForField = collapsedErrors[jsonPointer] || [];
            collapsedErrors[jsonPointer] = [...existingErrorsForField, err.message];
        });
        return collapsedErrors;
    }
};

Run a quick npm test (all tests should pass), and then all we have to do is wire in our validation functionality to our createProduct module. Add the following pieces to products/createProduct.spec.js:

...
beforeEach(function () {
    ...

    this.validateProduct = (product) => undefined;
    spyOn(this, 'validateProduct').and.callThrough();

    this.createProduct = proxyquire('./createProduct', {
        'aws-sdk': {
            DynamoDB: {
                DocumentClient: function() {
                    return documentClient;
                }
            }
        },
        './validateProduct': this.validateProduct
    });

});
...
it('should return validation errors as the body if validation fails', async function(){
    let errors = {name: []};
    this.validateProduct.and.returnValue(errors);
    await this.createProduct(this.context);
    expect(this.context.body).toBe(errors);
});

it('should set status to 400 if validation fails', async function(){
    this.validateProduct.and.returnValue({name: []});
    await this.createProduct(this.context);
    expect(this.context.status).toEqual(400);
});

it('should not save the product if validation fails', async function(){
    this.validateProduct.and.returnValue({name: []});
    await this.createProduct(this.context);
    expect(this.documentClient.put).not.toHaveBeenCalled();
});

We can now implement these tests by adding a require of our validation module and a check for validity in products/createProduct.js:

const validateProduct = require('./validateProduct');
...
module.exports = async function createProduct(ctx) {
    const product = ctx.request.body;

    const validationErrors = validateProduct(product);
    if (validationErrors) {
        ctx.body = validationErrors;
        ctx.status = 400;
        return;
    }
    ...
}

Validation is now in place.
You can test it out by running: npm test. See the changes we made here{:target="_blank}.

Table of Contents

If you have questions or feedback on this series, contact the authors at nodereference@sourceallies.com.