Hapi.js for Node developers

https://bit.ly/hapijs-wroc

07.10.2017, Wrocław

First server in Hapi

`Hello, World` with Hapi.js

const hapi = require('hapi');

const server = new hapi.Server();

server.connection({ port: 3000, host: 'localhost' });

server.route({
    method: 'GET',
    path: '/',
    handler: function (request, reply) {
        return reply('Hapi to see you!\n');
    }
});

server.start(() => {
    console.log(`Server running at ${server.info.uri}`);
});

Import the hapi framework

Create the main applicaiton object - the server

By default the server is not listening on any port. This is where connections come in.

There can be more than one connection to the server (eg. listen on multiple ports)

Register a route with the server. Routes need to be registered after the connections are setup.

Route configuration object contains the method field that can be set to any HTTP method or * that matches all of them.

path field may contain exact paths (/home), required params (/products/{id}) and optional params (/files/{name?})

The handler recieves the request object that contains all the information relevant to current request (params, headers, etc), and the reply interface which allows for sending responses.

After the config is done, start the server.

Serving static files

// npm install inert --save

const path = require('path');
const hapi = require('hapi');
const inert = require('inert');

const server = new hapi.Server();
server.connection({ port: 3000, host: 'localhost' });

server.register(inert, (err) => {

    server.route({
        method: 'GET',
        path: '/{param*}',
        handler: {
            directory: {
                path: path.join(__dirname, 'public'),
                listing: true
            }
        }
    });

    server.start((err) => {
        console.log(`Server running at: ${server.info.uri}`);
    });
});

Install the inert plugin

Plugins must be registered with the application server.

Because the registration is asynchronous rest of the config needs to be done in callback.

Thanks to inert, hapi will recognize a directory handler on this route

Handling templates

// npm install vision ejs --save

/** INDEX.JS **/

const hapi = require('hapi');
const vision = require('vision');
const ejs = require('ejs');

const server = new hapi.Server();
server.connection({ port: 3000, host: 'localhost' });

server.register(vision, () => {

    server.views({
        engines: { ejs },
        relativeTo: __dirname,
        path: 'views'
    });

    server.route({
        method: 'GET',
        path: '/',
        handler: function (request, reply) {
            return reply.view('index', { message: 'Hapi to see you!' });
        }
    });

    server.start(() => {
        console.log(`Server running at: ${server.info.uri}`);
    });
});


/** VIEWS/INDEX.EJS **/
<h1><%= message %></h1>

Install the vision plugin and ejs templates package

Register vision with the server

vision extends the server object with views method used to provide configuration for views.

Provide the templating engine. vision supports multiple out-of-the-box and allows custom handlers.

On top of that, the reply interface was exetened with the view method. It takes the template name and an object with data for the template as arguments.

The views/index.ejs contains the tempalte. All fields from the object provided to the template are available.

Tasks

Create a Hapi.js server that serves static files

Use our shop frontend or roll out your own

Add GET /api/products endpoint that returns a list of products

Keep the products in a separate JSON file

Generate the index.html file using vision (and templating engine of your choice)

Create an in-memory Products database. It should be able to list, add, and remove products

REST API & TestsDefining REST endpoints and writing tests.

Logging the routing table with Blipp

// npm install --save blipp
const blipp = require('blipp');

server.route([
    {
        method: 'GET',
        path: '/',
        config: {
            description: 'The homepage',
            /* ... */
        }
    },
    { /* ... */ }
]);

server.register(blipp, () => {
   server.start(/*...*/)
});

Install and import the blipp plugin

Configure application routes as normal

Route's config contains the description property: it will be usefull with logging and documentation generation.

Register the plugin and run the server

Logging with `good`

// npm install --save good good-console good-squeeze

const good = require('good');

server.route([
    {
        path: '/',
        method: 'GET',
        handler(request, reply) {
            return reply({ message: 'Hapi to see you!' });
        }
    },
    {
        path: '/error',
        method: 'GET',
        handler() {
            throw new Error();
        }
    }
]);

const goodOptions = {
    reporters: {
        consoleLogger: [
            {
                module: 'good-squeeze',
                name: 'Squeeze',
                args: [{
                    log: '*',
                    response: '*',
                    ops: '*',
                    error: '*'
                }]
            },
            {
                module: 'good-console'
            },
            'stdout'
        ]
    }
};

server.register({ register: good, options: goodOptions }, () => {
    server.start(() => { /* .. */ });
});

Install the good plugin and good-console reporter.

Register routes

Configuration object for the good plugin.

Configure a new logger object. The configuration is an array of streams to pipie the logs through.

good-squeeze is a utility that helps to filter stream events.

This reporter will log out all log, response, ops and error events fired by the server.

Next, format the logs with good-console.

Finally, pass the logs to the stdout stream (the actual console).

Register the plugin passing in the configuration object.

Basic tests in Hapi ecosystem

// npm install lab --save-dev


/* TEST/INDEX.JS */

const { expect, test, experiment } =
    exports.lab =
    require('lab').script();

experiment('Hapi to', () => {
    test('be sane', (done) => {
        expect(true).to.be.true();
        done();
    });
});

Install the lab testing utility. It's based on mocha and shares most of the API.

Import the package and register a testing script with script method.

The script has to be exported as lab.

Get the tools for testing. Mapping to mocha/jasmine: experiment is the same as describe and test corresponds to it.

Change in relation to mocha: assertions are functions (true() instead of true). Easy to miss!

All lab tests are asynchronous by default. Either call done() or return a promise from the test.

Testing requests

/* LIB/SERVER.JS */
server.route({
    method: 'GET',
    path: '/',
    handler: function (request, reply) {
        return reply('Hapi to see you!\n');
    }
});

module.exports = server;

/* TESTS/LIB/SERVER.JS */

const server = require('../lib/server');
const { expect, test, experiment } = exports.lab = require('lab').script();

experiment('Route: /', () => {
    test('responds with 200', (done) => {
        server.inject({ method: 'GET', path: '/' }, (res) => {
            expect(res.statusCode).to.equal(200);
            done();
        });
    });
});

Basic server definition wtih a route.

The server is exported to use it elswhere.

The server object is decorated with inject method thanks to the shot utility. The method enables faking server requests.

.inject takes an object with options and a callback that will be called with a response object.

Tasks

Log all requests with good and print routing table with blipp

Add POST /api/products route. Validate the payload and use boom for errors

Update the frontend

Test GET / and POST /api/products endpoints

Add DELETE /api/products/{id} route

Add POST /api/cart route

Get 100% test coverage (lab -c)

ValidationUsing the joi package to validate requests and responses

Validation schemas

// npm install joi --save

const passwordSchema = joi.string()
    .min(8)
    .max(30);

joi.validate('password123', passwordSchema);
// { error: null, value: 'password123' }

joi.validate('hello', passwordSchema);
// { error: {...}, value: 'hello' }

Install and import the joi library

Create a validation schema

The password must be a string...

...between 8 and 30 characters long.

The validate method takes the value to validate and the schema to validate against. It returns and object/promise-like that contains error and value properties.

If there were any errors, their details will be shown in the error property.

Validating requests

const idSchema = joi.string().token();
const productSchema = joi.object().keys({
    id: idSchema,
    price: joi.number().min(1)
});

server.route({
    method: 'PUT',
    path: '/products/{id}',
    config: {
        validate: {
            headers: true,
            params: {
                id: idSchema.required()
            },
            query: false,
            payload: productSchema
        },
        handler: function (request, reply) {
            // ...
        },
        response: {
            schema: productSchema,
            failAction: 'error',
            sample: 100
        }
    }
});

Define required schemas

Provide validation config

true means that any value is allowed. In this case: all headers are OK.

Specifies validation per route param

false - no values allowed. This request must not have any query params.

Payload of this request must match the productSchema.

API responses can be validated too.

Validate the response against the productSchema

If the validation fails return the 500 Internal Server Error response.

Percent of requests to validate. 100% is perfect for development.

Tasks

Add joi validation for the POST /api/prodcuts/ route

Add DELETE /api/products/{id} route and validate the param

Reuse the product validation schema to validate the response to GET /api/products

Add joi validation to all routes

Get 100% test coverage (lab -c)

Use either loki or lowdb and replace the in-memory database

Server compositionStructuring the application

Server configuration via manifest

/* /MANIFEST.JSS */
module.exports = {
    "connections": [
        {
            "port": 3000,
            "host": "localhost"
        }
    ],
    "registrations": [
        {
            "plugin": "inert"
        },
        {
            "plugin": "vision"
        }
    ]
};

/* /LIB/SERVER.JS */
const glue = require('glue');
const manifest = require('../manifest');

module.exports = glue.compose(manifest);

/* /INDEX.JS */
const Server = require('./lib/server');

Server.then((server) => {
    server.route(/* ... */);

    server.start().then(() => {
        console.log(`Server running at ${server.info.uri}`);
    });
});

manifest.js or (manifest.json) file in the main directory

Manifest contains the server connection configuration options that would normally be passed to server.connection([options]).

It also contains all the plugins along with their options.

Instead of creating the server manually, use the glue library to generate the server based on the manifest file.

Import the exported Server definition.

glue.compose (as well as most hapi methods) returns a promise.

Once the server is ready, register routes as per usuall and start the server.

Structuring the app with plugins

/* /LIB/PRODUCTS.jS */
exports.register = (server, options, next) => {
    server.route({
        method: 'GET',
        path: '/',
        description: 'Index route',
        /* ... */
    });

    server.route({
        method: 'GET',
        path: '/{id}',
        description: 'Fetch route',
        /* ... */
    });

    /* ... */
    next();
};

exports.register.attributes = {
    name: 'Product routes'
};

/* /MANIFEST.JS */
module.exports = {
    "connections": [/* ... */],
    "registrations": [
        /* ... */,
        {
            "plugin": "./lib/products",
            "options": {
                "routes": {
                    "prefix": "/products"
                }
            }
        }
    ]
};

/* /LIB/SERVER.JS */
const glue = require('glue');
const manifest = require('../manifest');

const options = { relativeTo: __dirname };

module.exports = glue.compose(manifest, options);

Common way to split a Hapi app into smaller pieces is providing those pieces as plugins.

A plugin is essentialy just an object with the register method that extends the server object when called. The consumer of the plugin can also provide options that contain additional configuration.

When the register method is called define routing for the products resource.

After the setup is done, call next() to return control back to the framework. In this case the registraton is synchronous, but it's possible to register plugins asynchronously.

The register method should have an attributes property that contains plugin metadata.

Add the plugin to the server configuration manifest. The plugin property contains the path to the plugin module.

While registering the plugin that contains routes it's useful to provide the prefix.

Because the plugin is registered in a project directory it's required to add the relativeTo option.

Tasks

Split the application into plugins

Move the /api/products prefix to plugin configuration

Write separate tests for a single plugin

Create separate package.json files for each plugin

Documentation, Security and CachingGenerate beautiful docs with no effort.

Automatic docs with lout/hapi-swagger

// npm install --save lout
const lout = require('lout');
server.register({ register: lout }, () => {
    server.start(() => { /* .. */ });
});

// OR npm install --save hapi-swagger
const hapiSwagger = require('lout');
server.register({ register: hapiSwagger }, () => {
    server.start(() => { /* .. */ });
});


// ROUTE CONFIG
server.route({
    method: 'GET',
    path: '/products/{id}/',
    config: {
        handler() { /* */},
        description: 'Get a product',
        notes: 'Returns a product by the id passed in the path',
        tags: ['api'],
        validate: {
            params: {
                id : Joi.string()
                    .token()
                    .required()
                    .description('The id for the product. Can only contain a-z, A-Z, 0-9, and underscore')
            }
        }
    }
})

Standard installation and registration.

Documentation plugins heavily leverage route and validation properties like description, notes and tags.

Open /docs (with lout) or /documentation (hapi-swagger)

Authentication with Hapi

/* /LIB/AUTHENTICATION.JS */
exports.register = (server, options, next) => {

    server.auth.strategy(
        'simple-authentication',
        'basic',
        false,
        {
            validateFunc(request, username, password, cb) {
                /* ... */
                cb(null, isAuthenticated, userCredentials)
            }
        }
    );

    next();
};

export.register.attributes = {
    name: 'auth',
    dependencies: 'hapi-auth-basic'
};

/* MANIFEST */
"registrations": [
    {
        "plugin": "hapi-auth-basic"
    },
    {
        "plugin": "./lib/authentication"
    },
    /* ... */
]

/* ROUTE CONFIG */
server.route({
    method: 'GET',
    path: '/',
    auth: 'simple-authentication',
    handler(request, response) {
        /* ... */
    }
});

Authentication in HapiJS is build around the concepts of authentication scheme and strategy. A scheme is a Hapi plugin that defines the internals of authentication (ie. basic, digest, oauth, etc).

A strategy is an instance of the scheme configured to work with our app. There could be multiple strategies (configurations) for the same schema.

The strategy for the app is defined as a plugin.

Strategies are registered using the auth.strategy method that takes up to 4 arguments.

First is the strategy's name: a server-wide unique identifier for this strategy.

Next, the schema name. Also unique, provided by the schema plugin.

Authentication mode defines the default behaviour: false means routes are not authenticated using this strategy by default. Other possible values: true, 'required', 'optional', 'try'.

The configuration object. Details depend on the schema, usually requires custom validation function.

If the plugin depends on some other plugins, it's possible to define that in the attributes object.

Register the auth plugin along with it's dependency.

The auth property allows to specify which strategy to use for this route.

Tasks

Generate docs using lout or hapi-swagger.

Each route should have validation, tags, and descriptions.

Add authentication with hapi-auth-jwt2

Add blankie and define strict Content Security Policy

Add a server method and matching route. Use caching.