[Перевод] Наилучшие практики создания REST API

?v=1

Всем привет!

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

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

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

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

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

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

Принимаем JSON и выдаем JSON в ответ

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

Существуют и другие способы передачи данных. Язык XML как таковой не очень широко поддерживается во фреймворках; обычно требуется преобразование данных в более удобный формат, а это обычно JSON. На стороне клиента, особенно в браузере, не так легко обращаться с этими данными. Приходится выполнять массу дополнительной работы всего лишь для того, чтобы обеспечить нормальную передачу данных.

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

Чтобы гарантировать, что клиент интерпретирует JSON, полученный с нашего REST API, именно как JSON, следует установить для Content-Type в заголовке отклика значение application/json после того, как будет сделан запрос. Многие серверные фреймворки приложений устанавливают заголовок отклика автоматически. Некоторые HTTP-клиенты смотрят Content-Type в заголовке отклика и разбирают данные в соответствии с указанным там форматом.

Единственное исключение возникает, когда мы пытаемся отправлять и получать файлы, пересылаемые между клиентом и сервером. Тогда требуется обрабатывать файлы, полученные в качестве отклика, и посылать данные форм с клиента на сервер. Но это тема уже для другой статьи.

Также следует убедиться, что в отклике от наших конечных точек нам приходит именно JSON. Во многих серверных фреймворках данная возможность является встроенной.

Рассмотрим в качестве примера API, принимающий полезную нагрузку в формате JSON. В данном примере используется бэкендовый фреймворк Express для Node.js. Можно использовать в качестве промежуточного ПО программу body-parser для разбора тела запроса JSON, а затем вызвать метод res.json с объектом, который мы хотим вернуть в качестве отклика JSON. Это делается так:

const express = require('express');
const bodyParser = require('body-parser');

const app = express();

app.use(bodyParser.json());

app.post('/', (req, res) => {
  res.json(req.body);
});

app.listen(3000, () => console.log('server started'));


bodyParser.json() разбирает строку с телом запроса в JSON, преобразуя ее в объект JavaScript, а затем присваивает результат объекту req.body.

Установим для заголовка Content-Type в отклике значение application/json; charset=utf-8 без каких-либо изменений. Метод, показанный выше, применим и в большинстве других бэкендовых фрейморков.

В названиях путей к конечным точкам используем имена, а не глаголы

В названиях путей к конечным точкам следует использовать не глаголы, а имена. Такое имя представляет объект с конечной точки, который мы оттуда извлекаем, либо которым мы манипулируем.

Дело в том, что в названии нашего метода HTTP-запроса уже содержится глагол. Ставить глаголы в названиях путей к конечной точке API нецелесообразно; более того, имя получается излишне длинным и не несет никакой ценной информации. Глаголы, выбираемые разработчиком, могут ставиться просто в зависимости от его прихоти. Например, кому-то больше нравится вариант «get», а кому-то «retrieve», поэтому лучше ограничиться привычным глаголом HTTP GET, сообщающим, что именно делает конечная точка.

Действие должно быть указано в названии HTTP-метода того запроса, который мы выполняем. В названиях наиболее распространенных методов содержатся глаголы GET, POST, PUT и DELETE.
GET извлекает ресурсы. POST отправляет новые данные на сервер. PUT обновляет имеющиеся данные. DELETE удаляет данные. Каждый из этих глаголов соответствует одной из операций из группы CRUD.

Учитывая два принципа, рассмотренных выше, для получения новых статей мы должны создавать маршруты вида GET /articles/. Аналогично, используем POST /articles/ для обновления новой статьи, PUT /articles/:id для обновления статьи с заданным id. Метод DELETE /articles/:id предназначен для удаления статьи с заданным ID.

/articles — это ресурс REST API. Например, можно воспользоваться Express, чтобы выполнять со статьями следующие операции:

const express = require('express');
const bodyParser = require('body-parser');

const app = express();

app.use(bodyParser.json());

app.get('/articles', (req, res) => {
  const articles = [];
  // код для извлечения статьи...
  res.json(articles);
});

app.post('/articles', (req, res) => {
  // код для добавления новой статьи...
  res.json(req.body);
});

app.put('/articles/:id', (req, res) => {
  const { id } = req.params;
  // код для обновления статьи...
  res.json(req.body);
});

app.delete('/articles/:id', (req, res) => {
  const { id } = req.params;
  // код для удаления статьи...
  res.json({ deleted: id });
});

app.listen(3000, () => console.log('server started'));


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

Конечные точки POST, PUT и DELETE принимают тело запроса в формате JSON и возвращают отклик также в формате JSON, включая в него конечную точку GET.

Коллекции называем существительными во множественном числе

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

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

При работе с конечной точкой /articles мы пользуемся множественным числом при именовании всех конечных точек.

Вложение ресурсов при работе с иерархическими объектами

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

Например, если мы хотим на определенной конечной точке получать комментарии к новой статье, то должны прикрепить путь /comments к концу пути /articles. В данном случае предполагается, что мы считаем сущность comments дочерней для article в нашей базе данных.

Например, это можно сделать при помощи следующего кода в Express:

const express = require('express');
const bodyParser = require('body-parser');

const app = express();

app.use(bodyParser.json());

app.get('/articles/:articleId/comments', (req, res) => {
  const { articleId } = req.params;
  const comments = [];
  // код для получения комментариев по articleId
  res.json(comments);
});


app.listen(3000, () => console.log('server started'));


В вышеприведенном коде можно использовать метод GET в пути '/articles/:articleId/comments'. Мы получаем комментарии comments к статье, которой соответствует articleId, а затем возвращаем ее в ответ. Мы добавляем 'comments' после сегмента пути '/articles/:articleId', чтобы указать, что это дочерний ресурс /articles.

Это логично, поскольку comments являются дочерними объектами articles и предполагается, что у каждой статьи — свой набор комментариев. В противном случае данная структура может запутать пользователя, поскольку обычно применяется для доступа к дочерним объектам. Тот же принцип действует при работе с конечными точками POST, PUT и DELETE. Все они используют одно и то же вложение структур при составлении имен путей.

Аккуратная обработка ошибок и возврат стандартных кодов ошибок

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

Коды наиболее распространенных ошибок HTTP:

  • 400 Bad Request (Плохой Запрос) — означает, что ввод, полученный с клиента, не прошел валидацию.
  • 401 Unauthorized (Не авторизован) — означает, что пользователь не представился и поэтому не имеет права доступа к ресурсу. Обычно такой код выдается, когда пользователь не прошел аутентификацию.
  • 403 Forbidden (Запрещено) — означает, что пользователь прошел аутентификацию, но не имеет права на доступ к ресурсу.
  • 404 Not Found (Не найдено) — означает, что ресурс не найден
  • 500 Internal server error (Внутренняя ошибка сервера) — это ошибка сервера, которую, вероятно, не следует выбрасывать явно.
  • 502 Bad Gateway (Ошибочный шлюз) — означает недействительное ответное сообщение от вышестоящего сервера.
  • 503 Service Unavailable (Сервис недоступен) — означает, что на стороне сервера произошло нечто непредвиденное — например, перегрузка сервера, отказ некоторых элементов системы, т.д.


Следует выдавать именно такие коды, которые соответствуют ошибке, помешавшей нашему приложению. Например, если мы хотим отклонить данные, пришедшие в качестве полезной нагрузки запроса, то, в соответствии с правилами Express API, должны вернуть код 400:

const express = require('express');
const bodyParser = require('body-parser');

const app = express();

// существующие пользователи
const users = [
  { email: 'abc@foo.com' }
]

app.use(bodyParser.json());

app.post('/users', (req, res) => {
  const { email } = req.body;
  const userExists = users.find(u => u.email === email);
  if (userExists) {
    return res.status(400).json({ error: 'User already exists' })
  }
  res.json(req.body);
});


app.listen(3000, () => console.log('server started'));


В вышеприведенном коде мы держим в массиве users список имеющихся пользователей, у которых известна электронная почта.

Далее, если мы пытаемся передать полезную нагрузку со значением email, уже присутствующим в users, то получаем отклик с кодом 400 и сообщение 'User already exists', означающее, что такой пользователь уже существует. Располагая этой информацией, пользователь может поправиться — заменить адрес электронной почты на тот, которого пока нет в списке.

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

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

Разрешать сортировку, фильтрацию и разбивку данных на страницы

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

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

Как фильтрация, так и пагинация данных позволяют повысить производительность, сократив использование серверных ресурсов. Чем больше данных накапливается в базе, тем более важными становятся две эти возможности.

Вот небольшой пример, в котором API может принимать строку запроса с различными параметрами. Давайте отфильтруем элементы по их полям:

const express = require('express');
const bodyParser = require('body-parser');

const app = express();

// информация о сотрудниках в базе данных
const employees = [
  { firstName: 'Jane', lastName: 'Smith', age: 20 },
  //...
  { firstName: 'John', lastName: 'Smith', age: 30 },
  { firstName: 'Mary', lastName: 'Green', age: 50 },
]

app.use(bodyParser.json());

app.get('/employees', (req, res) => {
  const { firstName, lastName, age } = req.query;
  let results = [...employees];
  if (firstName) {
    results = results.filter(r => r.firstName === firstName);
  }

  if (lastName) {
    results = results.filter(r => r.lastName === lastName);
  }

  if (age) {
    results = results.filter(r => +r.age === +age);
  }
  res.json(results);
});

app.listen(3000, () => console.log('server started'));


В вышеприведенном коде у нас есть переменная req.query, позволяющая получить параметры запроса. Затем мы можем извлечь значения свойств путем деструктуризации отдельных параметров запроса в переменные; для этого в JavaScript предусмотрен специальный синтаксис.
Наконец, мы применяем filter с каждым значением параметра запроса, чтобы найти те элементы, которые хотим вернуть.

Справившись с этим, возвращаем results в качестве отклика. Следовательно, при выполнении запроса GET к следующему пути со строкой запроса:

/employees?lastName=Smith&age=30

Получаем:

[
    {
        "firstName": "John",
        "lastName": "Smith",
        "age": 30
    }
]


в качестве возвращенного ответа, поскольку фильтрация производилась по lastName и age.
Аналогично, можно принять параметр запроса page и вернуть группу записей, занимающих позиции от (page - 1) * 20 до page * 20.

Также в строке запроса можно указать поля, по которым будет производиться сортировка. В таком случае мы можем отсортировать их по этим отдельным полям. Например, нам может понадобиться извлечь строку запроса из URL вида:

http://example.com/articles?sort=+author,-datepublished

Где + означает «вверх», а  «вниз». Таким образом, мы сортируем по имени автора в алфавитном порядке и по datepublished от новейшего к наиболее давнему.

Придерживаться проверенных практик обеспечения безопасности

Коммуникация между клиентом и сервером должна быть в основном приватной, так как зачастую мы отправляем и получаем конфиденциальную информацию. Следовательно, использование SSL/TLS для обеспечения безопасности — обязательное условие.

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

Человеку нельзя открывать доступ к большему объему информации, чем он запросил. Например, рядовой пользователь не должен получать доступа к информации другого пользователя. Также он не должен иметь возможности посмотреть данные администраторов.

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

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

Кэшировать данные для улучшения производительности

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

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

Например, в Express предусмотрено промежуточное ПО apicache, позволяющее добавить в приложение возможность кэширования без сложной настройки конфигурации. Простое кэширование в оперативной памяти можно добавить на сервер вот так:

const express = require('express');

const bodyParser = require('body-parser');
const apicache = require('apicache');
const app = express();
let cache = apicache.middleware;
app.use(cache('5 minutes'));

// информация о сотрудниках в базе данных
const employees = [
  { firstName: 'Jane', lastName: 'Smith', age: 20 },
  //...
  { firstName: 'John', lastName: 'Smith', age: 30 },
  { firstName: 'Mary', lastName: 'Green', age: 50 },
]

app.use(bodyParser.json());

app.get('/employees', (req, res) => {
  res.json(employees);
});

app.listen(3000, () => console.log('server started'));


Вышеприведенный код просто ссылается на apicache при помощи apicache.middleware, в результате имеем:

app.use(cache('5 minutes'))

и этого достаточно, чтобы применить кэширование в масштабах всего приложения. Кэшируем, например, все результаты за пять минут. Впоследствии это значение можно откорректировать в зависимости от того, что нам нужно.

Версионирование API

У нас должны быть различные версии API на тот случай, если мы вносим в них такие изменения, которые могут нарушить работу клиента. Версионирование может производиться по семантическому принципу (например, 2.0.6 означает, что основная версия — 2, и это шестой патч). Такой принцип сегодня принят в большинстве приложений.

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

Версионирование обычно делается путем добавления /v1/, /v2/, т.д., добавляемых в начале пути к API.

Например, вот как это можно сделать в Express:

const express = require('express');
const bodyParser = require('body-parser');
const app = express();
app.use(bodyParser.json());

app.get('/v1/employees', (req, res) => {
  const employees = [];
  // код для получения информации о сотрудниках
  res.json(employees);
});

app.get('/v2/employees', (req, res) => {
  const employees = [];
  // другой код для получения информации о сотрудниках
  res.json(employees);
});

app.listen(3000, () => console.log('server started'));


Мы просто добавляем номер версии к началу пути, ведущего к конечной точке.

Заключение

Важнейший вывод, связанный с проектированием высококачественных REST API: в них необходимо сохранять единообразие, следуя стандартам и соглашениям, принятым в вебе. JSON, SSL/TLS и коды состояния HTTP — обязательная программа в современном вебе.

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

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

© Habrahabr.ru