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?