Validation

In Velocity, we follow a convention for security and data integrity reasons that states that no user input can be accessed without first validating its contents. For this validation, we use the yup library, which is a JavaScript schema builder for value parsing and validation.

yup allows you to define a schema for the data you're expecting to receive and then validate the data against this schema. This includes checking that the data has the correct types, that required fields are present, and even applying custom validation rules. If the validation fails, it returns detailed error messages that make it clear what went wrong.

To make sure that no user input can be accessed without validation, and to integrate the yup validation with our Velocity codebase, we've created utility functions. These utilities use TypeScript's static typing capabilities to ensure it's only possible to access the user input via validation.

As discussed in the "Context" chapter, we use the AsyncLocalStorage API from Node.js to create a context for all pages, and we initialize the context with the request and reply arguments. These utility functions can access the request object from this context, meaning they can validate the request data directly without needing to pass in the request object.

The utility functions are located in src/utilities/validation.ts. These functions will receive a yup schema and return either an empty form, a valid form, or an invalid form. TypeScript's InferType is used to infer the type from the yup schema definition, which ensures that we're using the correct type based on the validation schema. When the isValid property is true, we can access the data based on the inferred type.

These utilities have three primary functions:

FunctionDescription
decodeFormValidates form data sent in the body of a request.
decodeQueryValidates query parameters from the request URL.
decodeParamsValidates parameters from the request URL.

If the validation fails, we try to cast the received data to the defined schema. This way, we can handle forms with invalid input and ensure that the user gets feedback on what went wrong.

Another critical aspect of this approach is that yup strips values that are not defined in the schema during the validation process. This is a security feature to combat prototype poisoning attacks, as it ensures that only the data defined in the schema can pass the validation.

Example

Let's consider an example page that validates the input of a signup form. In this example, we'll use decodeForm utility function to validate the form data.

First, define a validation schema using yup. In this case, we're expecting a username and email from the user.

import * as yup from 'yup';

const SignupSchema = yup.object().shape({
  username: yup.string().required(),
  email: yup.string().email().required(),
});

Next, use the decodeForm function in the page component to validate the user input.

import { decodeForm } from '~/utilities/validation';

const PageComponent = async () => {
  const signupForm = await decodeForm(SignupSchema);

  if (!signupForm.isValid) {
    return (
      <div>
        <p>Error: {signupForm.error?.message}</p>
      </div>
    );
  }

  const { username, email } = signupForm.data;

  // Use the validated data...
};

In this example, if the validation fails, the component will render an error message. If the validation is successful, we can safely use the validated data. This example demonstrates how validation works within a Velocity project and the role it plays in maintaining data integrity and security.

Advanced Validation - Schema Concatenation

In scenarios where you need to define schema rules that incorporate runtime information, you can make use of yup's .concat method. This method allows you to combine two schemas, where the second schema can override the rules defined in the first one.

Here's an example of how you can use the .concat method to add a rule dynamically that validates whether a user's age is over a minimum required age:

import { decodeForm } from '~/utilities/validation';
import * as yup from 'yup';

const SignupSchema = yup.object().shape({
  username: yup.string().required(),
  email: yup.string().email().required(),
  password: yup.string().min(8).required(),
  birthYear: yup.number().required().integer().min(1900),
});

const PageComponent = async () => {
  const currentYear = new Date().getFullYear();
  const minAge = 13; // Minimum required age to sign up

  // Define a dynamic validation rule inside the component
  const signupFormSchema = SignupSchema.concat(
    yup.object().shape({
      birthYear: yup.number().max(currentYear - minAge, `You must be at least ${minAge} years old to sign up.`),
    }),
  );

  const signupForm = await decodeForm(signupFormSchema);

  if (!signupForm.isValid) {
    return (
      <div>
        <p>Error: {signupForm.error?.message}</p>
      </div>
    );
  }

  const { username, email, password, birthYear } = signupForm.data;

  // Use the validated data...
};

In this example, the birthYear field is initially required to be an integer and at least 1900. We then concatenate another schema where we add an additional constraint to birthYear that it cannot be more than currentYear - minAge. This dynamically ensures that the user is at least 13 years old.

This way, yup's concat method provides you a flexible way to define dynamic rules that depend on runtime information, making sure that your user input is both valid and safe to use.

Advanced Validation - Dynamic Schema Enhancement

Sometimes we face scenarios where certain validation rules need to be set based on conditions that can only be evaluated at runtime. In this case, Yup provides us with the flexibility to dynamically enhance our schema validation within the component. The following example demonstrates this.

import { decodeQuery } from '~/utilities/validation';
import * as yup from 'yup';

const SearchSchema = yup.object().shape({
  query: yup.string().required(),
  type: yup.string().oneOf(['products', 'users', 'posts']),
  sort: yup.string().oneOf(['asc', 'desc']),
});

const PageComponent = async () => {
  const searchQuery = await decodeQuery(SearchSchema);

  if (!searchQuery.isValid) {
    return (
      <div>
        <p>Error: {searchQuery.error?.message}</p>
      </div>
    );
  }

  // Assuming this value is fetched at runtime
  const minimumQueryLength = 3;

  // Enhance the existing schema with a test at runtime
  const ExtendedSearchSchema = SearchSchema.test(
    'length-check',
    `Query must be at least ${minimumQueryLength} characters long.`,
    (value, context) => {
      const { query } = context.from;
      return query.length >= minimumQueryLength;
    },
  );

  const validatedSearchQuery = await decodeQuery(ExtendedSearchSchema);

  if (!validatedSearchQuery.isValid) {
    return (
      <div>
        <p>Error: {validatedSearchQuery.error?.message}</p>
      </div>
    );
  }

  const { query, type, sort } = validatedSearchQuery.data;

  // Use the validated data...
};

In this example, we first attempt to validate the query parameters using the original SearchSchema. If the validation passes, we retrieve a value minimumQueryLength at runtime (in this case, we just define it in the scope of the function, but it can come from a database or an external API, for instance).

We then define an enhanced version of our original schema, ExtendedSearchSchema, that includes a custom test. This test checks if the query string is at least minimumQueryLength characters long. We then use this ExtendedSearchSchema to validate the query parameters a second time.

This approach demonstrates how we can make our validation schemas dynamic and adaptable to changing conditions at runtime. It shows the flexibility and power that Yup provides when dealing with complex validation scenarios.

Powered by Doctave