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:
Function | Description |
---|---|
decodeForm | Validates form data sent in the body of a request. |
decodeQuery | Validates query parameters from the request URL. |
decodeParams | Validates 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.
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.
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.
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