Forklift is a TypeScript library written to simplify request-response flow in Express.js applications. It is meant to be used as a pre & post core logic middleware.
First, let's import the IO
module.
import { IO } from "@reactor4/forklift";
IO
is a class with both static functions and instance methods which are used in conjuction to ease the process of data validation and manipulation throughout express.js
request and response flow.
To be able to validate the data against something, IO
class needs to be initialized with a JSON Schema. More info about JSON Schema specification can be found here.
const exampleSchema = {
$schema: "http://json-schema.org/draft-07/schema#",
title: "Example Schema",
description: "Schema for creating examples",
type: "object",
properties: {
exampleId: {
type: "number",
},
exampleName: {
type: "string",
},
},
additionalProperties: false,
required: ["exampleId", "exampleName"],
};
const exampleIo = new IO({ reqBodySchema: exampleSchema });
Now, ioInstance
can be included as a middleware just like any other.
// This is a express.js router
router.post(
"/example",
exampleIo.processRequest(),
// any other business logic, e.g.
exampleController.post
);
Note that processRequest
method actually returns a function that accepts regular express.js
middleware arguments (req
, res
, next
). That function executes as a middleware and validates the req
argument. Validation starts with headers as the Accept and Content-Type headers are required.
Request is expected to provide the following headers:
Accept: application/json
Content-Type: application/json, */*, *
Next, the request content is validated (req.body
) against the provided schema at the IO
class constructor's first parameter. For example, the exampleSchema
above expects request's body to provide an object with exactly two properties; exampleId and exampleName, of types number
and string
, respectively.
If the headers or body do not comply with the specification, an error is thrown inside the module which is expected to be handled explicitly.
Nevertheless, if the request passes the validation, the request's body is stored under locals.io.data namespace inside the req
middleware object. Forklift actually provides additional static functions that make the locals namespace easy to manipulate with.
For example,
static set(target: object, data: any, status: Status = Status.OK, path: string = null)
is the most general one which sets the object at locals.io.data.${path} to the provided data
argument, as well as setting locals.io.status to the status
value (enum which can be imported from Forklift).
Such approach is expected when setting the response data so Forklift can know what data to validate and serialize.
// exampleController.js
import { IO, Status } from "@reactor4/forklift";
post(req, res, next) {
...
// business logic
...
IO.set(response, data, Status.CREATED);
// or simply
IO.setCreated(response, data);
}
To conclude the pipeline and send the response another middleware method is required.
router.post(
"/example",
exampleIo.processRequest(),
// any other business logic, e.g.
exampleController.post,
exampleIo.sendResponse()
);
It is also possible to validate query parameters if reqQuerySchema
is provided in IO
s config constructor. The options
parameter object for now includes possibility to include other valid Content-Type headers. The final middleware function sendResponse
will validate the response data (located at res
object's locals.io.data namespace) if response JSON schema was provided as resBodySchema
parameter in IO
class constructor.
const exampleIo = new IO(
{
reqBodySchema,
reqQuerySchema,
options: { contentTypes: ["text/html"] },
resBodySchema
}
);
Two basic middleware functions that come with Forklift provide out of the box pipeline error handling with clean code base in mind.
import { errorMiddleware } from "@reactor4/forklift";
Let's start with the more straight forward one, errorMiddleware
. This is simply a Forklift implementation of an error handler that is expected to be included at the end of the Express.js pipeline.
// e.g.
app.use(someRouter);
app.use(someOtherRouter);
app.use(errorMiddleware());
Expected behavior of this middleware is to handle special type of errors which extend ForkliftError
class. It is designed to provide a possibilty to add custom errors without much hassle. Every error that is a subclass of ForkliftError
has toJson
method available which will provide a pretty formatted object of error details.
import ForkliftError from '@reactor4/forklift';
class ForbiddenError extends ForkliftError {
constructor(message: string) {
// ForkliftError's constructor expects message, status of the response to be written, and an error name
super(message, 403, 'Forbidden');
}
}
In the example above, Forklift's implementation of ForbiddenError
can be seen. Same pattern can be reused for any other custom error type. Forklift comes with some common HTTP error types ready.
import { BadRequestError, ForbiddenError, NotFoundError, ConflictError } from "@reactor4/forklift";
To be able to use async/await promise syntax and still use an error handler like described Forklift's middleware, without wrapping each of your await's inside a try/catch block Forklift provides a higher order function which can be used to wrap pipeline handlers.
import { asyncMiddleware, BadRequestError, ForbiddenError } from "@reactor4/forklift";
getItems() {
return asyncMiddleware(async (req: Request, res: Response) => {
if (!req.params.size) {
throw new BadRequestError(`"Size" query parameter not provided!`);
}
const isAuthorized = await checkUser(req.headers);
if (!isAuthorized) {
throw new ForbiddenError("User is unauthorized for this action.");
}
const items = await getXAmountOfItems(req.params.size);
IO.set(res, items);
});
}
asyncMiddleware
basically wraps the function argument inside a try/catch block and calls the next function after it's completed.