DEV Community

Cover image for Enhancing Redwood: A Guide to Implementing Zod for Data Validation and Schema Sharing Between the API and Web Layers
growms
growms

Posted on • Updated on

Enhancing Redwood: A Guide to Implementing Zod for Data Validation and Schema Sharing Between the API and Web Layers

First things first: This walkthrough has been conducted on macOS. It uses symlinks, so I can't provide information about the setup on Windows.

The Story

I'm currently experimenting with the fantastic Redwood framework. However, while going through the excellent tutorial, I didn't find any guidance on using data validation libraries like Yup, Zod, Vest, etc. So, I had to do some investigation and came up with a solution. This article describes the implementation of validation with Zod in a fresh Redwood app. You can find the sources at this github repository.

Note: I'm not a React, Yarn or even WebPack expert. I used to be an Angular developer, and I work with Nx for workspace management.

Setup

Let's begin with a brand new Redwood app:

# Create the app
yarn create redwood-app my-redwood-zod
cd my-redwood-zod
# Migrate the default schema "UserExample"
yarn rw prisma migrate dev
# Generate the CRUD for "UserExample"
yarn rw g scaffold UserExample
# Launch the development server
yarn redwood dev
Enter fullscreen mode Exit fullscreen mode

Sharing Code

Start by creating the basic structure and sharing a variable called "userExampleSchema," which contains a string (although its content may change in the near future):

# Create the folder to be shared
mkdir -p ./api/src/lib/common
# Create a zod.ts file with a basic variable
echo "export const userExampleSchema: string = \"I'm a shared value written in TypeScript!\"" > ./api/src/lib/common/zod.ts
# Add the symlink to be able to use the common libs on the "web" side
ln -s ../../../api/src/lib/common ./web/src/lib/common
Enter fullscreen mode Exit fullscreen mode

Why symlink ? I have experimented with babel.config.js, tsconfig, webpack.config.js and just could not make it work properly, there was always drawbacks deploying or testing whatsoever. Once again i'm not a pro with yarn and webpack so if you have a better way feel free to share in comment !

We should ignore the symlink in .gitignore to avoid any code duplication:

// .gitignore
...
web/src/lib/common
Enter fullscreen mode Exit fullscreen mode

That's it! Now let's see if it works. Edit UserExampleForm.tsx:

// web/src/components/UserExample/UserExampleForm/UserExampleForm.tsx

import { userExampleSchema } from 'src/lib/common/zod';
...
const UserExampleForm = (props: UserExampleFormProps) => {
  ...
  return (
    <div className="rw-form-wrapper">
      <h1>Shared Variable: {userExampleSchema}</h1>
      ...
    </div>
  );
};
export default UserExampleForm;
Enter fullscreen mode Exit fullscreen mode

Check at http://localhost:8910/user-examples/new!

Image description

One side done ! what about the server ? let’s edit api/src/ervices/userExamples.ts :

import { userExampleSchema } from 'src/lib/common/zod'
...

export const userExamples: QueryResolvers['userExamples'] = () => {
  console.log('aSharedVar', userExampleSchema) // +
  return db.userExample.findMany()
}
...
Enter fullscreen mode Exit fullscreen mode

Then go at http://localhost:8910/user-examples and look at logs you should see our string ! :

Image description

It seems to work ! but as we are “clean coders” we need to check that test is working too so let’s make a small one. Add a zod.test.ts file in the common directory to test our variable :

import { userExampleSchema } from './zod'

describe.only('zod', () => {
  it('has userExampleSchema const', () => {
        expect(userExampleSchema).not.toBeUndefined()
  })
})
Enter fullscreen mode Exit fullscreen mode

You can run test on Redwoodwith the command yarn rw test then type t and zod

Sharing is done, let’s adding Zod validation.

Zod validation

Server Side

Now let's work on the server side. First, install zod in the api workspace:

cd api && yarn add zod && cd ..
Enter fullscreen mode Exit fullscreen mode

Remove the reference to userExampleSchema in UserExampleForm.tsx to avoid unnecessary errors. Replace the old string with an actual Zod schema to validate our email and name:

// api/src/lib/common/zod.ts
import { z } from 'zod';

export const userExampleSchema = z.object({
  email: z.string().min(1, { message: 'Email is required' }).email({
    message: 'Must be a valid email',
  }),
  name: z.string(),
});
Enter fullscreen mode Exit fullscreen mode

To validate the data in a "Redwood way," we need to return a RedwoodError. If we want to have a nice field mapping, it should comply with the ServiceValidationError. Let's add our custom error and a validateWithZod() utility to use it in our services:

// api/src/lib/zodValidation.ts
import { ZodError } from 'zod';
import { RedwoodError } from '@redwoodjs/api';

export class ZodValidationError extends RedwoodError {
  constructor(error: ZodError) {
    const { issues } = error;
    const errorMessage = 'Validation failed';

    const messages = {};

    const extensions = {
      code: 'BAD_USER_INPUT',
      properties: {
        messages,
      },
    };

    // Process each error and add it to messages object
    for (const { message, path } of issues) {
      path.forEach((pathItem) => {
        messages[pathItem] = messages[pathItem] || [];
        messages[pathItem].push(message);
      });
    }

    super(errorMessage, extensions);
    this.name = 'ZodValidationError';

    Object.setPrototypeOf(this, ZodValidationError.prototype);
  }
}

export const validateWithZod = (input: any, schema: any) => {
  const result = schema.safeParse(input);
  if (!result.success) {
    throw new ZodValidationError(result.error);
  }
};
Enter fullscreen mode Exit fullscreen mode

All the hard work is done. Enjoy:

// api/src/services/userExamples.ts
import { userExampleSchema } from 'src/lib/common/zod';
import { validateWithZod } from 'src/lib/zodValidation';
...
export const createUserExample: MutationResolvers['createUserExample'] = ({
  input,
}) => {
  validateWithZod(input, userExampleSchema);

  return db.userExample.create({
    data: input,
  });
};
Enter fullscreen mode Exit fullscreen mode

Yep, it's just one method call!

Image description

Note: Don't forget to update the related tests in userExamples.test.ts to ensure they pass.

// api/src/services/userExamples.test.ts
...
scenario(
    'creates a userExample with valid email and non-empty name',
    async () => {
      // Test that email must be valid
      await expect(async () => {
        return await createUserExample({
          input: { email: 'String1484848', name: 'John' },
        });
      }).rejects.toThrow(ZodValidationError);

      // Test that email must not be empty
      await expect(async () => {
        return await createUserExample({
          input: { email: '', name: 'John' },
        });
      }).rejects.toThrow(ZodValidationError);

      // Test that name must not be empty
      await expect(async () => {
        return await createUserExample({
          input: { email: 'johndoe@example.com', name: '' },
        });
      }).rejects.toThrow(ZodValidationError);

      // Test with valid email and non-empty name
      const validResult = await createUserExample({
        input: { email: 'johndoe@example.com', name: 'John' },
      });

      // Assert that the result has the expected email and name
      expect(validResult.email).toEqual('johndoe@example.com');
      expect(validResult.name).toEqual('John');
    }
  )
...
Enter fullscreen mode Exit fullscreen mode

Great! Our server is now robust. But we also need to take care of our users with l33t front-end validation.

Client Side

To begin, we need to install dependencies in the web workspace:

cd web && yarn add zod @hookform/resolvers && cd ..
Enter fullscreen mode Exit fullscreen mode

Now we want to use Zod in the generated UserExampleForm.

Note: Oh no! Redwood validation is so easy why should i change it !? Don't panic! Redwood validation is built on React Hook Form, and we just installed a Zod resolver for it.

Adding Zod validation

// UserExampleForm.tsx
import { zodResolver } from '@hookform/resolvers/zod';
...
import {
  ...
  useForm,
} from '@redwoodjs/forms';

const UserExampleForm = (props: UserExampleFormProps) => {
  const formMethods = useForm<FormUserExample>({
    resolver: zodResolver(userExampleSchema),
  });

  ...
  return (
    <div className="rw-form-wrapper">
      <Form<FormUserExample>
        onSubmit={onSubmit}
        error={props.error}
        formMethods={formMethods}
      >
        ...
      </Form>
    </div>
  );
};

export default UserExampleForm;
Enter fullscreen mode Exit fullscreen mode

If you've gone through the Redwood tutorial you should recognize the useForm() method . It was used to reset the Article Form. You can see the Zod resolver is a very small addition to it, and that's all the hard work to make it work.
Please note that the useForm function is essentially a direct call to the method with the same name in React Hook Form. This means it's not a "Redwood thing" and if you want to know more about it, you should refer to RHF documentation!"

Now if you test the form, it should send the same errors than before, but they are coming from the client !
Hmm wait... how can i be sure it’s not the server which throws the error ?
Well you can comment the line of validateWithZod in the service if you dare ! But you could also just check network in chrome dev tools or the server's logs ;)

ET VOILA
Now you have a Zod setup for both the client and server!

Hope you learned something ! cheers

Top comments (0)