5 вещей, которые вы никогда не делали со спецификацией REST

Опубликовано: 2022-03-11

Большинство фронтенд- и бэкенд-разработчиков уже имели дело со спецификациями REST и RESTful API. Но не все RESTful API одинаковы. На самом деле, они вообще редко бывают RESTful…

Что такое RESTful API?

Это миф.

Если вы думаете, что в вашем проекте есть RESTful API, вы, скорее всего, ошибаетесь. Идея RESTful API заключается в разработке таким образом, чтобы следовать всем архитектурным правилам и ограничениям, описанным в спецификации REST. Однако на практике это практически невозможно.

С одной стороны, REST содержит слишком много размытых и двусмысленных определений. Например, на практике некоторые термины из словарей методов и кодов состояния HTTP используются не по назначению или не используются вовсе.

С другой стороны, разработка REST создает слишком много ограничений. Например, атомарное использование ресурсов неоптимально для реальных API, используемых в мобильных приложениях. Полный отказ от хранения данных между запросами, по сути, запрещает механизм «пользовательской сессии», встречающийся практически везде.

Но подождите, это не так уж и плохо!

Для чего вам нужна спецификация REST API?

Несмотря на эти недостатки, при разумном подходе REST по-прежнему остается прекрасной концепцией для создания действительно отличных API. Эти API могут быть согласованными и иметь четкую структуру, хорошую документацию и высокое покрытие модульными тестами. Всего этого можно добиться с помощью высококачественной спецификации API .

Обычно спецификация REST API связана с его документацией . В отличие от спецификации — формального описания вашего API — документация предназначена для чтения человеком: например, для разработчиков мобильного или веб-приложения, использующего ваш API.

Правильное описание API — это не только хорошо написанная документация по API. В этой статье я хочу поделиться примерами того, как можно:

  • Сделайте ваши модульные тесты проще и надежнее;
  • Настроить предварительную обработку и проверку пользовательского ввода;
  • автоматизировать сериализацию и обеспечить согласованность ответов; и даже
  • Наслаждайтесь преимуществами статической типизации.

Но сначала давайте начнем с введения в мир спецификаций API.

OpenAPI

В настоящее время OpenAPI является наиболее распространенным форматом спецификаций REST API. Спецификация пишется в одном файле в формате JSON или YAML, состоящем из трех разделов:

  1. Заголовок с названием, описанием и версией API, а также любой дополнительной информацией.
  2. Описания всех ресурсов, включая идентификаторы, методы HTTP, все входные параметры, коды ответов и типы данных тела со ссылками на определения.
  3. Все определения, которые можно использовать для ввода или вывода, в формате схемы JSON (которые, да, также могут быть представлены в YAML).

Структура OpenAPI имеет два существенных недостатка: она слишком сложна и иногда избыточна. Небольшой проект может иметь спецификацию JSON из тысяч строк. Ведение этого файла вручную становится невозможным. Это серьезная угроза идее поддержания спецификации в актуальном состоянии во время разработки API.

Существует несколько редакторов, которые позволяют вам описывать API и создавать выходные данные OpenAPI. К дополнительным сервисам и облачным решениям на их основе относятся Swagger, Apiary, Stoplight, Restlet и многие другие.

Однако эти сервисы были для меня неудобны из-за сложности быстрого редактирования спецификации и согласования ее с изменениями кода. Кроме того, список функций зависел от конкретной службы. Например, создание полноценных модульных тестов на основе инструментов облачного сервиса практически невозможно. Генерация кода и имитация конечных точек, хотя и кажутся практичными, на практике оказываются в основном бесполезными. В основном это связано с тем, что поведение конечной точки обычно зависит от различных вещей, таких как разрешения пользователя и входные параметры, которые могут быть очевидны для архитектора API, но их нелегко автоматически сгенерировать из спецификации OpenAPI.

Тиниспек

В этой статье я буду использовать примеры, основанные на моем собственном формате определения REST API, tinyspec . Определения состоят из небольших файлов с интуитивно понятным синтаксисом. Они описывают конечные точки и модели данных, используемые в проекте. Файлы хранятся рядом с кодом, обеспечивая быструю справку и возможность редактирования во время написания кода. Tinyspec автоматически компилируется в полноценный формат OpenAPI, который можно сразу использовать в вашем проекте.

Я также буду использовать примеры Node.js (Koa, Express) и Ruby on Rails, но методы, которые я продемонстрирую, применимы к большинству технологий, включая Python, PHP и Java.

Где спецификация API рулит

Теперь, когда у нас есть некоторая предыстория, мы можем изучить, как получить максимальную отдачу от правильно указанного API.

1. Модульные тесты конечных точек

Разработка, управляемая поведением (BDD), идеально подходит для разработки REST API. Лучше всего писать модульные тесты не для отдельных классов, моделей или контроллеров, а для конкретных конечных точек. В каждом тесте вы эмулируете настоящий 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]); }); });

Рубин на рельсах

 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.

Любой литеральный объект в JS (или Hash в Ruby, dict в Python, ассоциативный массив в PHP и даже Map в Java) можно проверить на соответствие схеме JSON. Есть даже соответствующие плагины для тестирования фреймворков, например jest-ajv (npm), chai-ajv-json-schema (npm) и json_matchers для RSpec (rubygem).

Прежде чем использовать схемы, давайте импортируем их в проект. Сначала сгенерируйте файл openapi.json на основе спецификации tinyspec (вы можете делать это автоматически перед каждым запуском теста):

 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); }); });

Рубин на рельсах

Модуль 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 ограничен тремя этапами:

  1. Разработка спецификации в файлах tinyspec.
  2. Написание полного набора тестов для добавленных/отредактированных конечных точек.
  3. Реализация кода, который удовлетворяет тестам.

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, преемника 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[]}

А теперь перечислим его как один из возможных ответов конечной точки:

 # users.endpoints.tinyspec PATCH /users/:id {user: UserUpdate} => 200 {success: b} => 422 FieldsValidationError

Такой подход позволяет писать модульные тесты, проверяющие правильность сценариев ошибок, когда от клиента приходят недопустимые данные.

3. Сериализация модели

Почти все современные серверные фреймворки так или иначе используют объектно-реляционное сопоставление (ORM). Это означает, что большинство ресурсов, используемых API, представлены моделями, их экземплярами и коллекциями.

Процесс формирования представлений JSON для этих сущностей, которые будут отправлены в ответе, называется сериализацией .

Существует ряд плагинов для сериализации: например, sequenceize-to-json (npm), act_as_api (rubygem) и jsonapi-rails (rubygem). По сути, эти плагины позволяют вам предоставить список полей для конкретной модели, которые должны быть включены в объект JSON, а также дополнительные правила. Например, вы можете переименовывать поля и динамически вычислять их значения.

Это становится сложнее, когда вам нужно несколько разных представлений JSON для одной модели или когда объект содержит вложенные сущности — ассоциации. Затем вам начинают нужны такие функции, как наследование, повторное использование и связывание сериализаторов.

Разные модули предоставляют разные решения, но давайте подумаем: может ли снова помочь спецификация? В основном вся информация о требованиях к JSON-представлениям, все возможные комбинации полей, включая встроенные сущности, уже есть в нем. А это значит, что мы можем написать единый автоматизированный сериализатор.

Позвольте мне представить небольшой модуль sequenceize-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. Приведение типов строк запроса

Если ваш API по какой-то причине использует запросы с типом MIME application/x-www-form-urlencoded вместо application/json , тело запроса будет выглядеть так:

 param1=value&param2=777&param3=false

То же самое касается параметров запроса (например, в GET запросах). В этом случае веб-сервер не сможет автоматически распознавать типы: все данные будут в строковом формате, поэтому после парсинга вы получите такой объект:

 { 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 типов доступна в модуле приведения со схемой (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, счастливыми, и это, безусловно, лучше, чем заполнение шаблона документации REST API вручную.

Помимо упомянутых ранее облачных сервисов, существуют инструменты CLI, конвертирующие OpenAPI 2.0 в HTML и PDF, которые можно развернуть на любом статическом хостинге. Вот некоторые примеры:

  • bootprint-openapi (npm, используется по умолчанию в tinyspec)
  • swagger2markup-cli (jar, есть пример использования, будет использоваться в облаке tinyspec)
  • редок-кли (npm)
  • виддершинс (npm)

У вас есть еще примеры? Поделитесь ими в комментариях.

К сожалению, несмотря на то, что OpenAPI 3.0 был выпущен год назад, он по-прежнему плохо поддерживается, и мне не удалось найти подходящих примеров документации на его основе как в облачных решениях, так и в инструментах CLI. По той же причине tinyspec пока не поддерживает OpenAPI 3.0.

Публикация на GitHub

Один из самых простых способов публикации документации — GitHub Pages. Просто включите поддержку статических страниц для вашей папки /docs в настройках репозитория и храните HTML-документацию в этой папке.

Размещение HTML-документации вашей спецификации REST из папки /docs через GitHub Pages.

Вы можете добавить команду для создания документации с помощью tinyspec или другого инструмента CLI в файле scripts/package.json , чтобы автоматически обновлять документацию после каждой фиксации:

 "scripts": { "docs": "tinyspec -h -o docs/", "precommit": "npm run docs" }

Непрерывная интеграция

Вы можете добавить генерацию документации в свой цикл CI и публиковать ее, например, в Amazon S3 по разным адресам в зависимости от среды или версии API (например, /docs/2.0 , /docs/stable и /docs/staging ).

Облако Tinyspec

Если вам нравится синтаксис tinyspec, вы можете стать одним из первых пользователей tinyspec.cloud. Мы планируем построить на его основе облачный сервис и CLI для автоматизированного развертывания документации с широким выбором шаблонов и возможностью разработки персонализированных шаблонов.

Спецификация REST: чудесный миф

Разработка REST API — пожалуй, один из самых приятных процессов в разработке современных веб- и мобильных сервисов. Здесь нет браузера, операционной системы и зоопарков размером с экран, и все полностью под вашим контролем, у вас под рукой.

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

Суть в том, что если мы создаем миф, почему бы не сделать из него чудесный миф?

Связанный: ActiveResource.js ORM: создание мощного JavaScript SDK для вашего JSON API, быстро