Хабрахабр

[Перевод] 5+1 случай, когда спецификация REST API играет огромную роль

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

Что такое RESTful API?

Это миф.

Идея RESTful — в построении API, который во всём соответствовал бы архитектурным правилам и ограничениям, описанным стилем REST, однако в реальных условиях это оказывается почти невозможно. Серьёзно, если вы думаете, что в вашем проекте RESTful API, вы почти наверняка ошибаетесь.

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

К примеру, атомарное использование ресурсов в реальном мире не рационально для API, используемых мобильными приложениями. C другой стороны REST создаёт слишком много ограничений. Полный отказ от хранения состояния между запросами — по сути запрет используемого во многих API механизма пользовательских сессий.

Но подождите, не всё так плохо!

Зачем нужна спецификация REST API?

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

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

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

  • сделать более простым и надёжным unit-тестирование;
  • настроить предобработку и валидацию входных данных;
  • автоматизировать сериализацию и обеспечить целостность ответов;
  • и даже воспользоваться преимуществами статической типизации.

OpenAPI

Эта спецификация представляет из себя единый файл в формате JSON или YAML, состоящий из трёх разделов: Общепринятым форматом для описания REST API на сегодняшний день является OpenAPI, который также известен как Swagger.

  • заголовок, содержащий название, описание и версию API, а также дополнительную информацию;
  • описание всех ресурсов, включая их идентификаторы, HTTP-методы, все входные параметры, а также коды и форматы тела ответов, со ссылками на определения;
  • все определения объектов в формате JSON Schema, которые могут использоваться как во входных параметрах, так и в ответах.

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

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

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

Tinyspec

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

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

Когда ещё спецификация оказывается невероятно полезной

1. Unit-тесты эндпоинтов

Удобнее всего писать unit-тесты не для отдельных классов, моделей и контроллеров, а для конкретных эндпоинтов. Behavior-driven development (BDD) идеально подходит для разработки REST API. В Node.js для эмуляции тестовых запросов есть supertest и chai-http, в Ruby on Rails — airborne. В каждом тесте вы эмулируете настоящий HTTP-запрос и проверяете ответ сервера.

Вот синтаксис tinyspec, который описывает это: Предположим, у нас есть схема User и эндпоинт GET /users, возвращающий всех пользователей.

  1. Файл user.models.tinyspec:

User

  1. Файл 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. Когда у нас есть спецификация, в которой описаны форматы ответа сервера, мы можем упростить тест и просто проверять ответ на соответствие этой спецификации.

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

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

tinyspec -j -o openapi.json

Node.js

Схемы могут содержать в себе перекрёстные ссылки ($ref), поэтому, если у нас есть вложенные схемы (например, Blog {posts: Post[]}), то нам необходимо "развернуть" их, чтобы использовать в валидациях. Теперь мы можем использовать полученный JSON в проекте и взять из него ключ definitions, в котором находятся все JSON-схемы. Для этого будем использовать 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 последовательным шагам:

  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)

Это пример для 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. Сериализация моделей

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

Существует ряд плагинов для разных фреймворков, выполняющих функции сериализатора, например: sequelize-to-json (npm), acts_as_api (rubygem), jsonapi-rails (rubygem). Процесс формирования JSON-представления этих сущностей для передачи в ответе API называется сериализацией. По факту эти плагины позволяют для конкретной модели указать список полей, которые необходимо включить в 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. Статическая типизация

С помощью модулей sw2dts или swagger-to-flowtype можно сгенерировать все необходимые определения на основе JSON-схем и использовать для статической типизации тестов, входных данных и сериализаторов. Если вы настолько круты, что используете TypeScript или Flow, возможно, вы уже задались вопросом "А как же мои дорогие статические типы?!".

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

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

5. Приведение типов query string

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

param1=value&param2=777&param3=false

В этом случае веб-сервер не сможет автоматически распознать типы — все данные будут в виде строк (вот обсуждение в репозитории npm-модуля qs), так что после парсинга вы получите такой объект: То же самое касается и query-параметров (например, в 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-типов доступна в 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-запросах обычно все поля схемы делаются опциональными. Обычно схемы, которые описывают ответ сервера, отличаются от тех, которые описывают входные данные, используемые при создании и изменении моделей. Схемы же, определяющие ответ, могут быть более свободными.

Схемы User* могут быть определены следующим образом: В автоматической генерации CRUDL-эндпоинтов tinyspec используются постфиксы New и Update.

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.

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

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

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

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

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

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

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

Continuous Integration

0, /docs/stable, /docs/staging. Вы можете включить генерацию документации в цикл CI и публиковать её, к примеру, в Amazon S3 под разными адресами в зависимости от окружения или версии вашего API, например: /docs/2.

Tinyspec Cloud

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

Заключение

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

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

Ведь по сути, если уж мы и занимаемся тем, что создаём миф, то почему бы нам не сделать его прекрасным?

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть