Статьи

Создание приложений и сервисов с помощью Hapi.js Framework

Hapi.js описывается как «богатая среда для создания приложений и сервисов». Умные настройки Hapi по умолчанию упрощают создание API-интерфейсов JSON, а его модульная конструкция и система плагинов позволяют легко расширять или изменять его поведение.

В последнем выпуске версии 17.0 полностью реализованы async и await , поэтому вы будете писать код, который выглядит синхронным, но не блокирует и избегает ада обратного вызова. Win-выиграть.

Проэкт

В этой статье мы создадим следующий API для типичного блога с нуля:

 # RESTful actions for fetching, creating, updating and deleting articles GET /articles articles#index GET /articles/:id articles#show POST /articles articles#create PUT /articles/:id articles#update DELETE /articles/:id articles#destroy # Nested routes for creating and deleting comments POST /articles/:id/comments comments#create DELETE /articles/:id/comments comments#destroy # Authentication with JSON Web Tokens (JWT) POST /authentications authentications#create 

Статья будет охватывать:

  • Основной API Hapi: маршрутизация, запрос и ответ
  • модели и постоянство в реляционной базе данных
  • маршруты и действия для статей и комментариев
  • тестирование REST API с помощью HTTPie
  • аутентификация с JWT и защита маршрутов
  • Проверка
  • HTML View и Layout для корневого маршрута / .

Отправная точка

Убедитесь, что у вас установлена ​​последняя версия Node.js; node -v должен вернуть 8.9.0 или выше.

Загрузите начальный код отсюда с помощью git:

 git clone https://github.com/markbrown4/hapi-api.git cd hapi-api npm install 

Откройте package.json и вы увидите, что скрипт «start» запускает server.js с помощью nodemon . Это позаботится о перезапуске сервера для нас, когда мы изменим файл.

Запустите npm start и откройте http://localhost:3000/ :

 [{ "so": "hapi!" }] 

Давайте посмотрим на источник:

 // server.js const Hapi = require('hapi') // Configure the server instance const server = Hapi.server({ host: 'localhost', port: 3000 }) // Add routes server.route({ method: 'GET', path: '/', handler: () => { return [{ so: 'hapi!' }] } }) // Go! server.start().then(() => { console.log('Server running at:', server.info.uri) }).catch(err => { console.log(err) process.exit(1) }) 

Обработчик маршрута

Обработчик маршрута — самая интересная часть этого кода. Замените его приведенным ниже кодом, закомментируйте строки возврата по очереди и проверьте ответ в своем браузере.

 server.route({ method: 'GET', path: '/', handler: () => { // return [{ so: 'hapi!' }] return 123 return ` 

HTML- правила!

` вернуть ноль вернуть новую ошибку ('Boom') вернуть Promise.resolve ({whoa: true}) return require ('fs'). createReadStream ('index.html') } })

Чтобы отправить ответ, вы просто return значение, и Хапи отправит соответствующее тело и заголовки.

  • Object ответит строковым JSON и Content-Type: application/json
  • String значения будут Content-Type: text/html
  • Вы также можете вернуть Promise или Stream .

Функция обработчика часто делается async для более чистого потока управления с Promises:

 server.route({ method: 'GET', path: '/', handler: async () => { let html = await Promise.resolve(` 

Google

`) html = html.replace ('Google', 'Hapi') вернуть HTML } })

Хотя с async это не всегда чище. Иногда вернуть Обещание проще:

 handler: () => { return Promise.resolve(` 

Google

`) .then (html => html.replace ('Google', 'Hapi')) }

Мы увидим лучшие примеры того, как async помогает нам, когда мы начнем взаимодействовать с базой данных.

Слой модели

Как и популярный фреймворк Express.js , Hapi — это минимальный фреймворк, который не предоставляет никаких рекомендаций для слоя Model или персистентности. Вы можете выбрать любую базу данных и ORM, какую захотите, или ни одной — это ваше дело. В этом руководстве мы будем использовать SQLite и Sequelize ORM, чтобы обеспечить чистый API для взаимодействия с базой данных.

SQLite поставляется с предустановленной операционной системой MacOS и большинством дистрибутивов Linux. Вы можете проверить, установлен ли он с помощью sqlite -v . Если нет, вы можете найти инструкции по установке на веб-сайте SQLite .

Sequelize работает со многими популярными реляционными базами данных, такими как Postgres или MySQL, поэтому вам нужно установить как sequelize и адаптер sequelize :

 npm install --save sequelize sqlite3 

Давайте подключимся к нашей базе данных и напишем наше первое определение таблицы для articles :

 // models.js const path = require('path') const Sequelize = require('sequelize') // configure connection to db host, user, pass - not required for SQLite const sequelize = new Sequelize(null, null, null, { dialect: 'sqlite', storage: path.join('tmp', 'db.sqlite') // SQLite persists its data directly to file }) // Here we define our Article model with a title attribute of type string, and a body attribute of type text. By default, all tables get columns for id, createdAt, updatedAt as well. const Article = sequelize.define('article', { title: Sequelize.STRING, body: Sequelize.TEXT }) // Create table Article.sync() module.exports = { Article } 

Давайте протестируем нашу новую модель, импортировав ее и заменив наш обработчик маршрута следующим:

 // server.js const { Article } = require('./models') server.route({ method: 'GET', path: '/', handler: () => { // try commenting these lines out one at a time return Article.findAll() return Article.create({ title: 'Welcome to my blog', body: 'The happiest place on earth' }) return Article.findById(1) return Article.update({ title: 'Learning Hapi', body: `JSON API's a breeze.` }, { where: { id: 1 } }) return Article.findAll() return Article.destroy({ where: { id: 1 } }) return Article.findAll() } }) 

Если вы знакомы с SQL или другими ORM, API-интерфейс Sequelize должен быть понятен сам по себе. Он построен на Promises, поэтому он отлично работает и с async обработчиками async .

Примечание: использование Article.sync() для создания таблиц или Article.sync({ force: true }) для удаления и создания — это хорошо для целей этой демонстрации. Если вы хотите использовать это в работе, вы должны проверить sequelize-cli и написать Migrations для любых изменений схемы.

Наши RESTful действия

Давайте построим следующие маршруты:

 GET /articles fetch all articles GET /articles/:id fetch article by id POST /articles create article with `{ title, body }` params PUT /articles/:id update article with `{ title, body }` params DELETE /articles/:id delete article by id 

Добавьте новый файл routes.js , чтобы отделить конфигурацию сервера от логики приложения:

 // routes.js const { Article } = require('./models') exports.configureRoutes = (server) => { // server.route accepts an object or an array return server.route([{ method: 'GET', path: '/articles', handler: () => { return Article.findAll() } }, { method: 'GET', // The curly braces are how we define params (variable path segments in the URL) path: '/articles/{id}', handler: (request) => { return Article.findById(request.params.id) } }, { method: 'POST', path: '/articles', handler: (request) => { const article = Article.build(request.payload.article) return article.save() } }, { // method can be an array method: ['PUT', 'PATCH'], path: '/articles/{id}', handler: async (request) => { const article = await Article.findById(request.params.id) article.update(request.payload.article) return article.save() } }, { method: 'DELETE', path: '/articles/{id}', handler: async (request) => { const article = await Article.findById(request.params.id) return article.destroy() } }]) } 

Импортируйте и настройте наши маршруты перед запуском сервера:

 // server.js const Hapi = require('hapi') const { configureRoutes } = require('./routes') const server = Hapi.server({ host: 'localhost', port: 3000 }) // This function will allow us to easily extend it later const main = async () => { await configureRoutes(server) await server.start() return server } main().then(server => { console.log('Server running at:', server.info.uri) }).catch(err => { console.log(err) process.exit(1) }) 

Тестирование нашего API так же просто, как HTTPie

HTTPie — отличный маленький HTTP-клиент командной строки, который работает во всех операционных системах. Следуйте инструкциям по установке в документации, а затем попробуйте запустить API из терминала:

 http GET http://localhost:3000/articles http POST http://localhost:3000/articles article:='{"title": "Welcome to my blog", "body": "The greatest place on earth"}' http POST http://localhost:3000/articles article:='{"title": "Learning Hapi", "body": "JSON APIs a breeze."}' http GET http://localhost:3000/articles http GET http://localhost:3000/articles/2 http PUT http://localhost:3000/articles/2 article:='{"title": "True happiness, is an inner quality"}' http GET http://localhost:3000/articles/2 http DELETE http://localhost:3000/articles/2 http GET http://localhost:3000/articles 

Хорошо, кажется, все работает хорошо. Давайте попробуем еще несколько:

 http GET http://localhost:3000/articles/12345 http DELETE http://localhost:3000/articles/12345 

Yikes ! Когда мы пытаемся получить статью, которая не существует, мы получаем 200 с пустым телом, и наш обработчик уничтожения выдает Error которая приводит к 500 . Это происходит потому, что findById возвращает findById по умолчанию, когда не может найти запись. Мы хотим, чтобы наш API отвечал 404 в обоих этих случаях. Есть несколько способов достичь этого.

Защитная проверка на null значения и возврат ошибки

Существует пакет с именем boom который помогает создавать стандартные объекты ответов на ошибки:

 npm install --save boom 

Импортируйте его и измените GET /articles/:id route:

 // routes.js const Boom = require('boom') { method: 'GET', path: '/articles/{id}', handler: async (request) => { const article = await Article.findById(request.params.id) if (article === null) return Boom.notFound() return article } } 

Расширить Sequelize.Model, чтобы выдать ошибку

Sequelize.Model — это ссылка на прототип, от которого наследуются все наши модели, поэтому мы можем легко добавить новый метод find для findById и findById ошибку, если она возвращает null :

 // models.js const Boom = require('boom') Sequelize.Model.find = async function (...args) { const obj = await this.findById(...args) if (obj === null) throw Boom.notFound() return obj } 

Затем мы можем вернуть обработчик к его прежней славе и заменить вхождения findById на find :

 { method: 'GET', path: '/articles/{id}', handler: (request) => { return Article.find(request.params.id) } } 
 http GET http://localhost:3000/articles/12345 http DELETE http://localhost:3000/articles/12345 

Бум Теперь мы получаем ошибку 404 Not Found, когда пытаемся извлечь что-то из несуществующей базы данных. Мы заменили наши пользовательские проверки ошибок простым соглашением, которое поддерживает наш код в чистоте.

Примечание. Еще одним популярным инструментом для отправки запросов в API REST является Postman . Если вы предпочитаете пользовательский интерфейс и возможность сохранять общие запросы, это отличный вариант.

Параметры пути

Маршрутизация в хапи немного отличается от других фреймворков. Маршрут выбирается с учетом специфики пути, поэтому порядок их определения не имеет значения.

  • /hello/{name} соответствует /hello/bob и передает 'bob' в качестве имени параметра
  • /hello/{name?}? делает имя необязательным и совпадает с /hello и /hello/bob
  • /hello/{name*2}* обозначает несколько сегментов, соответствующих /hello/bob/marley , передавая 'bob/marley' в качестве имени параметра
  • /{args*} соответствует /any/route/imaginable и имеет самую низкую специфичность.

Объект запроса

Объект запроса, который передается обработчику маршрута, имеет следующие полезные свойства:

  • request.params — параметры пути
  • request.query — параметры строки запроса
  • request.payload — тело запроса для JSON или параметров формы
  • request.state — куки
  • request.headers
  • request.url

Добавление второй модели

Наша вторая модель будет обрабатывать комментарии к статьям. Вот полный файл:

 // models.js const path = require('path') const Sequelize = require('sequelize') const Boom = require('boom') Sequelize.Model.find = async function (...args) { const obj = await this.findById(...args) if (obj === null) throw Boom.notFound() return obj } const sequelize = new Sequelize(null, null, null, { dialect: 'sqlite', storage: path.join('tmp', 'db.sqlite') }) const Article = sequelize.define('article', { title: Sequelize.STRING, body: Sequelize.TEXT }) const Comment = sequelize.define('comment', { commenter: Sequelize.STRING, body: Sequelize.TEXT }) // These associations add an articleId foreign key to our comments table // They add helpful methods like article.getComments() and article.createComment() Article.hasMany(Comment) Comment.belongsTo(Article) // Create tables Article.sync() Comment.sync() module.exports = { Article, Comment } 

Для создания и удаления комментариев мы можем добавить вложенные маршруты по пути к статье:

 // routes.js const { Article, Comment } = require('./models') { method: 'POST', path: '/articles/{id}/comments', handler: async (request) => { const article = await Article.find(request.params.id) return article.createComment(request.payload.comment) } }, { method: 'DELETE', path: '/articles/{articleId}/comments/{id}', handler: async (request) => { const { id, articleId } = request.params // You can pass options to findById as a second argument const comment = await Comment.find(id, { where: { articleId } }) return comment.destroy() } } 

Наконец, мы можем расширить GET /articles/:id article GET /articles/:id чтобы вернуть и статью, и ее комментарии:

 { method: 'GET', path: '/articles/{id}', handler: async (request) => { const article = await Article.find(request.params.id) const comments = await article.getComments() return { ...article.get(), comments } } } 

article здесь — объект Model ; article.get() возвращает простой объект со значениями модели, в котором мы можем использовать оператор распространения для объединения с нашими комментариями. Давайте проверим это:

 http POST http://localhost:3000/articles/3/comments comment:='{ "commenter": "mb4", "body": "Agreed, this blog rules!" }' http POST http://localhost:3000/articles/3/comments comment:='{ "commenter": "Nigerian prince", "body": "You are the beneficiary of a Nigerian prince's $4,000,000 fortune." }' http GET http://localhost:3000/articles/3 http DELETE http://localhost:3000/articles/3/comments/2 http GET http://localhost:3000/articles/3 

API нашего блога практически готов к отправке в производство, просто требуется несколько последних штрихов.

Аутентификация с помощью JWT

JSON Web Tokens — это общий механизм аутентификации для API. Для его hapi-auth-jwt2 есть плагин hapi-auth-jwt2 , но он еще не был обновлен для Hapi 17.0, поэтому нам нужно установить вилку:

 npm install --save salzhrani/hapi-auth-jwt2#v-17 

Приведенный ниже код регистрирует hapi-auth-jwt2 и устанавливает стратегию с именем admin используя схему jwt . Если действительный токен JWT отправляется в заголовке, строке запроса или файле cookie, он вызовет нашу функцию проверки, чтобы убедиться, что мы рады предоставить доступ к этим учетным данным:

 // auth.js const jwtPlugin = require('hapi-auth-jwt2').plugin // This would be in an environment variable in production const JWT_KEY = 'NeverShareYourSecret' var validate = function (credentials) { // Run any checks here to confirm we want to grant these credentials access return { isValid: true, credentials // request.auth.credentials } } exports.configureAuth = async (server) => { await server.register(jwtPlugin) server.auth.strategy('admin', 'jwt', { key: JWT_KEY, validate, verifyOptions: { algorithms: [ 'HS256' ] } }) // Default all routes to require JWT and opt out for public routes server.auth.default('admin') } 

Затем импортируйте и настройте нашу стратегию аутентификации перед запуском сервера:

 // server.js const { configureAuth } = require('./auth') const main = async () => { await configureAuth(server) await configureRoutes(server) await server.start() return server } 

Теперь для всех маршрутов потребуется наша стратегия авторизации admin . Попробуйте эти три:

 http GET localhost:3000/articles http GET localhost:3000/articles Authorization:yep http GET localhost:3000/articles Authorization:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwibmFtZSI6IkFudGhvbnkgVmFsaWQgVXNlciIsImlhdCI6MTQyNTQ3MzUzNX0.KA68l60mjiC8EXaC2odnjFwdIDxE__iDu5RwLdN1F2A 

Последний должен содержать действительный токен и возвращать статьи из базы данных. Чтобы сделать маршрут открытым, нам просто нужно добавить config: { auth: false } к объекту маршрута. Например:

 { method: 'GET', path: '/articles', handler: (request) => { return Article.findAll() }, config: { auth: false } } 

Сделайте эти три маршрута общедоступными, чтобы каждый мог читать статьи и оставлять комментарии:

 GET /articles articles#index GET /articles/:id articles#show POST /articles/:id/comments comments#create 

Генерация JWT

Существует пакет с именем jsonwebtoken для подписи и проверки JWT:

 npm install --save jsonwebtoken 

Наш последний маршрут будет принимать электронную почту / пароль и генерировать JWT. Давайте определим нашу функцию входа в auth.js чтобы хранить всю логику аутентификации в одном месте:

 // auth.js const jwt = require('jsonwebtoken') const Boom = require('boom') exports.login = (email, password) => { if (!(email === '[email protected]' && password === 'bears')) return Boom.notAcceptable() const credentials = { email } const token = jwt.sign(credentials, JWT_KEY, { algorithm: 'HS256', expiresIn: '1h' }) return { token } } 
 // routes.js const { login } = require('./auth') { method: 'POST', path: '/authentications', handler: async (request) => { const { email, password } = request.payload.login return login(email, password) }, config: { auth: false } } 
 http POST localhost:3000/authentications login:='{"email": "[email protected]", "password": "bears"}' 

Попробуйте использовать возвращенный token в своих запросах к безопасным маршрутам!

Валидация с joi

Вы можете проверить параметры запроса, добавив config в объект маршрута. Приведенный ниже код гарантирует, что представленная article имеет body и title от трех до десяти символов. Если проверка не пройдена, Hapi ответит ошибкой 400 :

 const Joi = require('joi') { method: 'POST', path: '/articles', handler: (request) => { const article = Article.build(request.payload.article) return article.save() }, config: { validate: { payload: { article: { title: Joi.string().min(3).max(10), body: Joi.string().required() } } } } } } 

В дополнение к payload вы также можете добавить проверки к path , query и headers . Узнайте больше о проверке в документах .

Кто использует этот API?

Мы могли бы обслуживать одностраничное приложение из / . Мы уже видели — в начале учебника — один пример того, как обслуживать HTML-файл с потоками. Однако в Hapi есть намного лучшие способы работы с Views и Layouts. Посмотрите Обслуживание Статического Содержания и Представлений и Макетов для большего количества о том, как отобразить динамические представления:

 { method: 'GET', path: '/', handler: () => { return require('fs').createReadStream('index.html') }, config: { auth: false } } 

Если интерфейс и API находятся в одном домене, у вас не будет проблем с выполнением запросов: client -> hapi-api .

Если вы обслуживаете интерфейс из другого домена и хотите отправлять запросы к API напрямую от клиента, вам необходимо включить CORS. Это очень легко в хапи:

 const server = Hapi.server({ host: 'localhost', port: 3000, routes: { cors: { credentials: true // See options at https://hapijs.com/api/17.0.0#-routeoptionscors } } }) 

Вы также можете создать новое приложение между ними. Если вы пойдете по этому пути, вам не придется беспокоиться о CORS, поскольку клиент будет только отправлять запросы в интерфейсное приложение, а затем он может отправлять запросы к API на сервере без каких-либо междоменных ограничений. : client -> hapi-front-end -> hapi-api .

Является ли этот интерфейс другим приложением Hapi, или Next, или Nuxt … Я оставлю это на ваше усмотрение!