Playing Next Lesson In
seconds

Let's Learn AdonisJS 7 #2.7

Validation & Flash Storage

In This Lesson

Learn how to validate user-provided data and forms in AdonisJS using VineJS. We'll also discuss how to validate route parameters, query strings, cookies, and headers as well.

Created by
@tomgobich
Published

As mentioned in the last lesson, we want to be defensive with all the user-provided data we accept in our applications. The best way to do that is with validation, which allows us to ensure that the data provided adheres to the constraints we've defined.

Defining Validators

To start, we'll make a new file for our challenge validator using the Ace CLI.

node ace make:validator challenge
# DONE:    create app/validators/challenge.ts
Copied!

We can find our validator files within app/validators and a single file can define and export multiple validators. When we open our new challenge validator file, we'll find an import for VineJS. VineJS is AdonisJS's validation system; like EdgeJS, it too is home-grown by the AdonisJS Core Team.

import vine from '@vinejs/vine'
Copied!
  • app
  • validators
  • challenge.ts

We define a validator by calling the create method from vine and pass in an object where the keys are the field names we're submitting with our form. The values of our object are then the validation rules for that field.

import vine from '@vinejs/vine'

export const challengeValidator = vine.create({
  text: vine.string().minLength(5).maxLength(255),
  points: vine.number().min(1).max(100),
})
Copied!
  • app
  • validators
  • challenge.ts

We start by describing the type of the field, our text is a string, and points is a number. Then, we can add additional constraints or normalizations we want the field to adhere to. Here, we're saying the text value must be between 5 and 255 characters long, and the points value must be between 1 and 100. Alternatively, we could use range([1, 100]) to do the same.

By default, any field we define in our validator will be required, unless specifically marked as optional(). In our case, we need both of these fields for our challenge, so we'll keep them both as required.

Validating Data

Next, we need to use our validator within our controller.

import type { HttpContext } from '@adonisjs/core/http'

const challenges = [
  { id: 1, text: 'Learn AdonisJS', points: 10 },
  { id: 2, text: 'Learn EdgeJS', points: 5 },
  { id: 3, text: 'Build an AdonisJS app', points: 20 },
]

export default class ChallengesController {
  // ...

  /**
   * Handle form submission for the create action
   */
  async store({ request, response }: HttpContext) {
    const data = await request.validateUsing(challengeValidator)

    challenges.push({ id: challenges.length + 1, ...data })

    return response.redirect('/challenges')
  }

  // ...

  /**
   * Handle form submission for the edit action
   */
  async update({ params, request, response }: HttpContext) {
    const data = await request.validateUsing(challengeValidator)

    // find the challenge being updated by its id, and update its data
    const challengeIndex = challenges.findIndex((row) => row.id === params.id)
    challenges[challengeIndex] = { id: params.id, ...data }

    return response.redirect('/challenges')
  }

  // ...
}
Copied!
  • app
  • controllers
  • challenges_controller.ts

By using the validator directly off our request, AdonisJS will automatically pass our request's data, including the body, route params, query strings, cookies, and headers, to the validator. The request body is a direct property, while everything else is nested under their applicable name, for example:

// example
import vine from '@vinejs/vine'

export const exampleValidator = vine.create({
  someBodyProperty: vine.string(),

  params: vine.object({
    id: vine.number()
  }),

  qs: vine.object({
    sort: vine.string().in(['asc', 'desc']),
  }),

  cookies: vine.object({
    sessionId: vine.string()
  }),

  headers: vine.object({
    'x-api-version': vine.number().range([1, 7])
  })
})
Copied!

Additionally, the data we get back from our validation is type-safe as well. If, for any reason, we need to use a validator's type, we can easily do so with VineJS's Infer type helper.

import { type Infer } from '@vinejs/vine/types'

function example({ text, points }: Infer<typeof challengeValidator>) {
  console.log({ text, points })
}
Copied!

Our challengeValidator will give us our object type:

const data: {
  text: string;
  points: number;
}
Copied!

Finally, if we need to explicitly define the data that should be validated, we can do that as well using the validator directly.

export default class ChallengesController {
  // ...

  /**
   * Handle form submission for the create action
   */
  async store({ request, response }: HttpContext) {
    const data = await challengeValidator.validate(request.all())

    challenges.push({ id: challenges.length + 1, ...data })

    return response.redirect('/challenges')
  }

  // ...
}
Copied!
  • app
  • controllers
  • challenges_controller.ts

Validation Errors & User Feedback

Okay, so let's give our validator a try by entering something like:

{
  "text": "test",
  "points": 500
}
Copied!

When we send this, you'll notice we get redirected right back to where we were, and our form is emptied, why? When our validation fails, the validator will throw an exception. Our exception handler will then conveniently handle this for us using content-negotiation. When we submit an HTML form, it will redirect us back to the page. When using application/json it will return a 422 status code with our errors. So, where can we find our errors for form submissions?

Let's dump our state, and fill out our form again with a text of "test" and "500" points, and submit again.

@layout()

  <div class="form-container">
    <div>
      <h1> Create Challenge </h1>
      <p> Enter your challenge details below </p>
    </div>

    @dump(state)

    {{-- ... --}}
  </div>

@end
Copied!
  • resources
  • views
  • pages
  • challenges
  • create.edge

On our state we should see a property called flashMessages. Flash messages are messages sent by our server for this single request. If we expand this, we should see the following.

flashMessages: ReadOnlyValuesStore {
  values: Object {
    text: 'test',
    points: '500',
    errorsBag: Object {
      E_VALIDATION_ERROR: 'The form could not be saved. Please check the errors below.',
    },
    inputErrorsBag: Object {
      text: Array:1 [
        'The text field must have at least 5 characters',
      ],
      points: Array:1 [
        'The points field must be between 1 and 100',
      ],
    },
  },
  [[Prototype]] {}
}
Copied!

This is where we can find the contextual information about our validation failure. The errorsBag is where we'll find general exceptions and errors and inputErrorsBag is where we'll find specific validation errors. If there is a property in here, it means that the field failed validation, and AdonisJS gives an array of messages for those failures. Additionally, you'll also note AdonisJS has flashed the text and point values we submitted as well.

So, how can we use all of this? Well, for starters, we can access them directly with our flashMessages directly, or using one of its helper methods listed in its prototype.

flashMessages: ReadOnlyValuesStore {
  values: Object {...},
  [[Prototype]] {
    isEmpty: [Getter]
    get: [function get],
    has: [function has],
    all: [function all],
    toObject: [function toObject],
    toJSON: [function toJSON],
  }
}
Copied!
@layout()

  <div class="form-container">
    <div>
      <h1> Create Challenge </h1>
      <p> Enter your challenge details below </p>
    </div>

    @dump(state)

    <div>
      <form action="/challenges" method="POST">
        {{ csrfField() }}

        <div>
          <label>
            Text
            <input type="text" name="text" />
          </label>

          @if (flashMessages.has('inputErrorsBag.text'))
            <div>
              {{ flashMessages.get('inputErrorsBag.text').join(', ') }}
            </div>
          @endif
        </div>

        <div>
          <label>
            Points
            <input type="number" name="points" />
          </label>

          @if (flashMessages.has('inputErrorsBag.points'))
            <div>
              {{ flashMessages.get('inputErrorsBag.points').join(', ') }}
            </div>
          @endif
        </div>

        <div>
          <button type="submit" class="button"> Create Challenge </button>
        </div>
      </form>
    </div>
  </div>

@end
Copied!
  • resources
  • views
  • pages
  • challenges
  • create.edge

This is a common situation, though, so AdonisJS has added some utility tags to help us get at this data called @inputError. This tag injects our errors into the main scope, so we can access them with just $messages.

@layout()

  <div class="form-container">
    <div>
      <h1> Create Challenge </h1>
      <p> Enter your challenge details below </p>
    </div>

    @dump(state)

    <div>
      <form action="/challenges" method="POST">
        {{ csrfField() }}

        <div>
          <label>
            Text
            <input type="text" name="text" />
          </label>

          @inputError('text')
            <div>
              {{ $messages.join(', ') }}
            </div>
          @end
        </div>

        <div>
          <label>
            Points
            <input type="number" name="points" />
          </label>

          @inputError('points')
            <div>
              {{ $messages.join(', ') }}
            </div>
          @end
        </div>

        <div>
          <button type="submit" class="button"> Create Challenge </button>
        </div>
      </form>
    </div>
  </div>

@end
Copied!
  • resources
  • views
  • pages
  • challenges
  • create.edge

Fantastic! Same situation if we'd like to repopulate our form with its previously submitted values, which is generally a good idea. Again, this is a common situation, so there's a utility method called old() to help us get at this as well.

@layout()

  <div class="form-container">
    <div>
      <h1> Create Challenge </h1>
      <p> Enter your challenge details below </p>
    </div>

    @dump(state)

    <div>
      <form action="/challenges" method="POST">
        {{ csrfField() }}

        <div>
          <label>
            Text
            <input type="text" name="text" value="{{ flashMessages.get('text', '') }}" />
          </label>

          @inputError('text')
            <div>
              {{ $messages.join(', ') }}
            </div>
          @end
        </div>

        <div>
          <label>
            Points
            <input type="number" name="points" value="{{ old('points') }}" />
          </label>

          @inputError('points')
            <div>
              {{ $messages.join(', ') }}
            </div>
          @end
        </div>

        <div>
          <button type="submit" class="button"> Create Challenge </button>
        </div>
      </form>
    </div>
  </div>

@end
Copied!
  • resources
  • views
  • pages
  • challenges
  • create.edge

Cleaning Up with Components

Great, our form is looking good! Let's make it look better, because the starter kit comes with components, wrapping everything we've done here in a pretty little bow!

@layout()

  <div class="form-container">
    <div>
      <h1> Create Challenge </h1>
      <p> Enter your challenge details below </p>
    </div>

    <div>
      <form action="/challenges" method="POST">
        {{ csrfField() }}

        <div>
          @field.root({ name: 'text' })
            @!field.label({ text: 'Text' })
            @!input.control({ type: 'text' })
            @!field.error()
          @end
        </div>

        <div>
          @field.root({ name: 'points' })
            @!field.label({ text: 'Points' })
            @!input.control({ type: 'number' })
            @!field.error()
          @end
        </div>

        <div>
          <button type="submit" class="button"> Create Challenge </button>
        </div>
      </form>
    </div>
  </div>

@end
Copied!
  • resources
  • views
  • pages
  • challenges
  • create.edge

Here, @field.root({ name: 'text' }) injects the previously submitted value and validation errors into its child context. The control and error then read that context to display things similarly to how we just had it ourselves, giving you an idea of just how powerful components can be at condensing things into an easily digestible package.

The only thing these components use that we haven't already covered are prop helpers! If we, for example, take a look at our field label component.

@let(labelTextFromSlot = await $slots.main())
@let(labelText = labelTextFromSlot.trim() ? labelTextFromSlot : $props.get('text'))
@let(classes = [])

<label {{ $props
  .except(['text'])
  .merge({
    for: $context.id,
    class: classes,
    'data-invalid': $context.hasErrors ? 'true' : false,
  })
  .toAttrs() }}>{{{ labelText }}}</label>
Copied!
  • resources
  • views
  • components
  • field
  • label.edge

First, it's putting its slot's HTML into a variable called labelTextFromSlot. If it has contents, it'll use that as the value for labelText, otherwise it will try to get the text prop using the $props get method utility.

We've seen these methods before when we took a look at $props prototype methods:

ComponentProps {
  [[Prototype]] {
    all: [function all],
    has: [function has],
    get: [function get],
    only: [function only],
    except: [function except],
    merge: [function merge],
    mergeIf: [function mergeIf],
    mergeUnless: [function mergeUnless],
    toAttrs: [function toAttrs],
  }
}
Copied!

Our label is stating that it wants to use all the props except text. Merge forclass, and data-invalid into those props. Finally, toAttrs() takes the chain's results and converts them into valid HTML attributes to be placed on the label.

Sweet, before we round out this lesson, let's get our edit page up-to-date as well.

@layout()

  <div class="form-container">
    <div>
      <h1> Edit Challenge </h1>
      <p> Enter your challenge details below </p>
    </div>

    <div>
      <form action="/challenges/{{ challenge.id }}?_method=PUT" method="POST">
        {{ csrfField() }}

        <div>
          @field.root({ name: 'text' })
            @!field.label({ text: 'Text' })
            @!input.control({ type: 'text', value: challenge.text })
            @!field.error()
          @end
        </div>

        <div>
          @field.root({ name: 'points' })
            @!field.label({ text: 'Points' })
            @!input.control({ type: 'number', value: challenge.points })
            @!field.error()
          @end
        </div>

        <div>
          <button type="submit" class="button"> Update Challenge </button>
        </div>
      </form>
    </div>
  </div>

@end
Copied!
  • resources
  • views
  • pages
  • challenges
  • edit.edge

Remember, we need to pre-populate the field's values with the values of the challenge we're editing. Like our label component, the input control component will also build out and merge its props into attributes, so value works just fine here!

Join the Discussion 0 comments

Create a free account to join in on the discussion
robot comment bubble

Be the first to comment!