hapi is a powerful and scalable framework that gets the job done for hobby and enterprise-grade applications. It has a modern plugin system that allows you to easily build your application as isolated components which follows the SOLID principles and results in cleaner code.

Development

Plugins are isolated components that contain bits of business logic make up your application. Those could range from routes, middlewares, authentication or versioning. We will look at integrating ajv as an alternative to joi, following the JSON:API specifications for error formatting.

Developing a plugin is fairly simple, it's simply an object with a register property that is a function which will be called by hapi to execute and register the plugin. In our case we want to register a server extension for the onPreHandler that is triggered before a request is processed. This will allow us to grab any incoming data before it can cause damage, validate it and exit early with an error.

import { ResponseToolkit, Server, Request } from "@hapi/hapi";
import AJV, { ErrorObject } from "ajv";

const mapErrors = (type: string, errors: ErrorObject[]): Array<{
	status: number;
	source: {
		pointer: string;
		parameter?: string;
	};
	title: string;
	detail?: string;
}> => errors.map(error => {
    const report = {
        status: 422,
        source: { pointer: error.schemaPath },
        title: error.keyword,
    };

    if (error.message) {
        report.detail = error.message;
    }

    if (error.dataPath) {
        report.source.parameter = error.dataPath;
    }

    if (type === "query" && error.params.additionalProperty) {
        report.title = "Invalid Query Parameter";
        report.detail = `The endpoint does not have a '${error.params.additionalProperty}' query parameter.`;
    }

    return report;
});

export const plugin = {
    pkg: require("../package.json"),
    once: true,
    register(server: Server, options: object = {}) {
        const ajv = new AJV(options);

        server.ext({
            type: "onPreHandler",
            method: (request: Request, h: ResponseToolkit) => {
                const config = request.route.settings.plugins.ajv || {};

                for (const type of ["headers", "params", "query", "payload"]) {
                    const schema = config[type];

                    if (schema) {
                        if (type !== "headers") {
                            schema.additionalProperties = false;
                        }

                        if (!ajv.validate(schema, request[type])) {
                            return h
                                .response({ errors: errors: ajv.errors ? mapErrors(type, ajv.errors) : [] })
                                .code(422)
                                .takeover();
                        }
                    }
                }

                return h.continue;
            }
        });
    }
};

The plugin we just developed contains a few properties so lets look into what they do and how we use them.

  1. The pkg property exports the contents of the package.json file, only the name and version properties will be used.
  2. The once property that tells hapi to only register the plugin once instead of on every request.
  3. The register function which will be called by hapi and register our onPreHandler handler. This handler will grab all payloads that have been configured for validation and run the given schema against it, finally returning the errors as a response with status code 422.

Testing

Now that we have implemented this very simple plugin we'll need to write tests to ensure that it is validating our data and returning errors in the right format. To do this we will use the server.inject method provided by hapi to directly talk to the server instance without having to actually start it which would create a real process on our system.

import Hapi from "@hapi/hapi";

const schema: object = {
    properties: {
        name: { type: "string" }
    },
    required: ["name"]
};

const sendRequest = async (
    url: string,
    method: string = "GET"
): Promise<Hapi.ServerInjectResponse> => {
    const server = new Hapi.Server({ debug: { request: ["*"] } });

    await server.register({ plugin });

    server.route({
        method: "GET",
        path: "/",
        handler: () => []
    });

    server.route({
        method: "GET",
        path: "/query",
        handler: () => [],
        options: {
            plugins: {
                ajv: {
                    query: schema
                }
            }
        }
    });

    server.route({
        method: "POST",
        path: "/payload",
        handler: () => [],
        options: {
            plugins: {
                ajv: {
                    payload: schema
                }
            }
        }
    });

    server.route({
        method: "GET",
        path: "/params",
        handler: () => [],
        options: {
            plugins: {
                ajv: {
                    params: schema
                }
            }
        }
    });

    server.route({
        method: "GET",
        path: "/headers",
        handler: () => [],
        options: {
            plugins: {
                ajv: {
                    headers: schema
                }
            }
        }
    });

    return server.inject({ method, url, payload: {} });
};

const expect422 = ({
    payload,
    statusCode
}: {
    payload: object;
    statusCode: number;
}) => {
    expect(statusCode).toBe(422);

    expect(JSON.parse(payload)).toEqual({
        errors: [
            {
                status: 422,
                source: {
                    pointer: "#/required"
                },
                title: "required",
                detail: "should have required property 'name'"
            }
        ]
    });
};

describe("Validator", () => {
    it("should return 200 if no validation is required", async () => {
        expect((await sendRequest("/")).statusCode).toBe(200);
    });

    it("should return 422 if query validation fails", async () => {
        expect422(await sendRequest("/query"));
    });

    it("should return 422 if payload validation fails", async () => {
        expect422(await sendRequest("/payload", "POST"));
    });

    it("should return 422 if params validation fails", async () => {
        expect422(await sendRequest("/params"));
    });

    it("should return 422 if headers validation fails", async () => {
        expect422(await sendRequest("/headers"));
    });
});
  1. We created a sendRequest method which we will use to create a server with multiple endpoints and send a request to the endpoint we specified.
  2. We created an expect422 method which we will use to assert that a response encountered a validation error which is indicated by status code 422 and that the response is formatted according to the JSON:API specification.

Conclusion

As you can see it is a breeze to build and test a hapi plugin in a short amount of time. We now have the confidence that our request validation is working and properly formatted so we can get onto building out our business logic. Make sure to check out the hapi tutorials and Future Studio to learn more.