Create a Hapi.js server that serves static files
Use our shop frontend or roll out your own
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.
// 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
// 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.
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)
Add GET /api/cart endpoint that returns a list of products in the cart
Create an in-memory Products database. It should be able to list, add, and remove products
// 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
// 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.
// 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.
/* 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.
Log all requests with good and print routing table with blipp
Add POST /api/products route. Validate the payload and use boom for errors
Test GET / and POST /api/products endpoints
Add DELETE /api/products/{id} route
Add POST /api/cart route
Get 100% test coverage (lab -c)
// 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.
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.
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
/* /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.
/* /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.
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
// 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)
/* /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.
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.