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 `
` вернуть ноль вернуть новую ошибку ('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(`
`) html = html.replace ('Google', 'Hapi') вернуть HTML } })
Хотя с async
это не всегда чище. Иногда вернуть Обещание проще:
handler: () => { return Promise.resolve(`
`) .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 … Я оставлю это на ваше усмотрение!