Hapi.js — это фреймфорк для построения web-приложений. В этом посту собранно всё самое необходимое для горячего старта. К сожалению автор совсем не писатель, по этому будет много кода и мало слов.
MVP
Ставим пачку зависимостей:
npm i @hapi/hapi @hapi/boom filepaths hapi-boom-decorators
- hapi/hapi — собственно, наш сервер
- hapi/boom — модуль генерации стандартных ответов
- hapi-boom-decorators — помощник для hapi/boom
- filepaths — утилита, которая рекурсивно читает папки
Создаём структуру папок и пачку стартовых файлов:
В ./src/routes/ складываем описание апи эндпоинтов, 1 файл — 1 эндпоинт:
// ./src/routes/home.js
async function response() {
// content-type будет автоматически генерироваться в зависимости оттого какой тип данных в ответе
return {
result: 'ok',
message: 'Hello World!'
};
}
module.exports = {
method: 'GET', // Метод
path: '/', // Путь
options: {
handler: response // Функция, обработчик запроса, для hapi > 17 должна возвращать промис
}
};
./src/server.js — модуль, экспортирующий сам сервер.
// ./src/server.js
'use strict';
const Hapi = require('@hapi/hapi');
const filepaths = require('filepaths');
const hapiBoomDecorators = require('hapi-boom-decorators');
const config = require('../config');
async function createServer() {
// Инициализируем сервер
const server = await new Hapi.Server(config.server);
// Регистрируем расширение
await server.register([
hapiBoomDecorators
]);
// Загружаем все руты из папки ./src/routes/
let routes = filepaths.getSync(__dirname + '/routes/');
for(let route of routes)
server.route( require(route) );
// Запускаем сервер
try {
await server.start();
console.log(`Server running at: ${server.info.uri}`);
} catch(err) { // если не смогли стартовать, выводим ошибку
console.log(JSON.stringify(err));
}
// Функция должна возвращать созданый сервер
return server;
}
module.exports = createServer;
В ./server.js всё, что делаем — это вызываем createServer()
#!/usr/bin/env node
const createServer = require('./src/server');
createServer();
Запускаем
node server.js
И проверяем:
curl http://127.0.0.1:3030/
{"result":"ok","message":"Hello World!"}
curl http://127.0.0.1:3030/test
{"statusCode":404,"error":"Not Found","message":"Not Found"}
In the wild
В реальном проекте нам, как минимум, нужны база данных, логгер, авторизация, обработка ошибок и много чего ещё.
Добавляем sequelize
ORM sequelize подключается в виде модуля:
...
const Sequelize = require('sequelize');
...
await server.register([
...
{
plugin: require('hapi-sequelizejs'),
options: [
{
name: config.db.database, // identifier
models: [__dirname + '/models/*.js'], // Путь к моделькам
//ignoredModels: [__dirname + '/server/models/**/*.js'], // Если какие-то из моделек нужно заигнорить
sequelize: new Sequelize(config.db), // Инициализация
sync: true, // default false
forceSync: false, // force sync (drops tables) - default false
},
]
}
...
]);
База данных становится доступна внутри роута через вызов:
async function response(request) {
const model = request.getModel('имя_бд', 'имя_таблицы');
}
Инжектим дополнительные модули в запрос
Нужно перехватить событие «onRequest», внутри которого в объект request заинжектим конфиг и логгер:
...
const Logger = require('./libs/Logger');
...
async function createServer(logLVL=config.logLVL) {
...
const logger = new Logger(logLVL, 'my-hapi-app');
...
server.ext({
type: 'onRequest',
method: async function (request, h) {
request.server.config = Object.assign({}, config);
request.server.logger = logger;
return h.continue;
}
});
...
}
После этого внутри обработчика запроса нам будет доступен конфиг, логгер, и бд, без необходимости дополнительно что-то инклюдить в теле модуля:
// ./src/routes/home.js
async function response(request) {
// Логгер
request.server.logger.error('request error', 'something went wrong');
// Конфиг
console.log(request.server.config);
// База данных
const messages = request.getModel(request.server.config.db.database, 'имя_таблицы');
return {
result: 'ok',
message: 'Hello World!'
};
}
module.exports = {
method: 'GET', // Метод
path: '/', // Путь
options: {
handler: response // Функция, обработчик запроса, для hapi > 17 должна возвращать промис
}
};
Таким образом, обработчик запроса на входе получит всё необходимое для его обработки, и будет не нужно каждый раз инклюдить одни и те же модули из раза в раз.
Авторизация
Авторизация в hapi выполнена в виде модулей.
...
const AuthBearer = require('hapi-auth-bearer-token');
...
async function createServer(logLVL=config.logLVL) {
...
await server.register([
AuthBearer,
...
]);
server.auth.strategy('token', 'bearer-access-token', {// 'token' - это имя авторизации, произвольное
allowQueryToken: false,
unauthorized: function() { // Функция вызовится, если validate вернул isValid=false
throw Boom.unauthorized();
},
validate: function(request, token) {
if( token == 'asd' ) {
return { // Если пользователь авторизован
isValid: true,
credentials: {}
};
} else {
return { // Если нет
isValid: false,
credentials: {}
};
}
}
});
server.auth.default('token'); // авторизация по умолчанию
...
}
А также внутри роута нужно указать какой вид авторизации использовать:
module.exports = {
method: 'GET',
path: '/',
auth: 'token', // либо false, если авторизация не нужна
options: {
handler: response
}
};
Если используется несколько типов авторизации:
auth: {
strategies: ['token1', 'token2', 'something_else']
},
Обработка ошибок
По умолчанию, boom выдаёт ошибки в типовом виде, часто эти ответы нужно обернуть в свой собственный формат.
server.ext('onPreResponse', function (request, h) {
// Если ответ прилетел не от Boom, то ничего не делаем
if ( !request.response.isBoom ) {
return h.continue;
}
// Создаём какое-то своё сообщение об ошибке
let responseObj = {
message: request.response.output.statusCode === 401 ? 'AuthError' : 'ServerError',
status: request.response.message
}
// Не забудем про лог
logger.error('code: ' + request.response.output.statusCode, request.response.message);
return h.response(responseObj).code(request.response.output.statusCode);
});
Схемы данных
Это небольшая, но очень важная тема. Схемы данных позволяют проверить валидность запроса и корректность ответа. Насколько качественно Вы опишите эти схемы, настолько качественными будут swagger и автотесты.
Все схемы данных описываются через joi. Давайте сделаем пример для авторизации пользователя:
const Joi = require('@hapi/joi');
const Boom = require('boom');
async function response(request) {
// Подключаем модельки
const accessTokens = request.getModel(request.server.config.db.database, 'access_tokens');
const users = request.getModel(request.server.config.db.database, 'users');
// Ищем пользователя по почте
let userRecord = await users.findOne({ where: { email: request.query.login } });
// если не нашли, говорим что не авторизованы
if ( !userRecord ) {
throw Boom.unauthorized();
}
// Проверяем, совпадают ли пароли
if ( !userRecord.verifyPassword(request.query.password) ) {
throw Boom.unauthorized();// если нет, то опять же говорим, что не авторизованы
}
// Иначе, создаём новый токен
let token = await accessTokens.createAccessToken(userRecord);
// и возвращаем его
return {
meta: {
total: 1
},
data: [ token.dataValues ]
};
}
// подсхема для токена, которую вложим в основную схему
const tokenScheme = Joi.object({
id: Joi.number().integer().example(1),
user_id: Joi.number().integer().example(2),
expires_at: Joi.date().example('2019-02-16T15:38:48.243Z'),
token: Joi.string().example('4443655c28b42a4349809accb3f5bc71'),
updatedAt: Joi.date().example('2019-02-16T15:38:48.243Z'),
createdAt: Joi.date().example('2019-02-16T15:38:48.243Z')
});
// Схема ответа
const responseScheme = Joi.object({
meta: Joi.object({
total: Joi.number().integer().example(3)
}),
data: Joi.array().items(tokenScheme)
});
// Схема запроса
const requestScheme =Joi.object({
login: Joi.string().email().required().example('pupkin@gmail.com'),
password: Joi.string().required().example('12345')
});
module.exports = {
method: 'GET',
path: '/auth',
options: {
handler: response,
validate: {
query: requestScheme
},
response: { schema: responseScheme }
}
};
Тестируем:
curl -X GET "http://localhost:3030/auth?login=pupkin@gmail.com&password=12345"
Теперь отправим вместо почты, только логин:
curl -X GET "http://localhost:3030/auth?login=pupkin&password=12345"
Если ответ не соответствует схеме ответа, то сервер также, вывалится в 500 ошибку.
В случае, если проект стал обрабатывать больше чем 1 запрос в час, может потребоваться лимитировать проверку ответов, т.к. проверка — ресурсоёмкая операция. Для этого существует параметр: «sample»
module.exports = {
method: 'GET',
path: '/auth',
options: {
handler: response,
validate: {
query: requestScheme
},
response: { sample: 50, schema: responseScheme }
}
};
В таком виде только 50% запросов будут проходить валидацию ответов.
Очень важно описать дефолтные значения и примеры, в дальнейшем они будут использоваться для генерации документации и автотестов.
Swagger/OpenAPI
Нам нужна пачка дополнительных модулей:
npm i hapi-swagger @hapi/inert @hapi/vision
Подключаем их в server.js
...
const Inert = require('@hapi/inert');
const Vision = require('@hapi/vision');
const HapiSwagger = require('hapi-swagger');
const Package = require('../package');
...
const swaggerOptions = {
info: {
title: Package.name + ' API Documentation',
description: Package.description
},
jsonPath: '/documentation.json',
documentationPath: '/documentation',
schemes: ['https', 'http'],
host: config.swaggerHost,
debug: true
};
...
async function createServer(logLVL=config.logLVL) {
...
await server.register([
...
Inert,
Vision,
{
plugin: HapiSwagger,
options: swaggerOptions
},
...
]);
...
});
И в каждом роуте нужно поставить тег «api»:
module.exports = {
method: 'GET',
path: '/auth',
options: {
handler: response,
tags: [ 'api' ], // Этот тег указывает swagger'у добавить роут в документацию
validate: {
query: requestScheme
},
response: { sample: 50, schema: responseScheme }
}
};
Теперь по адресу http://localhost:3030/documentation будет доступна веб-мордочка с документацией, а по http://localhost:3030/documentation.json .json описание.
Генерация автотестов
Если мы качественно описали схемы запросов и ответов, подготовили seed базы, соответствующий примерам, описаным в примерах запроса, то, по известным схемам, можно автоматически сгенерировать запросы и проверить коды ответа сервера.
Например, в GET:/auth ожидаются параметры login и password, их мы возьмём из примеров, которые мы указали в схеме:
const requestScheme =Joi.object({
login: Joi.string().email().required().example('pupkin@gmail.com'),
password: Joi.string().required().example('12345')
});
И если сервер ответит HTTP-200-OK, то будем считать, что тест пройден.
К сожалению, готового подходящего модуля не нашлось, придётся немного поговнокодить:
// ./test/autogenerate.js
const assert = require('assert');
const rp = require('request-promise');
const filepaths = require('filepaths');
const rsync = require('sync-request');
const config = require('./../config');
const createServer = require('../src/server');
const API_URL = 'http://0.0.0.0:3030';
const AUTH_USER = { login: 'pupkin@gmail.com', pass: '12345' };
const customExamples = {
'string': 'abc',
'number': 2,
'boolean': true,
'any': null,
'date': new Date()
};
const allowedStatusCodes = {
200: true,
404: true
};
function getExampleValue(joiObj) {
if( joiObj == null ) // if joi is null
return joiObj;
if( typeof(joiObj) != 'object' ) //If it's not joi object
return joiObj;
if( typeof(joiObj._examples) == 'undefined' )
return customExamples[ joiObj._type ];
if( joiObj._examples.length <= 0 )
return customExamples[ joiObj._type ];
return joiObj._examples[ 0 ].value;
}
function generateJOIObject(schema) {
if( schema._type == 'object' )
return generateJOIObject(schema._inner.children);
if( schema._type == 'string' )
return getExampleValue(schema);
let result = {};
let _schema;
if( Array.isArray(schema) ) {
_schema = {};
for(let item of schema) {
_schema[ item.key ] = item.schema;
}
} else {
_schema = schema;
}
for(let fieldName in _schema) {
if( _schema[ fieldName ]._type == 'array' ) {
result[ fieldName ] = [ generateJOIObject(_schema[ fieldName ]._inner.items[ 0 ]) ];
} else {
if( Array.isArray(_schema[ fieldName ]) ) {
result[ fieldName ] = getExampleValue(_schema[ fieldName ][ 0 ]);
} else if( _schema[ fieldName ]._type == 'object' ) {
result[ fieldName ] = generateJOIObject(_schema[ fieldName ]._inner);
} else {
result[ fieldName ] = getExampleValue(_schema[ fieldName ]);
}
}
}
return result
}
function generateQuiryParams(queryObject) {
let queryArray = [];
for(let name in queryObject)
queryArray.push(`${name}=${queryObject[name]}`);
return queryArray.join('&');
}
function generatePath(basicPath, paramsScheme) {
let result = basicPath;
if( !paramsScheme )
return result;
let replaces = generateJOIObject(paramsScheme);
for(let key in replaces)
result = result.replace(`{${key}}`, replaces[ key ]);
return result;
}
function genAuthHeaders() {
let result = {};
let respToken = rsync('GET', API_URL + `/auth?login=${AUTH_USER.login}&password=${AUTH_USER.pass}`);
let respTokenBody = JSON.parse(respToken.getBody('utf8'));
result[ 'token' ] = {
Authorization: 'Bearer ' + respTokenBody.data[ 0 ].token
};
return result;
}
function generateRequest(route, authKeys) {
if( !route.options.validate ) {
return false;
}
let options = {
method: route.method,
url: API_URL + generatePath(route.path, route.options.validate.params) + '?' + generateQuiryParams( generateJOIObject(route.options.validate.query || {}) ),
headers: authKeys[ route.options.auth ] ? authKeys[ route.options.auth ] : {},
body: generateJOIObject(route.options.validate.payload || {}),
json: true,
timeout: 15000
}
return options;
}
let authKeys = genAuthHeaders();
let testSec = [ 'POST', 'PUT', 'GET', 'DELETE' ];
let routeList = [];
for(let route of filepaths.getSync(__dirname + '/../src/routes/'))
routeList.push(require(route));
describe('Autogenerate Hapi Routes TEST', async () => {
for(let metod of testSec)
for(let testRoute of routeList) {
if( testRoute.method != metod ) {
continue;
}
it(`TESTING: ${testRoute.method} ${testRoute.path}`, async function () {
let options = generateRequest(testRoute, authKeys);
if( !options )
return false;
let statusCode = 0;
try {
let result = await rp( options );
statusCode = 200;
} catch(err) {
statusCode = err.statusCode;
}
if( !allowedStatusCodes[ statusCode ] ) {
console.log('*** TEST STACK FOR:', `${testRoute.method} ${testRoute.path}`);
console.log('options:', options);
console.log('StatusCode:', statusCode);
}
return assert.ok(allowedStatusCodes[ statusCode ]);
});
}
});
Не забудем про зависимости:
npm i request-promise mocha sync-request
И про package.json
...
"scripts": {
"test": "mocha",
"dbinit": "node ./scripts/dbInit.js"
},
...
Проверяем:
npm test
А если запорота какая-то схема данных, либо ответ не соответствует схеме:
И не забываем, что рано или поздно тесты станут чувствительны к данным, лежащим в бд. Перед запуском тестов нужно, как минимум, вайпать базу.
Комментарии (4)
1x1
13.09.2019 15:42Стоит заметить, что hapi совсем не расчитан на маленьких. Главный его плюс — полный контроль над всеми этапами обработки запроса и разбиение кода на независимые части. Это более актуально для больших команд и очень сложных приложений.
raiSadam
Ну хоть бы пару строчек вступления… Ничего же не понятно, зачем это, для кого?
ATLANT1S
Для самых маленьких же.
hololoev Автор
Простите, исправился. Добавил вступление.