Let's Learn Adonis 5: Validating Requests

In this lesson, we focus on Adonis' built-in Validator. We'll learn how to define a validation schema, custom messages, custom rules, and more

Published
Apr 03, 21
Duration
17m 52s

Developer, dog lover, and burrito eater. Currently teaching AdonisJS, a fully featured NodeJS framework, and running Adocasts where I post new lessons weekly. Professionally, I work with JavaScript, .Net C#, and SQL Server.

Adocasts

Burlington, KY

Validation allows us to ensure the data we're accepting and working with matches what we expect it to be. For example, we need our user's username to be a string with a maximum length of 50 characters, and it also needs to be unique amongst all other usernames in our users table.

We can use validation to ensure a requested username matches all these criteria. Likewise, we can do this for all data we need to store for our application.

The Adonis Validator

Every new Adonis 5 project comes with the built-in Adonis Validator. This validator comes with a number of default types and rules that we can validate against. We can also extend the rules as needed by defining our own. The validator provides error messages for any failed validations. These too can be customized.

The Validator can be used in two different contexts, directly off our request, and standalone. We'll be focusing on using the Validator directly off the request first.

Validation Schema

In order to validate data using Adonis' Validator, we must first define a schema for the Validator to validate against. It'll then use this schema to ensure all our data matches our schema definition. If any data fail our validation, the Validator will throw an error.

To start with we'll want to import schema from @ioc:Adonis/Core/Validator. This schema object is what contains our different type definitions and a method to create our Validator schema.

import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import { schema, rules } from '@ioc:Adonis/Core/Validator'
import Status from 'Contracts/Enums/Status'

class TasksController {
  // ... other methods

  public async store ({}: HttpContextContract) {
    const taskSchema = schema.create({
      name: schema.string(),
      description: schema.string.optional(),
      statusId: schema.enum(Object.values(Status))
    })
  }
}

So, here we're creating a new Validator schema by calling schema.create(). This method then accepts an object containing our schema definition. The keys of our schema definition would be the key names of our data. For example, our data may look something like this:

{
  name: "My Introductory Task",
  description: "Welcome to your new task",
  statusId: 1
}

We'll then want to define a type for each key name using one of the types available off the schema object. You can find a full list of available types here. Our name is a required string, description is optional but must be a string if provided, and our statusId must be one of the enum values defined on our Status enum.

Some types accept an argument of an array of rules. These rules, also exported from @ioc:Adonis/Core/Validator, allow us to specify more granular validations beyond just validating the data type. To apply a rule, in the rule argument for the type, provide an array and within the array call the rule method for each of the rules you want to apply to that particular key.

import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import { schema, rules } from '@ioc:Adonis/Core/Validator';
import Status from 'Contracts/Enums/Status'

class TasksController {
	// ... other methods

  public async store ({}: HttpContextContract) {
    const taskSchema = schema.create({
      name: schema.string({}, [rules.minLength(3), rules.maxLength(50)]),
      description: schema.string.optional(),
      statusId: schema.enum(Object.values(Status))
    })
  }
}

In this example we're stating the following:

  • Name: required, must be a string, must be at least 3 characters long, but no more than 50 characters long

  • Description: optional, but must be a string if provided

  • StatusId: the value must be one of the values defined on the Status enum.

For some types, like number, the rules will be provided as the first argument. For others, like string, it'll be provided as the second argument. In string's case, the first argument is two additional options, escape and trim. Escape will escape <, >, &, ', ", and / with HTML entities. Trim will trim whitespace from the front and end of the string value.

These can be provided like so:

const taskSchema = schema.create({
  name: schema.string({ escape: true, trim: true }, [/* rules here */])
})

The rules object we import from @ioc:Adonis/Core/Validator has a number of different default validation rules we can use. You can find the full list of the provided rules here.

So far we've only defined our validation schema, what the Validator will use to validate against. Next, we need to actually perform the validation.

Validating Requests

Adonis' Validator conveniently resides on our route handler's request object. This version of the Validator, since it's directly on our request, has access to our request body. By default, when we run the Validator off our request it'll automatically validate against the data residing on our request body, so we don't need to provide it the data we want to validate.

Since the Validator resides directly on our request object, we can call the validate method by extracting our request from our HttpContextContract. Then, we provide it our schema.

import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import { schema, rules } from '@ioc:Adonis/Core/Validator';
import Status from 'Contracts/Enums/Status'

class TasksController {
  // ... other methods

  public async store ({ request }: HttpContextContract) {
    const taskSchema = schema.create({
      name: schema.string({ trim: true }, [rules.minLength(3), rules.maxLength(50)]),
      description: schema.string.optional(),
      statusId: schema.enum(Object.values(Status))
    })

    const data = await request.validate({ schema: taskSchema })
  }
}

If our request body data is invalid, Adonis will throw an error and automatically handle the response for us. If the request was JSON, the error will be returned as JSON. If the request was from a form, the user will be redirected back to said form and Adonis will provide us the errors in the form of flash messaging, which we'll cover in a later lesson.

If our request body data is valid the validate method will return back an object containing only the keys we defined on our schema.

So, if our request body contained:

{
  name: " My Introductory Task ",
  description: "Welcome to your new task",
  statusId: 1,
  assgineeId: 2
}

Since we didn't define assigneeId on our schema, the data returned from the validate method wouldn't contain the assigneeId key value pair.

{
  name: "My Introductory Task",
  description: "Welcome to your new task",
  statusId: 1
}

Also, note that since we have trim: true on our name string, the Validator will perform a trim on the data returned from the validate method.

Custom Error Messages

Adonis' Validator allows us to define custom messaging should our validation fail. You can define error messages a number of different ways. Note that you can also mix-and-match these as needed as well.

  • By schema key and rule

    const messages = {
      'name.minLength': 'Your name must be at least {{ options.minLength }} characters long',
      'name.maxLength': 'Your name cannot be longer than {{ options.maxLength }} characters long'
    }
  • By rule

    const messages = {
      minLength: '{{ field }} must be at least {{ options.minLength }} characters long',
      maxLength: '{{ field }} cannot be longer than {{ options.maxLength }} characters long'
    }
  • Wildcard

    const messages = {
      '*': (field, rule, arrayExpressionPointer, options) => {
        return `${field} failed ${rule} validation`
      }
    }

The Validator has a few template options, all of which you can see in use in the above examples.

  • {{ field }} injects the name of the field that failed the particular validation, for example, "name".

  • {{ rule }} injects the name of the validation rule, for example, "minLength".

  • {{ options }} will inject particular options as specified by the rule. You can find the full rundown of these options here.

Then, in order to apply these custom messages to our validation, we pass the object into our validate call, like so.

import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import { schema, rules } from '@ioc:Adonis/Core/Validator';
import Status from 'Contracts/Enums/Status'

class TasksController {
  // ... other methods

  public async store ({ request }: HttpContextContract) {
    const taskSchema = schema.create({
      name: schema.string({ trim: true }, [rules.minLength(3), rules.maxLength(50)]),
      description: schema.string.optional(),
      statusId: schema.enum(Object.values(Status))
    })

    const messages = {
      minLength: '{{ field }} must be at least {{ options.minLength }} characters long',
      maxLength: '{{ field }} cannot be longer than {{ options.maxLength }} characters long'
    }

	const data = await request.validate({ schema: taskSchema, messages })
  }
}

Standalone Usage

If we need to validate data where we aren't directly working off our request or if we just need to manually provide the data for whatever reason, we can utilize the standalone usage for the Validator.

There are only two differences here.

  1. We need to import the Validator from @ioc:Adonis/Core/Validator

  2. We need to provide the data to our validate call

import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import { validator, schema, rules } from '@ioc:Adonis/Core/Validator';
import Status from 'Contracts/Enums/Status'

class TasksController {
  // ... other methods

  public async store ({ request }: HttpContextContract) {
    const taskSchema = schema.create({
      name: schema.string({ trim: true }, [rules.minLength(3), rules.maxLength(50)]),
      description: schema.string.optional(),
      statusId: schema.enum(Object.values(Status))
    })

    const messages = {
      minLength: '{{ field }} must be at least {{ options.minLength }} characters long',
      maxLength: '{{ field }} cannot be longer than {{ options.maxLength }} characters long'
    }

    const data = {
      name: "My Introduction Task",
      description: "This is an example task",
      statusId: Status.IDLE
    }

	const validData = await request.validate({ 
      schema: taskSchema, 
      messages,
      data
    })
  }
}

Custom Rules

We can define our own custom rules for use with Adonis' Validator by first creating a preload file. Depending on the number of custom rules you're going to want to define you can either create a single preload file for all your custom rules or a preload file for each custom rule. We're going to just be creating a single custom rule, so we'll just make a single file called validationRules.

So, in your terminal start by running the following Ace command to create our preload file.

$ node ace make:prldfile validationRules

> Select the environment(s) in which you want to load this file ...
⚪ During ace commands
🟢 During HTTP server

CREATE: start/validationRules.ts

When asked which environment the file should be loaded in select "During HTTP server". This will load the preload file when the HTTP server boots up.

Our new file validationRules will then be created inside our start directory. By default, it will be an empty file that begins with a comment block, like the below.

/*
|--------------------------------------------------------------------------
| Preloaded File
|--------------------------------------------------------------------------
|
| Any code written inside this file will be executed during the application
| boot.
|
*/

Then, to create a custom rule, we can import the validator from @ioc:Adonis/Core/Validator, and define the custom rule using the rule method.

import { validator } from '@ioc:Adonis/Core/Validator'

validator.rule('myRuleName', (value, arg, { pointer, arrayExpressionPointer, errorReporter }) => {
  let ruleIsValid = false;

  // define rule here

  if (ruleIsInvalid) {
    errorReporter.report(pointer, 'myRuleName', 'The value for this rule failed validation', arrayExpressionPointer)
  }
})

The first argument is whatever name we want our custom rule to have. The second argument is a callback function where we're provided the value, array of provided arguments, and an object containing some error reporting properties.

Let's say for our username we want to have a list of usernames we don't want the user to be able to have, like admin for example. Instead of adding logic to our controller checking this, we can utilize a custom validation rule that we can call notIn. We can use this to ensure our username is not in a provided array of strings.

import { validator } from '@ioc:Adonis/Core/Validator'

validator.rule('notIn', (value: string, [notInArray], { pointer, arrayExpressionPointer, errorReporter }) => {
  // if value isn't a string we can skip validation and let string type check handle it.
  // if we aren't given an array of values to check against, validation auto passes
  if (typeof value !== 'string' || !Array.isArray(notInArray)) {
    return;
  }

  // check provided array for value
  const matches = notInArray.filter(item => {
    return typeof item === 'string' ? item.toLowerCase() === value.toLowerCase() : item === value
  });

  // report the validation error
  if (!matches.length) {
    errorReporter.report(pointer, 'notIn', 'This value is restricted and not allowed', arrayExpressionPointer)
  }
})

So, here we define our rule as notIn, which will check to ensure a provided array of strings does not match the value. If a match is found, then the rule will report the error using the errorReporter.

Next, we need to add our notIn rule to the Rules interface so TypeScript recognizes it. To do this, we can create a new file in our contracts directory called validator.ts. Then, in this file, we want to extend the Validator module and the Rules interface to include our custom rule.

declare module '@ioc:Adonis/Core/Validator' {
  import { Rule } from '@ioc:Adonis/Core/Validator'

  export interface Rules {
    notIn(options: Array<string>): Rule
  }
}

Note that since our rule accepts an array of strings, we'll want to define that as the type for the rule's options value.

Lastly, we just need to implement our rule. So, since this was created with our username value in mind, let's create a validation schema for our user.

import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import { schema, rules } from '@ioc:Adonis/Core/Validator';

export default class UsersController {
  public async register({ request, response }: HttpContextContract) {
    const userSchema = schema.create({
      username: schema.string({ trim: true }, [
        rules.maxLength(50),
        rules.minLength(3),
        rules.unique({ table: 'users', column: 'username' }),
        rules.regex(/^[a-zA-Z0-9-_]+$/),
        rules.notIn(['admin', 'super', 'moderator', 'public', 'dev', 'alpha', 'mail']) // 👈
      ]),
      email: schema.string({ trim: true }, [rules.unique({ table: 'users', column: 'email' })]),
      password: schema.string({}, [rules.minLength(8)])
    });

    const data = await request.validate({ schema: userSchema })
  }
}

So, let's break this down a bit.

username
Must be a string, a value is required, the provided value will be trimmed of whitespace at the start and end of the string, and must pass the following rules:

  • Max length of 50

  • Min length of 3

  • Must be unique (not already in the database) in the table users under the column username

  • Must be alphanumeric with hyphens or underscores

  • Cannot be equal to admin, super, moderator, public, dev, alpha, or mail. This one is enforced by our custom notIn rule.

email
Must be a string, a value is required, the provided value will be trimmed of whitespace at the start and end of the string, and must pass the following rules:

  • Must be unique in the table users under the column email

password
Must be a string, a value is required, and must pass the following rules:

  • Min length of 8

Creating Reusable Schemas and Messages

If you have a rather large validation schema or message pattern, or if you just need to use a validation schema in multiple places, Adonis allows you to create a specific validation file. This file can contain the schema definition and messages so that we don't have to directly define these within our controllers.

To start with head into your terminal and run the following Ace command

$ node ace make:validator RegisterValidator
CREATE: app/Validators/RegisterValidator

This will create our new validation file within our app/Validators directory. By default, the file will contain an exported class of our validator, a stubbed constructor call that has access to our HttpContextContract, a public schema definition, and a public messages object.

In order to extract our previous user registration validation from our UsersController to our new RegisterValidator all we need to do is copy/paste the schema definition into our validator's schema and define any messages we may want.

import { schema, rules } from '@ioc:Adonis/Core/Validator'
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'

export default class RegisterValidator {
  constructor (protected ctx: HttpContextContract) {
  }

	/*
	 * Define schema to validate the "shape", "type", "formatting" and "integrity" of data.
	 *
	 * For example:
	 * 1. The username must be of data type string. But then also, it should
	 *    not contain special characters or numbers.
	 *    ```
	 *     schema.string({}, [ rules.alpha() ])
	 *    ```
	 *
	 * 2. The email must be of data type string, formatted as a valid
	 *    email. But also, not used by any other user.
	 *    ```
	 *     schema.string({}, [
	 *       rules.email(),
	 *       rules.unique({ table: 'users', column: 'email' }),
	 *     ])
	 *    ```
	 */
  public schema = schema.create({
    username: schema.string({ trim: true }, [
      rules.maxLength(50),
      rules.minLength(3),
      rules.unique({ table: 'users', column: 'username' }),
      rules.regex(/^[a-zA-Z0-9-_]+$/),
      rules.notIn(['admin', 'super', 'moderator', 'public', 'dev', 'alpha', 'mail']) // 👈
    ]),
    email: schema.string({ trim: true }, [rules.unique({ table: 'users', column: 'email' })]),
    password: schema.string({}, [rules.minLength(8)])
  })

	/**
	 * Custom messages for validation failures. You can make use of dot notation `(.)`
	 * for targeting nested fields and array expressions `(*)` for targeting all
	 * children of an array. For example:
	 *
	 * {
	 *   'profile.username.required': 'Username is required',
	 *   'scores.*.number': 'Define scores as valid numbers'
	 * }
	 *
	 */
  public messages = {}
}

Then, we can simplify our UsersController register method to the following.

import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import RegisterValidator from 'App/Validators/RegisterValidator';

export default class UsersController {
  public async register({ request, response }: HttpContextContract) {
    const data = await request.validate(RegisterValidator)
  }
}

Extendable Custom Messages

Lastly, I just want to quickly cover something I like to do within my Adonis projects in regard to defining custom messages. I like to create a BaseValidator file inside my app/Validators directory. Then, inside this BaseValidator I add some vague custom error messages for rules I'm going to be using a lot. It ends up looking something like the one below.

export default class BaseValidator {
  public messages = {
    minLength: "{{ field }} must be at least {{ options.minLength }} characters long",
    maxLength: "{{ field }} must be less then {{ options.maxLength }} characters long",
    required: "{{ field }} is required",
    unique: "{{ field }} must be unique, and this value is already taken"
  }
}

Then, on my actual validator files, I import this BaseValidator and extend my actual validator from this BaseValidator like so.

import { schema, rules } from '@ioc:Adonis/Core/Validator'
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import BaseValidator from './BaseValidator'

export default class RegisterValidator extends BaseValidator {
  constructor (protected ctx: HttpContextContract) {
    super() // 👈 don't forget to call super
  }

  public schema = schema.create({
    username: schema.string({ trim: true }, [
      rules.maxLength(50),
      rules.minLength(3),
      rules.unique({ table: 'users', column: 'username' }),
      rules.regex(/^[a-zA-Z0-9-_]+$/),
      rules.notIn(['admin', 'super', 'moderator', 'public', 'dev', 'alpha', 'mail']) // 👈
    ]),
    email: schema.string({ trim: true }, [rules.unique({ table: 'users', column: 'email' })]),
    password: schema.string({}, [rules.minLength(8)])
  })
}

Now all my BaseValidator messages will be applied to my RegisterValidator.

Next Up

In the next lesson, we'll be moving out of our app directory and into our resources directory and begin covering the Edge templating engine.

Join The Discussion! (0 Comments)

Please sign in or sign up for free to join in on the dicussion.

robot comment bubble

Be the first to Comment!