DEV Community

Askarbek Zadauly
Askarbek Zadauly

Posted on

Validate SvelteKit endpoints with Joi

Intro

In this post we are going to build a very simple fullstack app using SvelteKit and add an endpoint parameter validation to it. A lot of projects usually host their backend on a separate project serving from a subdomain. But SvelteKit can be used as a fullstack framework and it's easier to maintain one project rather than two.

Validation

It doesn't matter if you choose separate backend or all-in-one SvelteKit, you should have a backend validation for api endpoints as a safety measure in case if someone wants to send requests directly in order to break something. A straightforward way to do this would be just checking every parameter like this:

if(!username || username.length < 3 || username.length > 30) {
  // return error
}
Enter fullscreen mode Exit fullscreen mode

But instead I am gonna use Joi for validations.

Joi validator used to be part of hapi but then became a standalone library that you can use everywhere where validation is needed. So for example, here's how username validation can be done:

username: Joi.string().alphanum().min(3).max(30).required()
Enter fullscreen mode Exit fullscreen mode

Use case

Let's build an App that has a form that user fills out and sends to our endpoint. If some of the parameters are invalid the endpoint will return an error message. If everything's fine then it will send it further to a server function to process.

Let's start

  • First we create a scaffold project by running:

npm create svelte@latest sveltekit-joi-example

  • Add tailwindcss for easier styling
  • Then create our Page at

/src/routes/+page.svelte

<script lang="ts">
  let formValues = {
    username: '',
    email: '',
    password: '',
    confirmPassword: '',
  };

  const onContinueClick = () => {
    console.log('sending data: ', formValues);
  }
</script>

<label>Username:</label>
<input bind:value="{formValues.username}" type="text" />

<label>Email:</label>
<input bind:value="{formValues.email}" type="text" />

<label>Password:</label>
<input bind:value="{formValues.password}" type="password" />      

<label>Confirm password:</label>
<input bind:value="{formValues.confirmPassword}" type="password" />

<button on:click="{onContinueClick}">
  Continue
</button>
Enter fullscreen mode Exit fullscreen mode

After adding some tailwind magic I got form looking like this:

Form

I will post a link to GitHub repo with full project.

Endpoints

Now it's time for a non-frontend stuff. SvelteKit has different ways to recognize backend code:

  • naming files as +server.ts (or .js)
  • putting files under /src/lib/server folder

Putting +server.ts files under /src/routes folder means you can call them using HTTP requests.
For example, let's say we have a file:
/src/routes/api/user/sign-in/+server.ts
That would mean we can call an HTTP request on this URL:
/api/user/sign-in
to execute code inside +server.ts

Which of HTTP request methods (GET, POST, PUT) can be called depends on exported function name:

export const GET: RequestHandler = async ({ request }) => {
  // GET endpoint
}


export const POST: RequestHandler = async ({ request }) => {
  // POST endpoint
}
Enter fullscreen mode Exit fullscreen mode

All files under /src/lib/server folder are server-only code. It's a good place for all DB interactions or external API calls.

So let's add our Joi validation into the endpoint:
/src/routes/api/entry/+server.ts

import type { RequestHandler } from '@sveltejs/kit';
import { json } from '@sveltejs/kit';
import Joi from 'joi';
import { SaveEntry } from '$lib/server/Entry';

const schema = Joi.object({
  username: Joi.string().alphanum().min(3).max(30).required(),
  password: Joi.string().pattern(new RegExp('^[a-zA-Z0-9]{3,30}$')),
  confirmPassword: Joi.ref('password'),
  email: Joi.string().email({ minDomainSegments: 2, tlds: { allow: ['com', 'net'] } }),
});

export const POST: RequestHandler = async ({ request }) => {
  const params = await request.json();
  const { error } = schema.validate(params);

  if (error) {
    let msg = '';
    if (error.details.length && error.details[0].message) {
      msg = error.details[0].message;
    } else {
      msg = 'unknown validation error';
    }
    return new Response(`Validation: ${msg}`, { status: 400 });
  }

  const { username, email, password, confirmPassword } = params;
  await SaveEntry(username, email, password, confirmPassword);

  return json({ success: true });
};
Enter fullscreen mode Exit fullscreen mode

SaveEntry function is supposed to save to Database, but it's not in our scope right now so it's just an empty function.

Server Hooks

Validating params inside an endpoint is a straightforward approach. A better way would be to handle validations elsewhere so the endpoint code don't have to worry about it. Luckily SvelteKit has server hooks that can help with that. The hooks source file is located here:

/src/hooks.server.ts

The hooks are app-wide functions that SvelteKit calls in response to specific events. We are particularly interested in the handle hook that gets called before endpoint functions on every HTTP request. The handle hook is basically a function but it can also be a chain of functions that will be called in sequence. A typical scenario for a hook sequence can be, for example:

  1. log
  2. authenticate
  3. validate

Works very similar to Express's middleware. Only on SvelteKit it's called sequence. Here's how our hook sequence can look like:

import { sequence } from '@sveltejs/kit/hooks';
import log from './sequences/log';
import auth from './sequences/auth';
import validation from './sequences/validation';

export const handle = sequence(log, auth, validation);
Enter fullscreen mode Exit fullscreen mode

Now each request will be:

  • Logged
  • Checked for authorization token
  • Validated if needed

Logging and Authorizing is out of our scope so those functions are empty for now. Now let's focus on the validation code.
We should be able to validate more than one endpoint. In order to do so we should have a Map (json object) where a key is an endpoint's path and a value is a Joi schema, like this:

import entrySchema from './routes/api/entry/validation';

const map: { [keys: string]: any } = {
  '/api/entry': entrySchema
};
Enter fullscreen mode Exit fullscreen mode

where ./routes/api/entry/validation is:

import Joi from 'joi';

export default {
  POST: Joi.object({
    username: Joi.string().alphanum().min(3).max(30).required(),
    password: Joi.string().pattern(new RegExp('^[a-zA-Z0-9]{3,30}$')),
    confirmPassword: Joi.ref('password'),
    email: Joi.string().email({ minDomainSegments: 2, tlds: { allow: ['com', 'net'] } }),
  }),
};
Enter fullscreen mode Exit fullscreen mode

As you can see I've added POST key which would allow us to have different validations for POST PUT DELETE requests.
Now let's move validation code into our hook. The logic is:

  1. get current path and method
  2. check if map object has a key with current path
  3. check if map object has validation for current method
  4. run the validation
  5. if validation has an error then return 400 error
  6. if validation has no error then handover to an endpoint
import type { Handle } from '@sveltejs/kit';
import entrySchema from '../routes/api/entry/validation';

const map: { [keys: string]: any } = {
  '/api/entry': entrySchema,
};

const validation: Handle = async ({ event, resolve }) => {
  for (const url of Object.keys(map)) {
    if (event.url.pathname.indexOf(url) > -1) {
      const method = event.request.method;
      // we need to clone, otherwise SvelteKit will respond with 'disturbed' error
      const req = event.request.clone();

      const params = await req.json();

      if (map[url][method]) {
        const { error } = map[url][method].validate(params);
        if (error) {
          let msg = '';
          if (error.details.length && error.details[0].message) {
            msg = error.details[0].message;
          } else {
            msg = 'unknown validation error';
          }

          return new Response(`Validation: ${msg}`, { status: 400 });
        }
      }
    }
  }

  return resolve(event);
};

export default validation;
Enter fullscreen mode Exit fullscreen mode

NOTE: As you can see I'm calling event.request.clone() to clone the request stream. This is a controversial approach. But if we don't call it then the endpoint function won't be able to get the params and SvelteKit will throw disturbed error, whatever that means. Another approach would be to move params into event.locals.

Now we can remove validation from our endpoint, so the code is much cleaner now:

import type { RequestHandler } from '@sveltejs/kit';
import { json } from '@sveltejs/kit';
import { SaveEntry } from '$lib/server/Entry';

export const POST: RequestHandler = async ({ request }) => {
  const params = await request.json();

  const { username, email, password, confirmPassword } = params;
  await SaveEntry(username, email, password, confirmPassword);

  return json({ success: true });
};
Enter fullscreen mode Exit fullscreen mode

In an endpoint function we are sure that all params are validated otherwise it wouldn't be called.

Form Actions

SvelteKit recommends to use form actions to handle requests and validations. While it's a valid approach but it doesn't cover all cases. You can checkout the project here:

https://github.com/Ascarbek/sveltekit-joi-example

Feel free to leave any comments.

Cheers!

Top comments (0)