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

Автор оригинала: John Au-Yeung
  • Перевод
Всем привет!

Предлагаемая вашему вниманию статья, несмотря на невинное название, спровоцировала на сайте 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. Пути вложенных ресурсов должны следовать после пути родительского ресурса. Они должны сообщать, что мы получаем или чем манипулируем, так, чтобы нам не приходилось дополнительно обращаться к документации, чтобы понять, что происходит.

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

Голосовалка

  • 50,8%Издайте книгу по REST62
  • 26,2%Издайте книгу по Redis32
  • 18,8%Издайте книгу по Express23
  • 43,4%Прочел, прослезился53

Комментарии 54

    +11

    Главная проблема REST в том, что REST — это термин, не стандарт, не спецификация, а всего лишь свод соглашений, весьма условных и слабых. Каждый волен пилить свою API как угодно. Сколько копий сломано в спорах о том, как называть маршруты до сущностей, правильно ли включать в маршруты глаголы, как версионировать, должен ли API отражать только взаимодействие с сущностями (CRUD) или может обрабатывать бизнес-операции? Базовый CRUD может написать каждый даже не имея навыков программирования, благо сейчас полно конструкторов API. Но стоит API чуток разрастись, как тут же начинаются проблемы с бизнес-процессами. Например, как сделать API для добавления комментариев? POST /comments? POST /articles/1/comments? COMMENT /articles/1?


    Многие возразят, что есть стандарты для реализации REST API вроде Swagger (Open API), JSON:API, OData, HAL, RAML, HATEOAS и т.п. На практике из этих стандартов жизнеспоспобны (на моем опыте) только Open API и RAML. И несмотря на наличие этих стандартов все равно остается проблема общения с потребителями API (клиенты, фронтендеры). В лучшем случае у потребителя будет HTML-документация (счастье, если не устаревшая).


    Коммент получился сумбурный, но что я хочу сказать из своего опыта — REST хорош для начала как MVP или как публичное API чисто для фронта, потому что можно пользоваться всеми фичами HTTP. Для чего-то серьезного рекомендую применять GraphQL, не потому что он хороший, а потому что на данный момент достойных альтернатив нет.

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

      В силу специфики своей работы я часто использую JSON-RPC, который тоже имеет лучшие практики, но от которых часто отклоняются разработчики…
        0

        Все статьи заканчиваются описанием простейших случаев. Да и то не отвечают на некоторые вопросы если копнуть глубже. Например если в базе не ещё ни одной статьи что вернуть? Вариант наиболее естественный пустой массив. Однако orm часто в этом случае возвращает null или undefined, вследствие чего это значение попадает и на выход api. Тут же обязательно найдется теоретик который скажет что статей нет значит нужно вернуть 404 статус. По пагинации аналогично. Автор както запамятовал что нужно передать ещё общее количество записей в базе для организации интерфейса. Как их передавать? В заголовке вдруг и внезапно. Или делать объект с полями count, items? Или если у вас в статье есть поле Автор. И нужно передать из связанной таблицы имя и фамилию. Должен ли ответ включать autorId или только объект author с полемid? А если мы обновляем автора то мы должны прислать новый идентификатор в поле autorId или в объекте author { id

          0
          Однако orm часто в этом случае возвращает null или undefined, вследствие чего это значение попадает и на выход api.


          return $posts ?? [];

          Автор както запамятовал что нужно передать ещё общее количество записей в базе для организации интерфейса. Как их передавать?


          на laravel делаю так:
          return $query->paginate();

          paginate возвращает массив с данными data, count, page и так далее

          На фронтенде для этого объекта описан интерфейс метод получения постов будет возвращать PaginationInterface<Post[]>

          Или если у вас в статье есть поле Автор. И нужно передать из связанной таблицы имя и фамилию. Должен ли ответ включать autorId или только объект author с полемid?


          Тут логичнее 2 вариант.

          А если мы обновляем автора то мы должны прислать новый идентификатор в поле autorId или в объекте author { id

          Если все что вернется — это id, тогда логичнее возвращать int
        +2
        А почему OData не жизнеспособна? Что Вы имеете в виду?
        Это ведь протокол, а не свод соглашений.
          –3
          Не знал про GraphQL, интересная разработка. Век живи век учись!
          +13

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


          Например запрос "добавить пользователя в проект". Что это должно быть с точки зрения методов HTTP? Post? Put? А какую ошибку он должен выдавать, если у этого пользователя, например, нет прав на доступ к этому проекту? 403 Forbidden? А как это отличить еще от кучи возможных ошибок доступа (например, у нас самих нет прав добавлять пользователя в этот проект, или нет прав вообще работать с пользователями и вообще нет доступа к этому эндпоинту, а может у нас вообще прокся сглючила или сеть отвалилась и запрос даже до сервера не дошел)?


          Поэтому в большинстве проектов что я видел использовался какой-то JSON-over-HTTP, который вряд ли можно назвать RESTом, но все называли его так. Сервер всегда возвращает 200 ОК, HTTP ошибки используются только для реальных ошибок протокола и сети. Внутри ответа JSON с реальным кодом ошибки. Из методов обычно используется только GET (для запросов не изменяющих состояние) и POST (изменяющих). Ну иногда DELETE. Эндпоинты именуются как захочется, чтобы было понятно, а не в угоду какому-то синтетическому стандарту.


          Собственно на труъ-REST проекте работал лишь однажды, и это было мучение.

            +2

            “ Например запрос "добавить пользователя в проект". Что это должно быть с точки зрения методов HTTP? Post? Put?”


            Это post


            « А какую ошибку он должен выдавать, если у этого пользователя, например, нет прав на доступ к этому проекту? 403 Forbidden? А как это отличить еще от кучи возможных ошибок доступа (например, у нас самих нет прав добавлять пользователя в этот проект, или нет прав вообще работать с пользователями и вообще нет доступа к этому эндпоинту, а может у нас вообще прокся сглючила или сеть отвалилась и запрос даже до сервера не дошел)?»


            Да, forbidden, а отличить легко, посмотреть код ошибки, Не важно каких прав нет, статус всегда 403 (если ошибка относится к правам), но в ответе разработчик дополнительно указывает данные, message, code и так далее, всяко лучше чем когда все get и не возвращает ошибок как в вашем примере ниже

              0
              Это post


              Если пользователь значение поля проекта, добавляющееся к существующему проекту, то можно использовать PUT (пользователя можно передать в поле JSON объекта проекта или по адресу поля)
            +2

            Вот спорно очень насчёт кодов ошибок. Потому как, к примеру, 404 — относится к тому что нет маршрута или нет документа? 50x — проблема с транспортом, прокси или самим приложением? Если не смотреть внутрь — то по одному коду непонятно, это проблема endpoint или самого приложения, а если смотреть и разбирать — то какой смысл использовать коды транспорта?


            Или в приведенном примере:


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

            А если сообщения локализованы? Как на уровне клиента корректно это обрабатывать, имея только код 400? Если есть ещё код от самого приложения — то см. выше, коды транспорта просто лишены смысла — неясно, ошибся пользователь, указав неверные данные, или приложение где-то словило исключение по независящим от пользователя причинам.


            Яркий пример такого неудобства — это PowerDNS API — там на всё код 400 и исключительно текстовые сообщения об ошибках, хорошо хоть не локализованы — но если кто-то решит их изменить хоть на один символ (что уже случалось), то все парсеры пойдут в лес (да, как это не удивительно, но некоторые клиенты создаются с учётом адекватной реакции на ошибки).


            Из кода ошибки должно быть ясно сразу и однозначно, где и почему она произошла (транспорт/приложение/валидация/etc), она временная или нет, одна или много, имеет ли смысл повторить запрос позже или "уже всё" и т.д. (примерно как SQL state). Очевидно, что HTTPшных кодов для этого недостаточно и поэтому использовать их для этого мало смысла.


            Я обычно при проектировании REST API исхожу из того что все коды ошибок HTTP относятся исключительно к транспорту (т.е. собственно HTTP), и при любом раскладе, если документ попал в приложение и был там обработан, то на выходе должно быть 200 — при любом ответе, с ошибкой или нет, а само тело ответа всегда содержит подробную информацию в случае ошибки в структурированном виде. Если код не 200 — значит он не попал в приложение, со всеми вытекающими.


            Из личного опыта — у одного из моих клиентов, которые использовали "чистый REST", пользователи никогда (при первой реализации) не изучают тело ответа при коде отличном от 200, были потрачены тонны тикетов для объяснений почему они должны смотреть внутрь если это не 200. Что примечательно, при переходе на код 200 в следующей версии с ошибками внутри никаких вопросов у новых пользователей уже не возникало.


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

              +1

              Получение текстовых строк 200 { error: "User already exists" } действительно с системой локализации не матчится и подходит только для неожиданных сбоев, поэтому во всех проектах делаем константы 200 { error: "USER_EXISTS" }, фронт уже выполнит необходимую логику и заберет из локалей перевод. С остальным согласен, HTTP коды только для транспортных ошибок удобны, а те, кто на них опирается в разработке сложных приложений, сталкиваются с их многозначностью, которую приходится специфицировать либо в body, либо в заголовках ответа (типа 402 resp.headers { validation_error: "FIELD_NOT_VALID" }).

                0
                404 — относится к тому что нет маршрута или нет документа?


                нет никакой разницы, ведь по маршруту все равно ничего нет
                  +1

                  Нет, есть. В одном случае это ошибка в приложении, в другом — ошибка данных.
                  Вероятно надо при этом разные сообщения об ошибках выдавать.
                  Или вот пример выше я приводил — получаете 403, а что вы должны показать клиенту? Есть куча ошибок бизнес-логики, попадающих под определение ошибки доступа, возможно на клиенте их надо обрабатывать по-разному, но из кода ошибки это так просто не выжать.


                  При этом я точно встречал библиотеки работы с HTTP, которые в случае ошибки от сервера выкидывали всякие эксепшены и не давали заглянуть в тело ответа. Так что идея "код 403 + json внутри с подробным описанием" даже технически работает не всегда.

                  +2

                  Интересно что в обсуждении оригинала статьи те же самые контрпримеры. Хотя пожалуй сейчас это уже мало кого волнует. Все уже смирились. А с развитием микросервисной архитектуры, построенной на HTTP протоколе просто не имеют альтернативы. Если не REST-API то нужно забыть про микросервисы, kubernetes — а это как раз наоборот сейчас самые перспективные как думает большнство архитектурные решения и технологии.


                  Поэтому интерес к статье и переводу ожидаемо умеренный, хотя лет 10 назад это могло бы быть темой на 1024 комментария

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

                  Как будет выглядеть конечная точка для отправки почты, например? Или активации пользователя?
                    0

                    Строго говоря, два неравноправных случая. Отправка почты – это создание письма, буквочка C из CRUD. Поэтому это будет что-то вроде POST /mail.
                    А вот активация пользователя – это уже действие. Поэтому тут каждый извращается, как может.

                      0

                      Активация это U, потому что пользователь уже существует и де-факто это изменение части объекта. Логично для этой цели использовать PATCH.

                        0
                        и де-факто это изменение части объекта

                        Не всегда действие ведет к изменению объекта.
                        Логично для этой цели использовать PATCH.

                        Это же ни разу не очевидно. Т.е. на PUT вы пользователя сохранили (и это общепринятая практика), а на PATCH почему-то активировали. А если еще будут действия с пользователями что вы будете делать?
                          0

                          POST — создать новый объект. PUT — заменить существующий объект. PATCH — изменить часть существующего объекта. Что тут неочевидно?


                          Если у объекта есть атрибут "активен" — это часть объекта, так что на любые изменения отдельных частей — это таки PATCH.


                          Если уж вам хочется что бы всё было отдельно — сделайте user-activation и используйте PUT или POST, хотя это (как мне кажется) менее логично, потому что сразу возникает искушение создать его отдельно от user (и кто-то обязательно это сделает, пусть и неудачно).

                            0
                            PUT — заменить существующий объект. PATCH — изменить часть существующего объекта. Что тут неочевидно?

                            Мы друг друга не поняли. Я действительно имел в виду, что активация это не только изменение свойства у пользователя, а отдельный большой процесс.
                            сделайте user-activation и используйте PUT или POST

                            ниже уже про такое писал, но лично мне нагляднее видеть
                            POST user/{id}/activate
                              0

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


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

                                +1

                                Одним и не самым маловажным отрицательным свойством restapi является в частности тот факт что отсутствие стандартизации и спецификации по любому вопросу отличному от crud порождает бесконечные споры. Что мы можем наблюдать даже на примере этого вот обсуждения. Главное что все позиции правы. И не правы. Не правы в том что пытаются найти смысл в restapi

                          0

                          Я думаю, что автор вопроса под "активацией пользователя" имел в виду нечто большее, чем, к примеру, установку свойства isActive из false в true. Под активацией может подразумеваться целый ворох изменений, не обязательно одного свойства. К тому же, активация может генерировать цепочку событий, на которые подписаны другие части системы.

                            0

                            Никто не запрещает действия при изменении свойства, этот ворох изменений может быть сделан во время PATCH при изменении состояния свойста, с соответствующим фидбеком (получилось/нет).


                            С другой стороны, точно также никто не запрещает создать отдельный ресурс для этого, который будет отличаться только… отдельным ресурсом, в то время как с точки зрения клиента принципиально ничего не меняется — объект активирован, что там произошло для этого "у ней внутре" его не интересует, кроме подтверждения изменения состояния.


                            Если же активация подразумевает отдачу чего-то очень интимного, свойственного только активации (credentials, token etc) — да, в этом случае уже будет логично создать отдельный ресурс.

                            +2
                            Добро пожаловать в удивительный мир CDN и прочих файрволов — где про PATCH никогда не слышали.
                            0
                            А вот активация пользователя – это уже действие. Поэтому тут каждый извращается, как может.

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

                              Один из подходов – всё-таки иметь wite-only ресурсы, имена которых обозначаются не существительными, а глаголами: POST /user/{userID}/activate

                                0
                                В статье написано, что глаголы нельзя. Часто видел когда люди, следуя подобным правилам, делали отглагольные варианты, например
                                POST /user/{userID}/activating или activation
                                Выглядит странновато, лично мне привычнее глаголы, но люди говорили что глаголы нельзя, правда, не могли объяснить почему.
                                  +2

                                  То что написано в статье — одно из мнений. Делайте так как вам подсказывает команда, здравый смысл, клиенты и ТЗ.


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


                                  Идеальный API — это тот когда пользователи активно его используют и при этом у них (почти) не возникает вопросов — исходите из этого.

                          0

                          Я конечно понимаю что статья про rest но хочу напомнить что http это не единственный способ организации взаимодействия, есть еще вебсокеты. Я вот например выбросил http и перевел все взаимодействие клиента с сервером на вебсокеты и не нарадуюсь — мне стали не нужны все эти бэкенд-фреймворки (например expres, koa, nestjs) и библиотеки которые в основном нацелены на http-стек.
                          К тому же напомню что в области разработки десктопных приложений для взаимодействия клиента с сервером испокон веков использовали обычные сокеты. И только с появлением веба и потому что до некоторого времени в браузерах javascript поддерживал лишь отправку http запросов стали популярными все эти rest-подходы поверх http.
                          Но теперь когда у нас есть поддержка вебсокетов (а это не еще одна абстракция поверх http как бывают думают некоторые, да для установки соединения по вебсокетам используется http но дальше это просто передача хедера с размером сообщения поверх tcp) и растет популярность desktop-like web-приложений (а есть еще offline-first приложения которые могут работать без сети и нужно синхронизировать изменения) использовать http в качестве транспорта не имеет никакого смысла

                            0
                            На 95% процентов согласен. Единственное — если данные передаются редко, вебсокеты либо станут отключаться, либо (если есть какой-то keep-alive) будут создавать нагрузку на сервер.
                              0

                              100% с Вами согласен. По советам и нагрузке. Однако если вынести советы на какой то брокер то нагрузка может не только увеличить вы но даже уменьшиться. Так как не будет затрат на соединение и загрузка будет более сглаженная. Так как бизнес сервер будет выбирать из очередей события в том объеме ко оный может обработать

                              0
                              Расскажу такой кейс. У вас есть редкоизменяемые данные на стороне сервера, иногда клиенту надо писать данные в бэкенд. Получается что мы постоянно держим соединение, по которому данные идут в основном в одну сторону. Дорого — каждое ресурсы жрет на сервере- можно очевидно сделать дешевле, отправляя запросы по HTTP только тогда, когда они действительно нужны. Редкий случай? Или нет?
                                0

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

                                  +1
                                  переход на брокер это «грязный хак», который нагрузку сглаживает за счет задержки — и точно также может быть применен в любой парадигме, в том числе и с http API. Что касается пушей — комбинация опроса по таймеру плюс использование платформенных пушей, если мы говорим о мобильных приложениях, куда экономичней. Опять же — это частный кейс — и никак не ложится на утверждение «не используйте http API, а используйте websocket». К тому же помилуйте, кто сейчас пишет на Erlang?
                                    0

                                    Вот Вы все вроде бы говорите правильно. Но есть нюансы.
                                    По порядку. Брокер нагрузку сглаживает не в большей степени чем любой веб-сервер у которого запросы выстраиваются в очередь. Ускорение происходит за счет других факторов. Нет расходов на повторное соединение и нет влияния мелких разрывов в сети. Так как согласно протокола произойдет реконнект и ответ с сервера будет получен.
                                    Опрос по таймеру. Честно, иногда приходится соглашаться. Но по двум причинам. Если заведомо известно что трафик будет очень слабый, и так как все уже реализовано по restapi и тянуть еще одну технологию мобильные разработчики просто отказываются наотрез.
                                    По пушам firebase все классно. Было бы. Если бы их нельзя было отключать. Но их иногда отключают и что же ломать логику приложения?
                                    По хорошему с таймером н и когда и не при таких условиях.
                                    Ну и про erlang. Брокеры пишут на erlang. Их довольно много.

                                  0

                                  Вы считаете что постоянно держать соединение дорого? Соединение в linux и в node это всего лишь файловый дескриптор размером с сотню байт. Расходов кроме памяти практически нет (браузеры либо вообще не отсылают специальные "ping"-вебсокет сообщения либо делают это очень редко — за полчаса я не получил ни одного такого сообщения, а потом мне ждать надоело)


                                  А что касается http — в нем есть заголовок "connection: keep-alive" который сообщает серверу открыть и держать tcp-соединение (и пересылать все http запросы по этому соединению) точно так же как и с вебсокетами, плюс в версии http 1.1 это подразумевается по умолчанию (если явно не передано "connection: close"). В общем можно сказать что значительная часть интернета использует keep-alive. В таком случае у http нет никаких преимуществ перед websockets


                                  Даже наоборот — в http есть фундаментальный недостаток — из-за того что http параллельный и "stateless" — браузеры не гарантируют что запросы на сервер поступят в том же порядке в котором были отправлены на клиенте (могут использовать keep-alive а могут и не использовать — это всего лишь оптимизация и узнать и проконтролировать со стороны javascript невозможно) и из-за этого на порядки усложняется решение одной из самых главных проблем всех бэкендов — https://habr.com/ru/company/yandex/blog/442762


                                  А вот с вебсокетами проблема неидемпотентных запросов описанная в статье решается очень просто. Поскольку используется только одно единственное соединение которое сами контролируем то получаем гарантию что запросы на сервер будут поступать последовательно а значит на сервере достаточно сохранить только айдишник последнего запроса а не хранить айдишники для всех http-запросов (а как-то очищать по таймеру ненадежно потому потому что могут быть ситуации остановки приложений и запуска через время когда они уже были очищены)

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

                                В смешанной среде полезней иметь как раз стандартные multipart/form-data для POST'а cURL'ом например.
                                  0

                                  стоит ли делать композитный апи над enity апи ?

                                    +2
                                    401 Unauthorized

                                    Тут надо заметить, что этот код при отсутствии корректной аутентификации используется только потому, что лучшей альтернативы нет. Unauthorized — это отсутствие авторизации, а не аутентификации. Отсутствие авторизации что-то делать вызывает запрет, т.е. Forbidden — так что по смыслу словесной части 401 и 403 скорее идентичны, чем различны. Правильно было бы написать 401 Unauthenticated, но что уж есть в RFC — то есть.
                                      0
                                      В книжках по SQL я как раз встречал совет называть таблицы в единственном числе. Типа все и так знают, что в таблицах обычно хранят несколько значений. Тогда и в REST API стоит использовать единственное число.
                                      В итоге получаем экономию на трафике за счёт избавления от [E]S в каждом запросе.
                                        0

                                        Лучше JSON заменить на что-нибудь вроде MessagePack или CBOR, а это так копейки.

                                        0

                                        Rest более менее пришёл в порядок последнее время, и лишь из года в год поднимается тема "REST зло, дайте RPC".


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

                                          +1
                                            0

                                            Не могу понять как он пришел в порядо если через двадцать лет все смотрят о тех же вопросах. Появился open api. Да это хорошо. Но это же просто средство документирования и отчасти проектирования. Хотя больше документтирования

                                            0

                                            RESTful как набор архитектурных принципов кажется простым, но реализовать протокол довольно сложно. Например, вот попытка формализации REST поверх HTTP в виде машины состояний https://github.com/webmachine/webmachine/wiki/Diagram. Без поддержки и жёстких ограничений со стороны фреймворка, на практике такое ни кто делать не станет. Поэтому реальные API весьма разнообразны.

                                              0

                                              Rest это набор архитектурных принципов. А restfull он же reatapi это практика истоки которой лежат в скафолдинге первых фреймворков. Когда некие фронтовые запросы прямо отражались в запросы к базе данных

                                                0
                                                да, спасибо, речь конечно же про REST. а RESTful это максимальный уровень соответствия принципам/ограничениями REST.
                                                  +1

                                                  Restful api не имеет никакого отношение к rest и во многом ему противоречит

                                                    0
                                                    Интересно, если это распространенное мнение, можете кинуть ссылкой где про это почитать, или привести какие-то примеры?
                                            0

                                            А что скажите по поводу использования OpenAPI, swagger и генерации серверного кода?

                                              +1

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

                                              +2

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


                                              В этом смысле довольно полезна http://apistylebook.com/


                                              Сборник гайдов разных компаний с группировкой по разделам.


                                              Даёт возможность
                                              1)посмотреть "а как у них"
                                              2)узнать об аспектах/проблемах которые в co-статьях редко освещаются, а на старте проекта могут быть не видны.

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

                                              Самое читаемое