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)


  1. raiSadam
    13.09.2019 08:41

    Ну хоть бы пару строчек вступления… Ничего же не понятно, зачем это, для кого?


    1. ATLANT1S
      13.09.2019 09:10
      +2

      Для самых маленьких же.


    1. hololoev Автор
      13.09.2019 09:46

      Простите, исправился. Добавил вступление.


  1. 1x1
    13.09.2019 15:42

    Стоит заметить, что hapi совсем не расчитан на маленьких. Главный его плюс — полный контроль над всеми этапами обработки запроса и разбиение кода на независимые части. Это более актуально для больших команд и очень сложных приложений.