![](https://habrastorage.org/webt/b1/dg/ob/b1dgobh5dxfw3q-firfz-77qu-0.png)
В этой статье речь пойдёт о написании и поддержке полезной и актуальной спецификации для REST API-проекта, которая позволит сэкономить много лишнего кода, а также серьёзно улучшить целостность, надежность и прозрачность прокта в целом.
Что такое RESTful API?
![](https://habrastorage.org/getpro/habr/post_images/dde/ee8/857/ddeee88570832b82d7a7ff7231255c3f.png)
Это миф.
Серьёзно, если вы думаете, что в вашем проекте RESTful API, вы почти наверняка ошибаетесь. Идея RESTful — в построении API, который во всём соответствовал бы архитектурным правилам и ограничениям, описанным стилем REST, однако в реальных условиях это оказывается почти невозможно.
С одной стороны REST содержит слишком много расплывчатых и неоднозначных определений. Например, некоторые термины из словарей HTTP методов и статус-кодов на практике используются не по прямому назначению, при этом многие из них не используются вообще.
C другой стороны REST создаёт слишком много ограничений. К примеру, атомарное использование ресурсов в реальном мире не рационально для API, используемых мобильными приложениями. Полный отказ от хранения состояния между запросами — по сути запрет используемого во многих API механизма пользовательских сессий.
Но подождите, не всё так плохо!
Зачем нужна спецификация REST API?
Несмотря на эти недостатки при разумном подходе REST всё же остаётся отличной основой для проектирования действительно крутых API. Такой API должен иметь внутреннее единообразие, чёткую структуру, удобную документацию и хорошее покрытие unit-тестами. Всего этого можно достичь, разработав качественную спецификацию для вашего API.
Чаще всего спецификация REST API ассоциируется с его документацией. В отличие от перво (которая является формальным описанием вашего API), документация предназначена для чтения людьми: например, разработчиками мобильного или веб-приложения, использующего ваш API.
Однако, кроме собственно создания документации, правильное описание API может принести ещё очень много пользы. В статье я хочу поделиться примерами того, как с помощью грамотного использования спецификации вы сможете:
- сделать более простым и надёжным unit-тестирование;
- настроить предобработку и валидацию входных данных;
- автоматизировать сериализацию и обеспечить целостность ответов;
- и даже воспользоваться преимуществами статической типизации.
OpenAPI
![](https://habrastorage.org/getpro/habr/post_images/222/aae/af9/222aaeaf95adf9cb50af1ed7702d4c9b.png)
Общепринятым форматом для описания REST API на сегодняшний день является OpenAPI, который также известен как Swagger. Эта спецификация представляет из себя единый файл в формате JSON или YAML, состоящий из трёх разделов:
- заголовок, содержащий название, описание и версию API, а также дополнительную информацию;
- описание всех ресурсов, включая их идентификаторы, HTTP-методы, все входные параметры, а также коды и форматы тела ответов, со ссылками на определения;
- все определения объектов в формате JSON Schema, которые могут использоваться как во входных параметрах, так и в ответах.
У OpenAPI есть серьёзный недостаток — сложность структуры и, зачастую, избыточность. Для небольшого проекта содержимое JSON-файла спецификации может быстро разрастись до нескольких тысяч строк. В таком виде поддерживать этот файл вручную невозможно. Это — серьёзная угроза для самой идеи поддержания актуальной спецификации по мере развития API.
Существует множество визуальных редакторов, позволяющих описывать API и формирующих в итоге спецификацию OpenAPI. На них в свою очередь основаны дополнительные сервисы и облачные решения, например сам Swagger, Apiary, Stoplight, Restlet и другие.
Однако, для меня подобные сервисы оказались не слишком удобными из-за сложности быстрого редактирования спецификации и совмещения с процессом написания кода. Ещё один минус — зависимость от набора функций каждого конкретного сервиса. Например, реализовать полноценное unit-тестирование только средствами облачного сервиса практически невозможно. Кодогенерация и даже создание "заглушек" для эндпоинтов, хоть и кажутся весьма возможными, на практике оказываются практически бесполезными.
Tinyspec
В этой статье я буду использовать примеры на основе собственного формата описания REST API — tinyspec. Формат представляет из себя небольшие файлы, которые интуитивно понятным синтаксисом описывают эндпоинты и модели данных, используемые в проекте. Файлы хранятся рядом с кодом, что позволяет сверяться с ними и редактировать их прямо в процессе его написания. При этом tinyspec автоматически компилируется в полноценный OpenAPI, который можно сразу же использовать в проекте. Пришло время рассказать, как именно.
В статье я буду приводить примеры из Node.js (koa, express) и Ruby on Rails, хотя эти практики применимы к большинству технологий, включая Python, PHP и Java.
Когда ещё спецификация оказывается невероятно полезной
1. Unit-тесты эндпоинтов
Behavior-driven development (BDD) идеально подходит для разработки REST API. Удобнее всего писать unit-тесты не для отдельных классов, моделей и контроллеров, а для конкретных эндпоинтов. В каждом тесте вы эмулируете настоящий HTTP-запрос и проверяете ответ сервера. В Node.js для эмуляции тестовых запросов есть supertest и chai-http, в Ruby on Rails — airborne.
Предположим, у нас есть схема User
и эндпоинт GET /users
, возвращающий всех пользователей. Вот синтаксис tinyspec, который описывает это:
- Файл user.models.tinyspec:
User {name, isAdmin: b, age?: i}
- Файл users.endpoints.tinyspec:
GET /users
=> {users: User[]}
Вот так будет выглядеть наш тест:
Node.js
describe('/users', () => {
it('List all users', async () => {
const { status, body: { users } } = request.get('/users');
expect(status).to.equal(200);
expect(users[0].name).to.be('string');
expect(users[0].isAdmin).to.be('boolean');
expect(users[0].age).to.be.oneOf(['boolean', null]);
});
});
Ruby on Rails
describe 'GET /users' do
it 'List all users' do
get '/users'
expect_status(200)
expect_json_types('users.*', {
name: :string,
isAdmin: :boolean,
age: :integer_or_null,
})
end
end
Когда у нас есть спецификация, в которой описаны форматы ответа сервера, мы можем упростить тест и просто проверять ответ на соответствие этой спецификации. Для этого мы воспользуемся тем, что наши tinyspec-модели превращаются в OpenAPI-определения, которые в свою очередь соответствуют формату JSON Schema.
Любой literal object в JS (или Hash
в Ruby, dict
в Python, ассоциативный массив в PHP и даже Map
в Java) можно протестировать на соответствие JSON-схеме. И даже есть соответствующие плагины для тестирующих фреймворков, например jest-ajv (npm), chai-ajv-json-schema (npm) и json_matchers (rubygem) для RSpec.
Перед тем как использовать схемы, надо подключить их в проект. Для начала сгенерируем на основе tinyspec файл спецификации openapi.json
(это действие можно автоматически выполнять перед каждым запуском тестов):
tinyspec -j -o openapi.json
Node.js
Теперь мы можем использовать полученный JSON в проекте и взять из него ключ definitions
, в котором находятся все JSON-схемы. Схемы могут содержать в себе перекрёстные ссылки ($ref
), поэтому, если у нас есть вложенные схемы (например, Blog {posts: Post[]}
), то нам необходимо "развернуть" их, чтобы использовать в валидациях. Для этого будем использовать json-schema-deref-sync (npm).
import deref from 'json-schema-deref-sync';
const spec = require('./openapi.json');
const schemas = deref(spec).definitions;
describe('/users', () => {
it('List all users', async () => {
const { status, body: { users } } = request.get('/users');
expect(status).to.equal(200);
// Chai
expect(users[0]).to.be.validWithSchema(schemas.User);
// Jest
expect(users[0]).toMatchSchema(schemas.User);
});
});
Ruby on Rails
json_matchers
умеет обрабатывать $ref
-ссылки, но требует наличия отдельных файлов со схемами в файловой системе по определённому пути, поэтому сначала придётся "разбить" swagger.json
на множество мелких файлов (подробнее об этом тут):
# ./spec/support/json_schemas.rb
require 'json'
require 'json_matchers/rspec'
JsonMatchers.schema_root = 'spec/schemas'
# Fix for json_matchers single-file restriction
file = File.read 'spec/schemas/openapi.json'
swagger = JSON.parse(file, symbolize_names: true)
swagger[:definitions].keys.each do |key|
File.open("spec/schemas/#{key}.json", 'w') do |f|
f.write(JSON.pretty_generate({
'$ref': "swagger.json#/definitions/#{key}"
}))
end
end
После этого наш тест мы сможем написать так:
describe 'GET /users' do
it 'List all users' do
get '/users'
expect_status(200)
expect(result[:users][0]).to match_json_schema('User')
end
end
Обратите внимание: писать тесты подобным образом невероятно удобно. Особенно, если ваш IDE поддерживает запуск тестов и отладку (как, например, WebStorm, RubyMine и Visual Studio). Таким образом, вы можете вообще не использовать какое-либо другое ПО, а весь цикл разработки API сводится к 3 последовательным шагам:
- проектирование спецификации (например, в tinyspec);
- написание полного набора тестов на добавленные/изменённые эндпоинты;
- разработка кода, удовлетворяющего всем тестам.
2. Валидация входных данных
OpenAPI описывает формат не только ответов, но и входных данных. Это позволяет нам прямо во время запроса производить валидацию данных, пришедших от пользователя.
Предположим, у нас есть следующая спецификация, которая описывает обновление данных пользователя, а также все доступные для изменения поля:
# user.models.tinyspec
UserUpdate !{name?, age?: i}
# users.endpoints.tinyspec
PATCH /users/:id {user: UserUpdate}
=> {success: b}
Ранее мы рассматривали плагины для валидации внутри тестов, однако для более общих случаев существуют модули валидации ajv (npm) и json-schema (rubygem), давайте воспользуемся ими и напишем контроллер с валидацией.
Node.js (Koa)
Это пример для Koa — преемника Express, однако для Express код будет выглядеть похожим образом.
import Router from 'koa-router';
import Ajv from 'ajv';
import { schemas } from './schemas';
const router = new Router();
// Standard resource update action in Koa.
router.patch('/:id', async (ctx) => {
const updateData = ctx.body.user;
// Validation using JSON schema from API specification.
await validate(schemas.UserUpdate, updateData);
const user = await User.findById(ctx.params.id);
await user.update(updateData);
ctx.body = { success: true };
});
async function validate(schema, data) {
const ajv = new Ajv();
if (!ajv.validate(schema, data)) {
const err = new Error();
err.errors = ajv.errors;
throw err;
}
}
В данном примере, если входные данные не соответствуют спецификации, сервер вернёт клиенту ответ 500 Internal Server Error
. Чтобы этого не произошло, мы можем перехватить ошибку валидатора и сформировать собственный ответ, который будет содержать более подробную информацию о конкретных полях, не прошедших проверку, и тоже соответствовать спецификации.
Добавим описание модели FieldsValidationError
в файле error.models.tinyspec:
Error {error: b, message}
InvalidField {name, message}
FieldsValidationError < Error {fields: InvalidField[]}
А теперь укажем её как один из возможных ответов нашего эндпоинта:
PATCH /users/:id {user: UserUpdate}
=> 200 {success: b}
=> 422 FieldsValidationError
Такой подход позволит писать unit-тесты, проверяющие правильность формирования ошибки при некорректных данных, пришедших от клиента.
3. Сериализация моделей
Практически все современные серверные фреймворки так или иначе используют ORM. Это означает, что большинство ресурсов, используемых в API, внутри системы представлены в виде моделей, их экземпляров и коллекций.
Процесс формирования JSON-представления этих сущностей для передачи в ответе API называется сериализацией. Существует ряд плагинов для разных фреймворков, выполняющих функции сериализатора, например: sequelize-to-json (npm), acts_as_api (rubygem), jsonapi-rails (rubygem). По факту эти плагины позволяют для конкретной модели указать список полей, которые необходимо включить в JSON-объект, а также дополнительные правила, например для их переименования или динамического вычисления значений.
Сложности начинаются, когда нам необходимо иметь несколько отличающихся JSON-представлений одной модели или когда объект содержит вложенные сущности — ассоциации. Возникает необходимость в наследовании, переиспользовании и связывании сериализаторов.
Разные модули решают эти задачи по-разному, но давайте задумаемся, а может ли нам снова помочь спецификация? Ведь по сути вся информация о требованиях к JSON-представлениям, все возможные комбинации полей, включая вложенные сущности, уже находятся в ней. А значит мы можем написать автоматический сериализатор.
Предлагаю вашему вниманию небольшой модуль sequelize-serialize (npm), позволяющий сделать это для моделей Sequelize. Он принимает на вход экземпляр модели или массив, а также требуемую схему и итеративно строит сериализованный объект, учитывая все требуемые поля и используя вложенные схемы для ассоциированных сущностей.
Итак, предположим, у нас есть необходимость вернуть из API всех пользователей, у которых есть посты в блоге, включая комментарии к этим постам. Опишем это с помощью следующей спецификации:
# models.tinyspec
Comment {authorId: i, message}
Post {topic, message, comments?: Comment[]}
User {name, isAdmin: b, age?: i}
UserWithPosts < User {posts: Post[]}
# blogUsers.endpoints.tinyspec
GET /blog/users
=> {users: UserWithPosts[]}
Теперь мы можем построить запрос с помощью Sequelize и вернуть сериализованный объект, в точности соответствующий только что описанной выше спецификации:
import Router from 'koa-router';
import serialize from 'sequelize-serialize';
import { schemas } from './schemas';
const router = new Router();
router.get('/blog/users', async (ctx) => {
const users = await User.findAll({
include: [{
association: User.posts,
required: true,
include: [Post.comments]
}]
});
ctx.body = serialize(users, schemas.UserWithPosts);
});
Это почти магия, правда?
4. Статическая типизация
Если вы настолько круты, что используете TypeScript или Flow, возможно, вы уже задались вопросом "А как же мои дорогие статические типы?!". С помощью модулей sw2dts или swagger-to-flowtype можно сгенерировать все необходимые определения на основе JSON-схем и использовать для статической типизации тестов, входных данных и сериализаторов.
tinyspec -j
sw2dts ./swagger.json -o Api.d.ts --namespace Api
Теперь мы можем использовать типы в контроллерах:
router.patch('/users/:id', async (ctx) => {
// Specify type for request data object
const userData: Api.UserUpdate = ctx.request.body.user;
// Run spec validation
await validate(schemas.UserUpdate, userData);
// Query the database
const user = await User.findById(ctx.params.id);
await user.update(userData);
// Return serialized result
const serialized: Api.User = serialize(user, schemas.User);
ctx.body = { user: serialized };
});
И в тестах:
it('Update user', async () => {
// Static check for test input data.
const updateData: Api.UserUpdate = { name: MODIFIED };
const res = await request.patch('/users/1', { user: updateData });
// Type helper for request response:
const user: Api.User = res.body.user;
expect(user).to.be.validWithSchema(schemas.User);
expect(user).to.containSubset(updateData);
});
Обратите внимание, что сгенерированные определения типов могут быть использованы не только в самом проекте API, но и в проектах клиентских приложений для описания типов функций, в которых происходит работа с API. Разработчики клиентов на Angular будут особенно рады такому подарку.
5. Приведение типов query string
Если ваш API по какой-то причине принимает запросы с MIME-типом application/x-www-form-urlencoded
, а не application/json
, тело запроса будет выглядеть так:
param1=value¶m2=777¶m3=false
То же самое касается и query-параметров (например, в GET-запросах). В этом случае веб-сервер не сможет автоматически распознать типы — все данные будут в виде строк (вот обсуждение в репозитории npm-модуля qs), так что после парсинга вы получите такой объект:
{ param1: 'value', param2: '777', param3: 'false' }
В этом случае запрос не будет проходить валидацию по схеме, а значит необходимо будет вручную убедиться, что каждый параметр имеет корректный формат, и привести его к требуемому типу.
Как нетрудно догадаться, это можно сделать с помощью всё тех же схем из нашей спецификации. Представим, что у нас есть такой эндпоинт и схема:
# posts.endpoints.tinyspec
GET /posts?PostsQuery
# post.models.tinyspec
PostsQuery {
search,
limit: i,
offset: i,
filter: {
isRead: b
}
}
Вот пример запроса к такому эндпоинту
GET /posts?search=needle&offset=10&limit=1&filter[isRead]=true
Давайте напишем функцию castQuery
, которая за нас приведёт все параметры к нужным типам. Она будет выглядеть примерно так:
function castQuery(query, schema) {
_.mapValues(query, (value, key) => {
const { type } = schema.properties[key] || {};
if (!value || !type) {
return value;
}
switch (type) {
case 'integer':
return parseInt(value, 10);
case 'number':
return parseFloat(value);
case 'boolean':
return value !== 'false';
default:
return value;
}
});
}
Её более полная реализация с поддержкой вложенных схем, массивов и null
-типов доступна в cast-with-schema (npm). Теперь мы можем использовать её в нашем коде:
router.get('/posts', async (ctx) => {
// Cast parameters to expected types
const query = castQuery(ctx.query, schemas.PostsQuery);
// Run spec validation
await validate(schemas.PostsQuery, query);
// Query the database
const posts = await Post.search(query);
// Return serialized result
ctx.body = { posts: serialize(posts, schemas.Post) };
});
Обратите внимание, как из четырёх строк кода эндпоинта, в трёх используются схемы из спецификации.
Лучшие практики
Отдельные схемы для создания и изменения
Обычно схемы, которые описывают ответ сервера, отличаются от тех, которые описывают входные данные, используемые при создании и изменении моделей. Например, список доступных полей при POST
— и PATCH
-запросах должен быть жестко ограничен, при этом в PATCH
-запросах обычно все поля схемы делаются опциональными. Схемы же, определяющие ответ, могут быть более свободными.
В автоматической генерации CRUDL-эндпоинтов tinyspec используются постфиксы New
и Update
. Схемы User*
могут быть определены следующим образом:
User {id, email, name, isAdmin: b}
UserNew !{email, name}
UserUpdate !{email?, name?}
Старайтесь не использовать одни и те же схемы для разных типов действий, чтобы избежать случайных проблем с безопасностью из-за повторного использования или наследования старых схем.
Семантика в названиях схем
Содержимое одних и тех же моделей может отличаться в разных эндпоинтах. Используйте постфиксы With*
и For*
в названиях схем, чтобы показать, чем они отличаются и для чего предназначены. В tinyspec модели также можно наследовать друг от друга. Например:
User {name, surname}
UserWithPhotos < User {photos: Photo[]}
UserForAdmin < User {id, email, lastLoginAt: d}
Постфиксы можно варьировать и комбинировать. Главное, чтобы их название отражало суть и упрощало знакомство с документацией.
Разделение эндпоинтов по типу клиента
Часто одни и те же эндпоинты возвращают разные данные в зависимости от типа клиента или роли пользователя, обращающегося к эндпоинту. Например, эндпоинты GET /users
и GET /messages
могут сильно отличаться для пользователей вашего мобильного приложения и для менеджеров бэк-офиса. При этом изменение самого названия эндпоинта может быть слишком большим усложнением.
Чтобы описать один и тот же эндпоинт несколько раз, можно добавить его тип в скобках после пути. Кроме того полезно использовать теги: это поможет разделить документацию ваших эндпоинтов на группы, каждая из которых будет предназначена для определённой группы клиентов вашего API. Например:
Mobile app:
GET /users (mobile)
=> UserForMobile[]
CRM admin panel:
GET /users (admin)
=> UserForAdmin[]
Документация REST API
После того как у вас появилась спецификация в формате tinyspec или OpenAPI, вы можете сгенерировать красивую документацию в HTML и опубликовать её на радость разработчикам, использующим ваш API.
Кроме упомянутых ранее облачных сервисов, существуют CLI-инструменты, конвертирующие OpenAPI 2.0 в HTML и PDF, после чего вы можете загрузить его на любой статический хостинг. Примеры:
- bootprint-openapi (npm, используется по умолчанию в tinyspec)
- swagger2markup-cli (jar, есть пример использования, будет использоваться в tinyspec Cloud)
- redoc-cli (npm)
- widdershins (npm)
Знаете ещё примеры? Поделитесь ими в комментариях.
К сожалению, вышедшая год назад OpenAPI 3.0 всё ещё мало поддерживается и мне не удалось найти достойных примеров документации на её основе: ни среди облачных решений, ни среди CLI-инструментов. По этой же причине OpenAPI 3.0 пока не поддерживается в tinyspec.
Публикация в GitHub
Один из самых простых способов публикации документации — GitHub Pages. Просто включите поддержку статических страниц для директории /docs
в настройках вашего репозитория и храните HTML-документацию в этой папке.
Можно добавить команду для генерации документации через tinyspec или другой CLI-инструмент в scripts
в package.json
и обновлять документацию при каждом коммите:
"scripts": {
"docs": "tinyspec -h -o docs/",
"precommit": "npm run docs"
}
Continuous Integration
Вы можете включить генерацию документации в цикл CI и публиковать её, к примеру, в Amazon S3 под разными адресами в зависимости от окружения или версии вашего API, например: /docs/2.0
, /docs/stable
, /docs/staging
.
Tinyspec Cloud
Если вам понравился синтаксис tinyspec, вы можете зарегистрироваться в качестве early adopter на tinyspec.cloud. Мы собираемся построить на его основе облачный сервис и CLI для автоматической публикации документации с широким выбором шаблонов и возможностью разрабатывать свои собственные шаблоны.
Заключение
Разработка REST API — пожалуй, самое приятное занятие из всех, что существуют в процессе работы над современными веб- и мобильными сервисами. Здесь нет зоопарка браузеров, операционных систем и размеров экранов, всё находится полностью под нашим контролем — "на кончиках пальцев".
Поддержание актуальной спецификации и бонусы в виде различных автоматизаций, которые при этом предоставляются, делают этот процесс ещё приятнее. Такой API становится структурированным, прозрачным и надежным.
Ведь по сути, если уж мы и занимаемся тем, что создаём миф, то почему бы нам не сделать его прекрасным?