What is the correct way to handle validation with json-schema in objection.js?

973 Views Asked by At

I want to understand if using json-schema as my main validation tool would be a good choice. The main thing I'm not sure about is whether I should use it to validate the query params in my API besides it being used to validate DB input.

I'll try to provide a better explanation with code examples below:

Let's start with a json-schema for the User model:

{
  "$schema": "http://json-schema.org/draft-07/schema",
  "type": "object",
  "title": "User",
  "description": "User schema",
  "required": ["email", "full_name", "password"],
  "additionalProperties": false,
  "properties": {
    "id": {
      "$id": "#/properties/id",
      "title": "User ID",
      "type": "integer"
    },
    "email": {
      "$id": "#/properties/email",
      "title": "User email. Must be unique",
      "type": "string",
      "format": "email"
    },
    "password": {
      "$id": "#/properties/password",
      "title": "Hashed password for the user",
      "type": "string",
      "maxLength": 128,
      "pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$"
    },
    "full_name": {
      "$id": "#/properties/full_name",
      "title": "User first name and last name",
      "type": "string",
      "maxLength": 128,
      "pattern": "^[a-zA-Z]+(?:\\s[a-zA-Z]+)+$"
    },
    "image_url": {
      "$id": "#/properties/image_url",
      "title": "URL to user image",
      "type": "string"
    },
    "created_at": {
      "$id": "#/properties/created_at",
      "title": "The creation date of the user",
      "type": "string"
    },
    "updated_at": {
      "$id": "#/properties/updated_at",
      "title": "The date the user was last updated",
      "type": "string"
    }
  }
}

As you can see, I'm using regex to validate the input for each field to ensure the format is correct. I can specify which fields are required which is very useful and I set additionalProperties to false which means that the schema/Objection will not accept properties that are not specified in the json schema.

Next let's take a look at an example of a registration API that I'm trying to use:

router.post("/registration", async (req, res, next) => {
  try {
    const { password, ...payload } = await User.query().insert(req.body);
    const token = await jwt.sign(payload);

    res.json({ user: payload, token });
  } catch (error) {
    next(error);
  }
});

So there's no validation of the request body in the route itself, or really in any other place, I'm trying to delegate the validation entirely to json-schema. When the request comes in, the password is not hashed so it can pass the validation, but then I need a way of storing the hashed password.

Currently I'm using this solution but I'm not sure if it's smart/safe?

// User model
async $beforeInsert(queryContext) {
    this.$setJson(
      { password: await bcrypt.hash(this.password, 12) },
      { skipValidation: true }
    );

    await super.$beforeInsert(queryContext);
  }

This would enable the following:

  • validation to check for the correct params (full_name, email, password) and test whether the values are correct
  • after the validation passes, update the model with the hashed password and skip another validation as it's already been run
  • insert (safe?) data in the db

Now let's look at the login route:

router.post("/login", async (req, res, next) => {
  try {
    User.fromJson(req.body, { skipRequiredFields: ["full_name"] });

    const { email, password } = req.body;

    const user = await User.query().where({ email }).first();

    if (!user) {
      res.status(403);

      throw new Error("Invalid credentials");
    }

    if (!(await bcrypt.compare(password, user.password))) {
      res.status(403);

      throw new Error("Invalid credentials");
    }

    const payload = { id: user.id, email, full_name: user.full_name };

    const token = await jwt.sign(payload);

    res.json({ user: payload, token });
  } catch (error) {
    next(error);
  }
});

Because I want to leave the validation to the json-schema and I need to check the request payload, I have to create a model which is redundant for the route itself, it's only used to trigger the validation. Another reason to do this is because .where({ email }) doesn't trigger the validation and it throws an error when email is undefined.

Code to remove specific required fields from the schema when needed:

// User model

$beforeValidate(jsonSchema, _, { skipRequiredFields }) {
    if (skipRequiredFields) {
      return {
        ...jsonSchema,
        required: jsonSchema.required.filter(
          (fieldName) => !skipRequiredFields.includes(fieldName)
        ),
      };
    }
  }

This allows me to remove required fields from the validation schema. In this case it works perfectly for the /login route as I don't want the request body to have a full_name field.

So far this has been working for me but it feel more like a workaround rather than an actual solution. I am also not necessarily sure about the fact that I have to tap in into Objection hooks and override the password like that. The last thing I don't like is the fact that I have to create a redundant model to trigger the validation but I also understand that triggering a validation (or having the option to) on .when() doesn't make much sense.

I'm new to Objection and this is not a production project but rather a side-project which I'm using to explore Objection along side other frameworks/libraries and I'd like to know if there are better ways of doing this or maybe my thinking is entirely wrong and I should have a separate validation for the request body and leave the json-schema as a db validation only?

0

There are 0 best solutions below