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

А если запорота какая-то схема данных, либо ответ не соответствует схеме:

И не забываем, что рано или поздно тесты станут чувствительны к данным, лежащим в бд. Перед запуском тестов нужно, как минимум, вайпать базу.
ссылка на оригинал статьи https://habr.com/ru/post/467099/

Добавить комментарий