Introduction
Validating request payloads plays a vital role in the security and robustness of any backend application.
Applications achieve this by writing custom validation logic wired to work with the request’s route handler function, or one that is thereby invoked.
Suppose we have a backend service for managing routine bookkeeping activities, and suppose we want to design APIs that support the following bookkeeping operations:
- 1. Add a new transaction.
- 2. Retrieve a transaction using the transaction ID.
- 3. Retrieve invoices based on query string filtering.
Let’s start by implementing the first API, the one to add a new transaction.
Using Node.js+Express for our server, we express (pun intended) this as follows:
import express from 'express'
const app = express()
app.post('/transactions', (req, res) => {
try {
// Some business logic
res.sendStatus(204)
} catch (error) {
res.sendStatus(500)
throw new Error(`Something went wrong: ${error.message}`)
}
})
app.listen(process.env.PORT || 5000, () => {
console.log(`Bookkeeping service listening on port: ${process.env.PORT
|| 5000}`)
})
Consider the following example JSON request body containing mandatory properties that we expect the client to send:
{
"accountId": "GTX001AAA",
"transactionAmount": 5000,
"transactionDate": "2024-03-11T06:57:28.932Z",
"transactionType": "CREDIT"
}
Based on the expected request payload, let us add validation logic to our route handler. Further, suppose that we will validate the expected request payload against the following business rules:
- 1. The accountId has to be a combination of characters and numbers, having a maximum length of 15 characters.
- 2. The transactionAmount has to be in the inclusive range of 1000 and 10,000.
- 3. The transactionType should only have string values.
We achieve this by using a validation middleware that sits in front of our main route handler. Let’s introduce a new module (validator.js) to handle this validation, which exposes a validateRequest function:
export default function validateRequest(req, res, next) {
ACC_ID_MAX_LENGTH = 15
MAX_TRANSACTION_AMOUNT = 1000
MIN_TRANSACTION_AMOUNT = 10000
if (!req.body || !req.body.accountId || !req.body.transactionAmount ||
!req.body.transactionType) {
return res.status(400).json({ error: "Missing required fields"
})
}
if (!accountId.match(/^[a-zA-Z0-9]+$/) && accountId.length >
ACC_ID_MAX_LENGTH) {
return res.status(400).json({ error: "accountId must contain
only alphanumeric characters and have a maximum length of 15 characters"
})
}
if (!accountId.match(/^[a-zA-Z0-9]+$/)) {
return res.status(400).json({ error: "accountId must contain
only alphanumeric characters" })
}
if (!(1000 <= transactionAmount && transactionAmount <= 10000)) {
return res.status(400).json({ error: "transactionAmount must be
between 1000 and 10000 (inclusive)" })
}
if (typeof(transactionType) !== 'string') {
return res.status(400).json({ error: "transactionType must a
string" })
}
next()
}
Let’s modify the main file to use the validator defined above:
import express from 'express'
import validateRequest from 'validator'
const app = express()
app.post('/transactions', validateRequest, (req, res) => {
try {
// Some business logic
res.sendStatus(204)
} catch (error) {
res.sendStatus(500)
throw new Error(`Something went wrong: ${error.message}`)
}
})
app.listen(process.env.PORT || 5000, () => {
console.log(`Bookkeeping service listening on port: ${process.env.PORT
|| 5000}`)
})
Things are shaping up, and our route handler now performs logic if and only if the request is first validated. However, it’s easy to see that even with this contrived basic example that our validation logic quickly becomes verbose.
To see why, consider how complexity grows if for example, we account for more properties in the request body? And what if we were to add validation checks for query string parameters? And we haven’t yet introduced the other operations!
It’s possible we end up with a large number of functions and classes to perform validation. The bottom-line then, is that the above implementation does not scale well. So, what’s the solution? We should leverage a library that makes it straightforward to perform routine validation checks.
The express-json-validator-middleware library
This is where the express-json-validator-middleware comes in. It facilitates validating different request properties, such as the request body, the request query, and the request path params. It uses AJV (as of the date of writing this), which in turn uses JSON schemas that define the validation rules, under the hood.
The library exposes a Validator, as well as a ValidationError object. The Validator is what is interesting to us, and is what will be the focus for the remainder of this article. With the Validator instance at our disposal, we modify our transactions API to conform to a set of validation rules defined by a JSON Schema, like so:
const transactionsSchema = {
type: "object",
required: [
"accountId",
"transactionAmount",
"transactionDate",
"transactionType"
],
properties: {
accountId: {
type: "string",
maxLength: 15,
pattern: '/^[a-zA-Z0-9]+$/'
},
transactionAmount: {
type: "number",
min: 1000,
max: 10000
},
transactionType: {
type: "string"
}
}
}
Now, we condense all our validation logic down to a single line to invoke the middleware! Let’s take a look at how our code looks after delegating validation to our new library:
// Validate the request.body using the “transacationsSchema” object.
app.post('/transactions', validate( { body: transactionsSchema } ), (req,
res) => {
try {
// Some business logic
res.sendStatus(204)
} catch (error) {
res.sendStatus(500)
throw new Error(`Something went wrong: ${error.message}`)
}
})
And we’re done! We have done away with our validateRequest function, and consequently, the validator module.
One obvious advantage of doing things in this manner is that we now have a single object, the transactionsSchema, which defines validation rules. Any changes to the API’s contract are directly tied to this object.
Validating different request properties
Validating path parameters
Now let’s implement our second API that implements the bookkeeping operation of retrieving a transaction using its unique transaction identifier (UUID). Suppose we design the API to use a path parameter to serialise the transaction UUID.
We would define a validation schema for the path parameter:
const transactionsPathParamSchema = {
type: "object",
required: ["uuid"],
properties: {
uuid: {
type: "string",
minLength: 36,
maxLength: 36
}
}
}
Our API then simply needs to use the schema defined above:
app.get('/transactions/:uuid', validate( { params:
transactionsPathParamSchema } ), (req, res) => {
try {
// Business logic here
res.sendStatus(200).json()
} catch (error) {
res.sendStatus(500)
throw new Error(`Something went wrong: ${error.message}`)
}
})
Notice that we passed the key params to our validate middleware to indicate that we want to validate path parameters. This is good to go! Let’s move on to the next API.
Validating query string parameters
For our third API implementation, we want to retrieve invoices based on the following filters:
- 1. minAmount - retrieve invoices that are greater than or equal to a given value. We want to ensure that the user doesn’t enter an amount less than or equal to zero.
- 2. maxAmount - retrieve invoices that are lesser than or equal to a given value. Similar to (1) we want to ensure that the user cannot input a number greater than 1 million.
Here is our new endpoint, accompanying the requisite JSON validation schema:
const invoicesSchema = {
type: "object",
properties: {
minAmount: {
type: "number",
minimum: 1
},
maxAmount: {
type: "number",
maximum: 1000000
}
}
}
app.get('/invoices/', validate( { query: invoicesSchema } ), (req, res) => {
try {
// Business logic here
res.sendStatus(200).json()
} catch (error) {
res.sendStatus(500)
throw new Error(`Something went wrong: ${error.message}`)
}
})
Note that we have not marked either minAmount or maxAmount as required properties. In this case, we are saying that the input is valid if the user chooses to altogether avoid passing in any query string parameters.
This kind of flexibility simplifies validation. If we wanted to mandate either (or both) of the properties, all we would need to do is add it to the list of required properties.
Validating multiple request properties
Up until this point, we have explored examples wherein we handled individual properties of the request. In reality, we would want to validate multiple request properties for completeness. We have the ability to in fact validate multiple, or potentially all, properties of the request if desired.
To see how, let’s introduce a new operation that our bookkeeping service needs to support: A way to overwrite one of the objects in our transactions resource, using its unique identifier. This requires us to validate both the UUID that we expect as a path parameter, and the request body.
All we have to do is re-purpose the transactionsSchema and the transactionsPathParamSchema, and simply pass them along as distinct property keys to our validate middleware function:
app.put('/transactions/:uuid', validate( { body: transactionsSchema, params:
transactionsPathParamSchema } ), (req, res) => {
try {
res.sendStatus(200).json()
} catch (error) {
res.sendStatus(500)
throw new Error(`Something went wrong: ${error.message}`)
}
})
And we’re done!
Conclusion
To recap, we saw how conventional validation becomes messy and repetitive to write, which leads to code that is difficult to maintain at best, and is buggy at worst.
In order to remediate this, we rid ourselves of all kinds of boilerplate validation code, and replaced it with declaratively written, expressive code, using the express-json-validator-middleware library.