Привет! Эта статья на холиварную тему. Комментарии приветствуются, особенно если у вас есть интересный опыт, которым можете поделиться!

Проектирование REST API - это процесс создания дизайна методов обмена данными. Дизайн - это субъективное. У одних "так", у других "сяк". А кто прав? Иногда все, а иногда нет.

В этой статье Системные аналитики, Backend-разработчики и Архитекторы найдут спорные вопросы с проектов и собеседований, которые связаны с созданием контрактов REST API.

Вопросы взяла по итогам общения с системными аналитиками, которые недавно проходили собеседования, с моего открытого вебинара, и из комментариев к серии постов про REST API в моем канале, где я рассказываю про него и показываю, как использовать на практике.

-------------------------------------

Спорные вопросы:

  1. Можно ли использовать метод POST для получения данных?

  2. Можно ли сделать в проекте все методы POST?

  3. Можно ли в GET передавать тело запроса?

  4. Как правильно именовать эндпоинты - ед. число или мн. число (/user или /users)?

  5. Как правильно строить URL - нужно ли писать create/update в названии метода?

  6. Какой код ответа на метод POST: 200 или 201?

  7. Что вернуть в ответ, если получен пустой результат - пустой массив или 404?

-------------------------------------

REST API - что это и для чего?

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

Впервые был определен и описан Роем Филдингом в диссертации 2000-го года. Рой Филдинг - соавтор спецификаций HTTP и URI.

Главная цель REST API - облегчить передачу и управление информацией между разными системами: создавать, читать, изменять, удалять (CRUD). REST API использует для этого стандартные HTTP-запросы: GET, POST, PUT, DELETE и другие.

???? REST API - это архитектурный стиль проектирования взаимодействия приложений.
???? HTTP - протокол, на основе которого работает REST API.

Отличие REST API от других видов API, таких как SOAP API, ftp и RPC, заключается в том, что REST API не имеет жестких правил и структур, и может быть использован с любым языком программирования - Java, Python, C++ и другие.

Из-за того, что REST API не имеет жестких правил и структур, и возникают спорные вопросы, связанные с его проектированием.

Всё самое важное про REST API в одной картинке. Уже по ней у меня есть спорный вопрос: "Можно ли использовать метод POST для получения данных?".
Всё самое важное про REST API в одной картинке. Уже по ней у меня есть спорный вопрос: "Можно ли использовать метод POST для получения данных?".

1. Можно ли использовать метод POST для получения данных?

Да, можно.

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

1. Запросы на получение данных (списки объектов) с большим количеством фильтров

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

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

Пример:

Допустим, у нас есть API для получения списка книг из базы данных. Пользователь может фильтровать этот список по автору, жанру, году публикации, издательству, рейтингу и многим другим параметрам. Если пытаться передать все эти фильтры в URL, то он может выглядеть так:

Метод "Получение списка книг с применением фильтров":

GET https://test-system.com/api/books?author=Толстой&genre=роман&year=1877&publisher=Огонек&rating=5&...

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

Вместо этого, можно использовать метод POST для передачи всех этих параметров в теле запроса в формате JSON:

Метод "Создание поискового запроса на получение списка книг с применением фильтров":
POST https://test-system.com/api/books/search 

{
    "author": "Толстой",
    "genre": "роман",
    "year": 1877,
    "publisher": "Огонек",
    "rating": 5,
    ...
}

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

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

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

2. Асинхронные запросы на получение данных: комбинирование POST и GET

Асинхронные запросы на получение данных реализуются за счет комбинации методов POST и GET.

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

Реализуются асинхронные запросы последовательным использованием методов:

  • POST - используется для создания задачи на сервере. При получении такого запроса, сервер помещает задачу в очередь на обработку и возвращает идентификатор задачи.

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

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

1. Пользователь отправляет POST-запрос для формирования отчета по продажам. При этом в теле запроса передаются параметры, необходимые для формирования отчета, такие как диапазон дат, тип отчета или другие фильтры.

Метод размещения запроса на формирование отчета:

POST /api/reports/sales

{
    "startDate": "2023-01-01",
    "endDate": "2023-12-31"
}

После обработки этого запроса сервер создает задачу на формирование отчета и помещает ее в очередь.

Cервер возвращает идентификатор задачи. Этот уникальный идентификатор позволяет пользователю отслеживать статус формирования отчета.

{
    "taskId": "12345"
}

2. Через некоторое время пользователь может отправить GET-запрос, чтобы проверить, готов ли отчет или получить его, если он готов.

Метод получения информации по статусу формирования отчета или готового отчета:

GET /api/reports/sales/status/{taskId}

Если отчет еще формируется:

{
    "status": "progress"
}

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

Пример, если в ответ на отчет готова ссылка на его загрузку:

{
    "status": "completed",
    "reportUrl": "/api/reports/sales/download/12345"
}

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

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

1. Пользователь отправляет POST-запрос с деталями поиска (дата, пункт назначения и т.д.).

Метод размещения запроса на поиск авиабилетов:

POST /api/tickets/search

{
    "departure": "2023-10-30",
    "destination": "Rome",
    ...
}

Сервер возвращает идентификатор задачи - идентификатор созданного запроса на поиск.

{
    "searchId": "Уникальный идентификатор задачи"
}

2. Через некоторое время пользователь делает GET-запрос с этим идентификатором, чтобы узнать статус поиска.

Метод получения статуса или информации по результатам поиска, если он завершен:

GET /api/tickets/search/{searchId}
или
GET /api/tickets/search/status/{searchId}

Ответ (если поиск еще не завершен):

{
    "status": "progress"
}

Как только поиск завершен, пользователь получает список доступных рейсов в ответ на GET-запрос.

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

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

2. Можно ли сделать в проекте все методы POST?

Можно, но не рекомендуется :)

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

Почему так могут делать:

  • Универсальность: в теле запроса POST можно передавать большие объемы данных, что может быть удобно для сложных запросов, в том числе поисковых.

  • Развитие действующего API: потому что так уже сделаны все методы API и сейчас разработчики продолжают поддерживать дизайн API в том же стиле.

  • Другие причины: будет интересно почитать в комментариях.

Возможные проблемы такого подхода:

  1. Несоблюдение стандартов: Принципы REST подразумевают использование различных HTTP-методов для разных CRUD-операций (GET для получения, PUT для обновления и т.д.). Использование только POST может запутать разработчиков и усложнить интеграцию с вашей системой.

  2. Сложности кэширования: HTTP-кэширование обычно применяется к GET-запросам. Не критично.

  3. Отсутствие идемпотентности: Повторное выполнение одного и того же POST-запроса может привести к разным результатам. Не критично.

Примеры API-документации, где только POST-запросы:

В открытом доступе я знаю только про DaData. Хороший сервис, но почему-то все запросы реализованы через POST. Верю, что этому есть какое-то объяснение

API: организация по ИНН или ОГРН:

API: геокодирование (координаты по адресу):

3. Можно ли в GET передавать тело запроса?

Не стоит, оно будет проигнорировано или обрезано.

HTTP-спецификация не запрещает передачу тела запроса в методе GET, но этот подход считается нестандартным и может вызвать определенные проблемы и вопросы при разработке и использовании API.

Почему передача тела запроса в GET может показаться привлекательной? В некоторых сценариях может возникнуть потребность в передаче сложных данных для фильтрации или поиска, которые сложно или невозможно передать через URL в виде query-параметров.

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

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

4. Как правильно именовать эндпоинты - ед. число или мн. число (/user или /users)?

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

В именовании ресурсов в REST API есть разные подходы, и, честно говоря, нет строгого стандарта.

Однако на практике чаще всего предпочитают использовать единственное число для ресурсов. Но и множественное число также активно используется!

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

Примеры:

Единственное число:
GET /task/123 - получить информацию о задаче с ID 123.
POST /task - создать новую задачу.

Множественное число:
GET /tasks - получить список всех задач
GET /tasks/123 - получить информацию о задаче с ID 123.
POST /tasks - создать новую задачу.

Множественное число представляет собой коллекцию элементов, что соответствует идеологии REST для метода добавления нового элемента в коллекцию и чтения списка - /tasks читается легче, где у вас есть коллекция ресурсов и конкретные ресурсы внутри этой коллекции.

Рекомендации по множественному числу есть в гайдлайнах:

1) Microsoft API Guidelines: 7.4.1. POST ---> POST http://api.contoso.com/account1/servers

2) Google Cloud API Design Guide: Resource names ---> Example 1: A storage service has a collection of buckets, where each bucket has a collection of objects

При этом, если мы взглянем на Яндекс:

Пример с ед. числом:
https://yandex.ru/dev/rasp/doc/ru/reference/nearest-settlement


Пример с мн. числом:
https://yandex.ru/dev/market/partner-api/doc/ru/overview/express

Наглядный пример как в рамках одной огромной компании работали разные команды.

От чего зависит выбор? От опыта команды, и как комфортнее воспринимать методы при чтении.

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

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

Хотя я предпочитаю единственное число, для меня важнее договоренность с командой и поддержка единого подхода на протяжении всего проекта.

5. Как правильно строить URL - нужно ли писать create/update в названии метода?

Не рекомендуется так делать. Это считается плохим тоном.

В принципах RESTful дизайна, использование глаголов, таких как create или update, в URL считается плохой практикой. Идея REST заключается в
использовании стандартных HTTP-методов (GET, POST, PUT, DELETE и т. д.)
в сочетании с номинативными URL, представляющими ресурсы.

  • GET - получить,

  • POST - создать,

  • PUT - изменить или создать,

  • PATCH - изменить,

  • DELETE - удалить.

Типы методов и есть глаголы, дублирование глаголов create и update в URL избыточно. Не рекомендуется их дублировать в URL! Зачем? API и так прекрасно читается, если он спроектирован по стандартам RESTful и в нём не все POST!

Примеры:

Неправильно:

  • POST /products/create (использует глагол create)

  • PATCH /products/update/123 (использует глагол update)

Правильно:

  • POST /products (для создания нового продукта)

  • PATCH /products/123 (для обновления продукта с идентификатором 123)

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

Дополнительно по примерам формирования URL
Дополнительно по примерам формирования URL

6. Какой код ответа на метод POST: 200 или 201?

При правильном подходе к использованию метода POST для создания новых данных в БД рекомендуется на успешные операции отвечать HTTP-201 Created. Хотя HTTP-200 ОК тоже допустим, когда в результате выполнения запроса новые данные не создаются.

Выбор правильного кода ответа так же важен, как и структура запросов и ответов. Ответы HTTP имеют стандартизированные коды, которые передают определенный смысл клиентам API.

Метод POST обычно используется для создания нового ресурса - новых данных в БД. После успешного создания нового ресурса, наиболее подходящим кодом ответа является 201 Created. Он явно указывает на то, что запрос был успешно выполнен и в результате был создан новый ресурс.

200 OK также является допустимым кодом ответа для метода POST. Он обычно используется в ситуациях, когда POST-запрос не приводит к созданию нового ресурса, но успешно обработан. Такие ситуации могут включать в себя операции, которые вызывают некоторые действия на сервере или просто передают данные без их сохранения в базе данных.

Пример:

Метод для создания новых пользователей:

POST /users

{
    "name": "Иван",
    "email": "ivan@example.com"
}

Рекомендуемый ответ:

  • HTTP-код: 201 Created

  • Тело ответа: может включать информацию о новом пользователе или быть пустым.

При использовании метода POST для создания новых ресурсов в вашем RESTful API рекомендуется ответ с кодом 201 Created. Это не только соответствует стандартам HTTP, но и ясно передает намерение сервера клиенту или пользователю.

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

7. Что вернуть в ответ, если получен пустой результат - пустой массив или 404?

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

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

Вариант 1 - пустой массив:

Если клиент запрашивает список элементов, то наиболее логичным было бы вернуть пустой список (в формате JSON это будет пустой массив []), когда элементы отсутствуют. Это четко указывает на то, что запрос был корректным, и в текущий момент элементы отсутствуют.

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

Вариант 2 - ошибка с кодом HTTP-404 Not Found: Код 404 Not Found явно указывает на то, что искомый ресурс отсутствует. Однако, при запросе списка ресурсов, это может вызвать путаницу, потому что 404 чаще ассоциируется с ситуацией, когда сам endpoint не найден (Помните, когда на сайтах страница не найдена, то пишут 404 Not Found?).

В то время как для списка ресурсов лучше возвращать пустой массив, для конкретного ресурса (например, конкретный пользователь или статья) 404 Not Found подходит, если он не существует.

Примеры:

Метод получения списка пользователей:
GET /users
Рекомендуемый ответ, если пользователи не найдены:
HTTP-код:
200 OK
Body ответа: []

Метод получения информации о пользователе с id=123:
GET /users/123
Рекомендуемый ответ, если пользователь не найден:
HTTP-код:
404 Not Found
Body ответа: нет.

При разработке RESTful API я бы рекомендовала везде возвращать 200 и пустой массив в случае отсутствия результатов по запросу, а не код ошибки 404 Not Found. А для получения информации об одном конкретном объекте как раз 404 Not Found.

Можно делать в обоих случаях 200 и [], можно делать в обоих случаях 404 ошибку, но моя настоятельная рекомендация: внесите рекомендации по стандарту ответа на такие ситуации в корпоративный гайдлайн по дизайну REST API и проинформируйте о принятом решении по обработке такого исключения клиентов вашего API.

Заключение

В статье нет ни единого слова про НУЖНО. Здесь я рассказала про негласные стандарты отрасли IT и свой опыт проектирования и тестирования REST API разных проектов.

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

При развитии REST API придерживайтесь тех правил, которые уже ранее были приняты в проекте и поддерживайте их. Если эти правила не зафиксированы - разработайте корпоративный гайдлайн по дизайну REST API. Т.е. если в API уже все POST, не надо умничать и добавлять GET. Это будет выбиваться из общей концепции и вызывать вопросы.

При создании нового REST API сразу вводите корпоративный гайдлайн по дизайну REST API и в нём фиксируйте:

  • общие правила по дизайну методов:

    • типы методов;

    • дизайн URL эндпоинтов;

    • коды ответов;

    • реакция на типовые ошибки;

    • и другие.

  • обоснование принятых решений - почему команда приняла решение делать так, а не иначе.

Документация и гайдлайны в проекте наше всё! Особенно в тех вопросах, где дело касается дизайна (хоть и программного интерфейса) и опыта команды.

Хорошие дизайнеры UI/UX делают гайдлайны для приложений, чтобы можно было развивать их и пользователи интуитивно понимали и чувствовали, что приложение сделано в одном визуальном стиле. В разработке API это тоже применимо!

Что еще почитать и посмотреть по REST API

Хорошо подойдет для тех, кто только пытается разобраться что это и зачем.

YouTube:

База знаний GetAnalyst:

Книги:

P.S.

Какие еще спорные вопросы вы встречали по дизайну REST и какие решения по ним? Пишите в комментарии.

P.P.S.

Делитесь, когда у вас в компании принято использовать 400-е ошибки (ошибки клиента), а когда 500-е (ошибки сервера)?

Например, что вернете, если при создании нового города в справочнике обнаружили, что город с таким названием уже ранее был создан в БД (найден дубликат)?

Комментарии (113)


  1. LeshaRB
    27.10.2023 06:03

    Например, что вернете, если при создании нового города в справочнике обнаружили, что город с таким названием уже ранее был создан в БД (найден дубликат)?

    А вы справочники вручную заполняете?
    Мне когда надо были страны, я импортнул CSV
    Из данной репы https://github.com/stefangabos/world_countries
    Страны и города у нас не создаются каждый день

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


    1. katherine_a Автор
      27.10.2023 06:03
      +3

      Здравствуйте!

      Можно заменить пример на "Создать задачу, при условии, что запрещено создавать задачи с одинаковыми названиями".

      Интересно, как обрабатываются ошибки логики сервера. У кого 400-я, а у кого 500-я на такой тип ошибок идёт. Не обязательно 1 в 1, а похожие.


      1. lkoida
        27.10.2023 06:03
        +1

        По идее должен быть 409 Conflict


      1. mozg3000tm
        27.10.2023 06:03

        ...


    1. mozg3000tm
      27.10.2023 06:03

      что вернете, если при создании нового города в справочнике обнаружили, что город с таким названием уже ранее был создан в БД

      Я полагаю что ошибка должна быть 422. Ошибка валидации входных данных.

      422 Unprocessable Content
      The HyperText Transfer Protocol (HTTP) 422 Unprocessable Content response status code indicates that the server understands the content type of the request entity, and the syntax of the request entity is correct, but it was unable to process the contained instructions.

      Посмотрел 409.

      Код ошибки 409 относится к HTTP-кодам состояния и обычно указывает на "конфликт".

      ...

      Примеры ситуаций, когда может возникнуть такой код ошибки:

      • Попытка загрузить документ, который был изменен другим пользователем с момента вашей последней загрузки.

      • Два пользователя пытаются одновременно создать ресурс с одинаковым именем или идентификатором.

      Т.е. тут должно быть какое-то "состояние" и должен юыть какой то "конфликт" и какая-то "одновременность" чтобы конфликт возник. Если просто пытаешься вставить дубликат, то тут мне кажется ещё нет конфликта...


      1. masai
        27.10.2023 06:03

        Зависит от API.

        Если название — это часть имени ресурса (хотя именно для городов не очень подходит, так как есть много городов с одинаковым названием), то это скорее всего PUT и HTTP 409 Conflict подходит идеально.

        Если название — часть состояния ресурса, а в имени только идентификатор, то тут возможны варианты в зависимости от приложения. Логичнее всего вернуть HTTP 303 See Other, который для такой ситуации и предназначен. Если нужно добавить строгости и предполагать, что попытка добавления уже существующего ресурса — это явная ошибка, то тут подходят как HTTP 422 Unprocessable Content (так, например, делает GitHub), так и HTTP 409 Conflict, но оба с небольшой натяжкой.


      1. An70ni
        27.10.2023 06:03
        +1

        Меня, конечно, за такое заплюют, но иногда, для своих внутренних нужд я использую что-нибудь типа 302 found с перенаправлением на url найденного объекта


  1. rSedoy
    27.10.2023 06:03
    +1

    Не затронут момент, который часто встречаю у новичков, избыточный идентификатор родителя в url, пример /item/{item_id}/subitem/{subitem_id}, в большинстве случаев достаточно (а часто и необходимо) только /subitem/{subitem_id}. Конечно же бывают редкие исключения, когда этот идентификатор уникален только в подмножестве своего родителя.

    А про 500, только тогда, когда сервер не может сообщить клиенту почему произошла ошибка, точнее даже когда он сам не может это понять.

    что вернете, если при создании нового города в справочнике обнаружили, что город с таким названием уже ранее был создан в БД (найден дубликат)?

    400 или можно вообще 409
    500 тут возможна только когда бд выдала ошибку уровня "IntegrityError duplicate key value violates unique constraint", а разработчики не предусмотрели ее обработки, т.е. это их явная ошибка.


    1. katherine_a Автор
      27.10.2023 06:03

      Здравствуйте!

      Можете привести чуть более конкретный пример, пожалуйста?

      Идею поняла, но не сталкивалась много с подобными ошибками у новичков.


      1. rSedoy
        27.10.2023 06:03
        +1

        Пример: есть страны, есть города из этих стран, в подавляющем количестве решений, городу сделают уникальный id на всем множестве стран. Новички, разрабатывая api, сначала сделают для стран /country/1/, потом сделают получить все города страны /country/1/city/ и в итоге приходят к /country/1/city/1/


        1. mozg3000tm
          27.10.2023 06:03

          А какой эндпоинт должен быть если вы хотите получить список всех городов страны 1?


          1. yarkov
            27.10.2023 06:03
            +1

            /cities?countryId=1


            1. rSedoy
              27.10.2023 06:03

              ага, есть два варианта, и обычно эти оба два можно без проблем реализовать в одном проекте
              /country/1/city
              /city/?country=1


          1. rSedoy
            27.10.2023 06:03

            так вроде сразу написал про /country/1/city/ или это не то?


          1. masai
            27.10.2023 06:03
            +1

            Руководство по API от Microsoft рекомендует /countries/1/cities

            При этом города могут быть самостоятельным ресурсом: /cities/1234



    1. masai
      27.10.2023 06:03

      Конечно же бывают редкие исключения

      Кстати, не очень-то и редкие. Например, всё случаи, когда идентификатор задаётся клиентом. Например, GitHub API.


  1. 18741878
    27.10.2023 06:03
    +1

    Из недавно вышедших книг: https://www.labirint.ru/books/935221/


    1. katherine_a Автор
      27.10.2023 06:03

      Благодарю за рекомендацию!


  1. Skykharkov
    27.10.2023 06:03
    -1

    Использовал POST вместо GET в трех случаях:
    а) Относительно большое количество мелких параметров (а=1, b=2, c=3... z=N). Гарантированно попадаешь в длину URL, но удобнее передать объект в теле запроса.
    б) Заказчик получал странные рекомендации от своих "безопасников" касательно того что POST "безопаснее" чем GET по HTTPS. В таких случаях делал две копии endpoint для одного и того-же запроса. И POST и GET юзали одинаковые параметры в заголовках (bearer и так далее)...
    в) API для сторонних пользователей, которые "не асиливают" POST в коде на своей стороне и им проще писать "https://server.com?" + "a=1" + "&" + "b=2" + "&" + "c=3", чем морочится с параметрами.

    Вообще это наверное больше холивар. Наверное таки да. POST - логично когда запрос меняет данные в базе на сервере. Типа как DELETE. Конечно, запрос на удаление можно и GET'ом сделать. Но логика более прозрачна и понятна.


    1. abutorin
      27.10.2023 06:03
      +4

      Конечно, запрос на удаление можно и GET'ом сделать

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


      1. Skykharkov
        27.10.2023 06:03

        Так про то и разговор. Можно вот так:
        GET "https:\\server.com?action=delete&id=1"
        но конечно намного лучше
        DELETE "https:\\server.com" с параметром "id=1" в теле. Намного понятнее и легче в поддержке.
        Я встречал много "я-ж программистов" которые никак не могли осилить POST в javascript например. Для них приходилось дублировать запрос c GET и параметрами в query...


        1. nin-jin
          27.10.2023 06:03
          +1

          DELETE https://server.com/id=1


          1. Skykharkov
            27.10.2023 06:03
            +1

            А вот не всегда. Если "id" это не "id", а "id's"

            DELETE https://server.com/ids=1,2,3,4,5

            или

            DELETE https://server.com
            и в BODY
            ids = {1, 2, 3, 4, 5}

            в BODY таки более гибко получается.


            1. nin-jin
              27.10.2023 06:03
              +1

              По гибкости одинаково, зато в девтулзах сразу видно что именно удалялось.


              1. Skykharkov
                27.10.2023 06:03

                Совершенно с вами согласен. Тут уж от задачи плясать надо. В мелких базах, где не подразумевается "bulk delete" больших объемов - конечно проще "id" [FromRoute] брать. Намного нагляднее получается. Там, где удаляется что-то "большими пачками", все таки из BODY проще.


                1. michael_v89
                  27.10.2023 06:03

                  Когда удаляется несколько записей, это обращение к другому ресурсу.
                  DELETE /entities/1 это обращение к отдельной сущности, а DELETE /entities обращение к коллекции сущностей.


  1. olku
    27.10.2023 06:03

    Тело в GET есть в ElasticSearch API, например.

    Ещё помогает сделать дырку в политике CSRF для эндпойнта, если нет возможности политику переписать.


    1. Skykharkov
      27.10.2023 06:03
      +4

      Тело в GET может очень легко в форвардинге потеряться. Аж со свистом. Очень плохая практика кстати.


  1. propell-ant
    27.10.2023 06:03
    +3

    Про ошибки 400 vs 500 - принцип простой:
    ошибки 4xx означают, что если клиент изменит что-то в своем запросе, то он может быть получит результат. https://www.rfc-editor.org/rfc/rfc9110.html#name-client-error-4xx
    Ошибки 5xx означают, что от действий клиента ничего не зависит - где-то внутри сервиса при обработке запроса всё идет плохо. https://www.rfc-editor.org/rfc/rfc9110.html#name-server-error-5xx
    Однако на практике это означает необходимость ручного анализа возникающих на сервере ошибок и принятия решения по каждой, что выдавать: 400 или 500. Сами понимаете, на это подпишется не всякая команда разработки.

    Есть и приколы. Некоторые системы допускают более широкое толкование понятия "действий клиента". Например SaaS позволяющий изменять конфигурацию купленного сервиса. Бывает, что клиент так наизменяет, что сервис выпадает с внутренней ошибкой 500. И для того, чтобы донести до клиента информацию, что он сам может исправить ситуацию, приходится совершенно очевидную ошибку 500 (сервис подняться не может) преобразовывать в 400.


  1. igrishaev
    27.10.2023 06:03
    -1

    Хороший сервис, но почему-то все запросы реализованы через POST. Верю, что этому есть какое-то объяснение

    Банально проще. Метод везде один и тот же, диспатч происходит по аргументу из строки или по ключу JSON-а.


  1. gandjustas
    27.10.2023 06:03

    Теперь уже и API аналитики проектируют? Программисты не могут?

    Когда начнут классы создавать и за ООП холиварить?


    1. Pavel1114
      27.10.2023 06:03
      +1

      Начиная читать тоже подумал что сейчас начнётся какая то дичь. Но в итоге вполне адекватная статья про действительно неоднозначные моменты при проектировании rest api.


    1. RomanSeleznev
      27.10.2023 06:03
      +1

      Всё зависит от правил в конкретной компании. API могут проектировать разработчики, системные аналитики или архитекторы. У кого как заведено. Явных противопоказаний я не вижу.


  1. olezh
    27.10.2023 06:03
    -2

    Post /users для создания сущности плохая рекомендация. Выше пример с заменой гета на пост из за большого объёма фильтра, как думаете два одинаковых поинта сочетать?

    Post /user - никаких вопросов не возникнет


  1. Arm79
    27.10.2023 06:03

    Пришлось делать получение объектов через post /objects/get - сложные фильтры с массивами через тело передаю

    В итоге если остальные методы делать как rest - будет некрасиво и не единообразно

    Решил тогда всю api-шку сделать как rpc:

    Все методы post, глагол в конце url, все идентификаторы - как query параметры

    Ну и в этой парадигме в тему пришлось создание сразу нескольких объектов в одном запросе


    1. hVostt
      27.10.2023 06:03
      -1

      Ни один администратор спасибо не скажет за RPC. Сопровождение становится сложнее. Когда разработчик использует стандарты HTTP, обслуживать, решать проблемы и наблюдать за работой системы можно и без глубоких знаний деталей реализации. Нам досталась поделка от вендора аля RPC, это худшая система, которую нам доводилось сопровождать. Всё POST, все ответы, любые даже с ошибками, 200 OK, с какой-то трудноразбираемой фигнёй внутри. Поэтому абсолютно невозможно дать какие-то ответы на вопросы, типа масштаб бедствия, процент удачи/неудачи на тот или иной запрос, вообще ничего, а лезть и разбираться в кишки ответов на каждый случай (сотни сервисов) никто не желает, и нафиг это никому не упало.

      Не делайте RPC. А если уж делаете, придётся поработать руками и сделать всё то, чего нехватает для мониторинга, да это будет доп. работа, при чём много, но что поделаешь. Такова цена.


      1. Arm79
        27.10.2023 06:03

        Тут я не согласен. Возвращать 200 на любой чих можно и в REST. Это не косяк RPC, а кривые руки разработчика.

        ЗЫ А так вообще приятно видеть знакомые ники с sql ru


        1. hVostt
          27.10.2023 06:03

          Согласен, привет-привет! :)

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


  1. insighter
    27.10.2023 06:03

    Делитесь, когда у вас в компании принято использовать 400-е ошибки (ошибки клиента), а когда 500-е (ошибки сервера)?

    Если делается публичное АПИ, то при выборе статуса ошибки стоит учитывать такую семантику: запрос после получения 500-ой ошибки может ретраится пока не получит, что то более осмысленное, а на 400-х прекращать попытки.

    Ну и идемпотентность (ключи идемпотентности, как её проявление) стоило бы подробно рассмотреть.


  1. maxtorchel
    27.10.2023 06:03
    +1

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


  1. zubrbonasus
    27.10.2023 06:03
    -5

    Endpoint /user или /users, Что правильно?

    Ответ очевиден. Если хотим получить одного пользователя, используем /user, если получаем список пользователей /users. GET /users/:ID это по юниорски, так как не читаемо.

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

    Можно ли использовать всегда POST ?

    Например так удобно применять паттерн CQRS. GET запрос, POST команда. Например так более секьюрно: DELETE user/:ID может попробовать отправить любой член клуба юных хакеров потому что это стандартно.

    И напоследок: система может быть распределенной из множества микросервисов с шиной данных из например кафка и тогда POST запросом удобно и безопасно создавать обработчик запросов.

    Программирование тесно связана с математикой, а в математике все логично и доказательно. Точно также как и при написании RESTFUL.


    1. nin-jin
      27.10.2023 06:03
      -1

      Как там единственное число от слова goods?


      1. zubrbonasus
        27.10.2023 06:03

        Product


    1. mayorovp
      27.10.2023 06:03
      +3

      GET /users/:ID это по юниорски, так как не читаемо

      Что нечитаемого-то? Директория /users, в ней файл :ID.

      Вот если пользователи лежат в директории /user, а их список почему-то в файле /users - тогда-то и получается нечитаемо.

       Например так более секьюрно: DELETE user/:ID может попробовать отправить любой член клуба юных хакеров потому что это стандартно.

      Ну да, ну да, старое доброе security through obscurity… Главное - не забудьте убрать спеку OpenAPI из общего доступа :-)

      И напоследок: система может быть распределенной из множества микросервисов с шиной данных из например кафка и тогда POST запросом удобно и безопасно создавать обработчик запросов.

      Вот вообще связи не вижу.


    1. mozg3000tm
      27.10.2023 06:03

      Endpoint /user или /users, Что правильно?

      Ответ очевиден. Если хотим получить одного пользователя, используем /user, если получаем список пользователей /users. GET /users/:ID это по юниорски, так как не читаемо.

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


  1. sophist
    27.10.2023 06:03
    +1

    В примере с единственным числом у Яндекса nearest-settlement – это ведь, наверное, не элемент коллекции nearest-settlements, а результат вычисления. А есть ли какие-нибудь публичные примеры, когда единственное число используется именно для получения элемента коллекции?


  1. RuGrof
    27.10.2023 06:03
    +1

    А если смотреть на REST API, как на спеку, а HTTP как на траспортный протокол. И не иметь интимностей с особенностями кэширования и проксирования разных HTTP методов.

    Когда лучше не использовать GET - если ответ меняется чаще чем раз в день.

    PUT и POST для создания/обновления записи в базе, нужно использовать если пользователи вам платят за вызовы API.


  1. mozg3000tm
    27.10.2023 06:03
    +1

    В одном сборнике вопросов к собеседованиям прочитал такой вопрос:

    Зачем придумали все эти GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD,... (и ещё куча)

    и ответ был очень простой:

    чтобы тупо не создавать кучу эндпоинтов с вариацией на тему /users и оперецией которую нужно сдедать.


    1. michael_v89
      27.10.2023 06:03

      А почему не надо создавать кучу эндпойнтов для операций, которые нужно сделать? Это же разные операции, значит должны быть и разные эндпойнты.


      1. mozg3000tm
        27.10.2023 06:03

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


        1. michael_v89
          27.10.2023 06:03
          +1

          Так вы не составляете из методов GET, POST, PUT, PATCH слова, вы используете только один метод за один запрос. Где же тут сходство?

          GET, POST, PUT, PATCH это точно такие же уникальные слова. Вы предлагаете использовать язык из 10 слов для описания всех действий. Это не то что сложно, а в некоторых случаях невозможно, и придется переносить часть информации в тело запроса.

          И да, вы не ответили на вопрос.


          1. mozg3000tm
            27.10.2023 06:03

            да, вы не ответили на вопрос

            Это прямо зависит от того находите ли вы сходство.

            Где же тут сходство?

            Ну тут метафора.

            Get, Post - предлоги,

            /users - корень

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

            Увидя иероглиф, вы просто видите закорючку.


            1. michael_v89
              27.10.2023 06:03

              Это прямо зависит от того находите ли вы сходство.

              Нет, это зависит от того, дали вы прямой ответ или нет. Метафора это не прямой ответ.

              Сочетая корень с различными предлогами вы получаете разные слова

              Ну вот я и предлагаю сочетать корень с большим количеством предлогов, чтобы получить больше слов.

              вы можете набросать слова которые вы уже будете понимать.

              У меня есть API, в нем есть операция "PATCH /article/1". Вы можете сказать, что она делает - это публикация статьи или редактирование текста? Если нет, значит вы это не понимаете. Значит ваш подход не работает.

              А что делает "POST /article/publish/1" - это публикация статьи или редактирование текста? Если вы понимаете, какой из этих двух вариантов правильный, значит мой подход работает.

              Увидя иероглиф, вы просто видите закорючку.

              Почему набор букв "POST" это предлог, а набор букв "PUBLISH" это иероглиф? Слово publish точно так же является глаголом, как и post.


              1. mozg3000tm
                27.10.2023 06:03

                Вы можете сказать, что она делает

                Тут как с языком, зависит от того говорим ли мы с вами на одном.

                Patch частичное обновление ресурса,

                /articles - ресурс

                /1 - идентификатор

                Т.е. частичное обновление ресурса с идентификатором 1. Если обновляется только свойство is_published. То я могу понять, что это публикация

                "PUBLISH" это иероглиф

                Вам /article/publish/1 кажется понятным лишь потому что вы его придумали. Вы удивитесь сколькими способами можно не понять друг друга. Например, поскольку английский возможно не ваш родной, то вы можете необоснованно полагать что англоговорящий подразумевает под publish публикацию. Но это может быть совсем не так. И англоговорящий может мыслить по другому. Я уж не говорю как может мыслить совсем не говорящий на инглише.

                Вообще, эти слова get, post идут из веба. Страницу /users получают get /users, форму на этой странице отправляют post /users. По вашей логике, практически для каждого поля формы можно отдельный метод делать. В т.ч. для чекбокса is_publish тоже отдельный экшен. Иероглифы...


                1. michael_v89
                  27.10.2023 06:03

                  Вам /article/publish/1 кажется понятным лишь потому что вы его придумали.

                  Нет, я как раз объясняю, что "PATCH /article/1" нисколько не понятнее "POST /article/publish/1", как вы пытаетесь доказать. Это точно такой же придуманный язык.

                  По вашей логике, практически для каждого поля формы можно отдельный метод делать.

                  Нет, по моей логике отдельный метод надо делать для каждого бизнес-действия. Отклонение статьи модератором имеет поле формы "Причина отклонения", которое не является свойством статьи, а связано с действием. При этом во время выполнения действия изменяется состояние статьи.


  1. sshikov
    27.10.2023 06:03
    +4

    Мне кажется, что при описании GET vs POST не учтены некоторые нюансы кеширования. Как правило считается, что GET запрос с одним набором параметров возвращает одно и тоже - и его результат можно кешировать. А вот POST по умолчанию нельзя. Так что можно конечно применять POST вместо GET - пока не столкнетесь с тем, что где-то посредине у вас кеширующий прокси.


  1. michael_v89
    27.10.2023 06:03
    +2

    Мне кажется, любой, кто делал бэкенд с достаточно сложной бизнес-логикой, быстро приходит к выводу, что REST для бизнес-логики не подходит. Какой HTTP-метод надо использовать для действия "Опубликовать статью" - PATCH, POST, PUT? А на бэкенде сравнивать статусы, какой был и какой стал? Зачем делать какие-то механизмы для определения нужного действия, если можно его сразу указать?

    Иногда еще советуют добавить фиктивную сущность "Запрос на публикацию статьи" и создавать ее с POST. Зачем она мне на бэкенде, если в бизнес-требованиях ее нет?


    1. nin-jin
      27.10.2023 06:03
      -1

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


      1. michael_v89
        27.10.2023 06:03
        +1

        "что видишь, то и меняешь"

        Я вижу статью, у меня нет никакого "запроса на публикацию". Зачем мне менять то, чего нет?

        а не 100500 методов типа

        Учитывая, что они есть в бизнес-требованиях, и поэтому всё равно должны быть реализованы, я не вижу ни одной причины, почему в API их надо прятать. Мне в любом случае надо вызвать один из них. Как я должен определять, какой именно? Проверять все возможные комбинации всех полей "было/стало"?

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

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


        1. nin-jin
          27.10.2023 06:03
          -1

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


          1. michael_v89
            27.10.2023 06:03
            +2

            Вы мыслите "бизнес-действиями"

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

            Вот если будете мыслить "бизнес-состояниями", то заметите, как всё сильно упрощается

            Я на это ответил в предыдущем комментарии. Если мыслить бизнес-состояниями, для меня все усложняется. Да, я проверял. Непонятно, почему вы это игнорируете.

            один раз прописали гуарды и эффекты на целевые состояния, и уверены, что какими бы путями вы к ним ни пришли, инварианты будут выполнены

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

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

            и "а что если при скрытии модератором сервер будет прибит и письмо отправлено не будет?"

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

            Как нам его переотправить в вашем варианте? А никак, у сущности уже новый статус. Публиковать статью, которую публиковать нельзя, чтобы изменился статус, а потом скрывать? Делать еще одну фиктивную сущность "Запрос на скрытие модератором"? Вместо одной сущности с 10 действиями у нас стало 10 сущностей с одним действием. Не вижу тут никакого упрощения API или кода на бэкенде.

            И это мы еще не начинали про загрузку изображений к товару, где валидировать надо данные файла, а в сущности ProductImage есть только свойство url. С RPC это делается тривиально, а с REST начинаются танцы с бубнами.


            1. nin-jin
              27.10.2023 06:03

              C RPC модератор либо видит, что статься скрылась и больше не нажимает кнопку, либо видит ошибку скрытия, сколько бы раз он на кнопку ни нажал и гневно пишет вам критикал баг. Оба варианта плохи с точки зрения бизнеса. Правильно - скрыть статью, а письмо отослать, когда сервис рассылки писем снова начнёт работать. С RPC вы такое сможете реализовать лишь через костыльную материализацию событий в состояние через какую-нибудь Кафку, что резко повышает сложность решения.

              Судя по вашим вопросам, с состояниями вы работать не умеете. Так что попробуйте ещё раз. На этот раз более вдумчиво, учитывая весь ворох проблем событийных архитектур, которых в реактивных нет как класса. Вам нужно отразить в состоянии всё, что важно для бизнеса. Для этого представьте, что время остановилось, и вам нужно по текущему состоянию мира понять, надо ли отсылать письмо. Например, это может быть флаг "уведомление о скрытии отослано". В качестве бесплатного бонуса вы сможете легко группировать уведомления, дабы не заваливать почту пользователей спамом, и даже не слать никаких уведомлений, если модератор скрыл статью по ошибке и тут же отменил скрытие.


              1. michael_v89
                27.10.2023 06:03

                C RPC модератор либо видит, что статься скрылась и больше не нажимает кнопку

                Он видит сообщение "Что-то пошло не так, попробуйте еще раз", и пробует еще раз. Также в зависимости от реализации он может видеть, что письмо не отправилось.

                либо видит ошибку скрытия, сколько бы раз он на кнопку ни нажал

                Я не понимаю, откуда вы это взяли, по условиям примера ошибка происходит один раз.

                и даже не слать никаких уведомлений, если модератор скрыл статью по ошибке и тут же отменил скрытие

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

                когда сервис рассылки писем снова начнёт работать

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

                Оба варианта плохи с точки зрения бизнеса.

                С RPC вы такое сможете реализовать лишь через костыльную материализацию событий в состояние через какую-нибудь Кафку

                Что лучше для бизнеса, решать бизнесу, а не вам. У бизнеса нет требования делать переотправку при сбое скрытия статьи автоматически, ни с Кафкой, ни с "Запросом на скрытие". Он сказал, что ему удобнее простая кнопка с сообщением об ошибке. Он не хочет платить за автоматическое повторение действия для каждой кнопки.

                Судя по вашим вопросам, с состояниями вы работать не умеете. Так что попробуйте ещё раз. На этот раз более вдумчиво

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

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

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

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

                Например, это может быть флаг "уведомление о скрытии отослано"

                Опять какие-то непонятные данные, которых нет в бизнес-требованиях. А как уведомление о скрытии отправить при повторном скрытии? Значит надо где-то сбрасывать флаг, наверно при публикации. Вы же обещали, что это будет проще, а пока получается только сложнее.

                В качестве бесплатного бонуса вы сможете легко группировать уведомления

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


    1. masai
      27.10.2023 06:03

      В гугловских рекомендациях нестандартные глаголы предлагается добавлять через двоеточие в конце имени ресурса. Это неплохо работает, если нестандартного немного. Но если это действительно проблема, то стоит задуматься, а так ли уж нужен REST. Может, скажем, gRPC (или GraphQL) был бы лучше.


    1. alxsad
      27.10.2023 06:03
      -1

      В вашем случае тогда POST /articles. Ведь это новая сущность в БД, не понимаю зачем в данном случае что-то сравнить.


      1. michael_v89
        27.10.2023 06:03
        +1

        Почему новая? Она уже есть в черновике, у нее есть текст, заголовок и id. Просто она пока не отображается в списке опубликованных. Кнопка "Опубликовать" меняет поле "Статус" с "В черновике" на "Опубликована".


        1. alxsad
          27.10.2023 06:03

          Значит не правильно понял. Тогда такой вариант должен подойти

          POST /articles/{id}/states

          {"type":"published"}

          {"type":"declined", "reason":"text from moderator"}

          и сохранять эти мутации стейтов, которые будут менять и стейт поста


          1. michael_v89
            27.10.2023 06:03

            У article нет состояния "Declined", это то же самое, что и неопубликованная, то есть в черновике. Модератор скрывает статью из опубликованных и пишет почему скрыл.

            Также у article нет свойства reason. То есть это в любом случае не соответствует REST, так как не является representational state сущности article.

            Вот если бы при запросе "GET /articles/{id}" среди полей article возвращалось поле states, которое является массивом, то тогда так можно было бы делать. Но тогда непонятно, какой должен быть тип у элемента этого массива. Он будет содержать все возможные параметры всех действий со статьей аналогично reason.


            1. alxsad
              27.10.2023 06:03

              С помощью POST /articles/{id}/states мы создаем условно "action log" сущность, частью которой легко может являться поле reason. Тут полное соответствие REST. Например, когда мы сделаем такой запрос


              POST /articles/{id}/states

              {"type":"declined", "reason":"text from moderator"}

              на бэкенде мы проставим посту статус draft.

              И на GET /articles/{id} необязательно возвращать массив states. Все зависит от случая.


              1. michael_v89
                27.10.2023 06:03

                мы создаем условно "action log" сущность, частью которой легко может являться поле reason

                Я именно про это и написал предыдущем комментарии.
                У вас есть одно действие с параметром reason, другое с параметрами count и timeout, третье с параметром email, и еще штук 10 разных действий. Вы все эти параметры будете добавлять в сущность ActionLog? Зачем, если есть способ проще?

                И на GET /articles/{id} необязательно возвращать массив states.

                Если вы хотите придерживаться Representational State Transfer, то обязательно. Если не хотите, тогда непонятно, почему нельзя просто сделать "POST /articles/{id}/decline".


                1. alxsad
                  27.10.2023 06:03

                  и еще штук 10 разных действий

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

                  Если вы хотите придерживаться Representational State Transfer, то обязательно. Если не хотите, тогда непонятно, почему нельзя просто сделать "POST /articles/{id}/decline".

                  Мы создаем подресурс через POST /articles/{id}/states, соответсвенно и получаем весь список с помощью GET /articles/{id}/states.


                1. alxsad
                  27.10.2023 06:03

                  Из того же restfulapi.net.

                  1.2. Collection and Sub-collection Resources

                  resource may contain sub-collection resources also.

                  For example, sub-collection resource “accounts” of a particular “customer” can be identified using the URN “/customers/{customerId}/accounts” (in a banking domain).

                  Similarly, a singleton resource “account” inside the sub-collection resource “accounts” can be identified as follows: “/customers/{customerId}/accounts/{accountId}“.


                  1. michael_v89
                    27.10.2023 06:03

                    которое можно решить введением поля metadata

                    Можно. Но вы не ответили на вопрос "зачем". С RPC ничего решать не надо.

                    думаю, это искусственное предположение

                    Это предположение из опыта. Конечно там были не статьи, а другие предметные области.

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

                    Ну так я ж привел пример, спроектируйте грамотно. Пока что у вас получается запись action_log "Пользователь опубликовал статью" со свойством reason, которое там не нужно.

                    соответсвенно и получаем весь список с помощью GET /articles/{id}/states

                    Я про это и написал. Если этой коллекции нет в родительском ресурсе, то это не REST.

                    A resource may contain sub-collection resources also.

                    А где там сказано, что в родительском ресурсе customer может не быть свойства accounts?

                    https://restfulapi.net/rest-api-design-tutorial-with-example/
                    "Single Device Resource
                    Opposite to collection URI, a single resource URI includes complete information about a particular device. It also includes a list of links to sub-resources and other supported operations."

                    Список полей там может быть меньше, чем в ссылке на конкретный под-ресурс, но коллекция должна быть. Ну и вопрос собственно остается в силе - зачем ее туда добавлять, если проще не добавлять и сделать RPC?


                    1. alxsad
                      27.10.2023 06:03

                      Насколько я понимаю, в вашем примере links это немного другое, в тексте говориться о такой штуки как HATEOAS, где в состояние ресурса еще включат секцию links с возможным набором действий с этим ресурсом. В любом случае, наш спор превращается в типичный "этот язык круче, нет этот". На вопрос "зачем" отвечу что вы вольны делать как угодно, удобнее RPC подход, окей, но есть еще и такой подход как REST и мой поинт в том что любое апи можно написать используя данный архитектурный подход. Разные подходы не противоречат друг другу, всегда есть trade-off.


                      1. michael_v89
                        27.10.2023 06:03

                        Да нет никакого трейд-оффа) С RPC легко сделать то, что сложно с REST, но нет ничего, что легко сделать с REST, но сложно с RPC. RPC более универсальный подход.

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


  1. RomanSeleznev
    27.10.2023 06:03

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

    1. Есть мнение, что в случае, когда клиент обеспечивает идемпотентность (сам генерирует ID создаваемого ресурса и его передаёт в сервис через параметр или HTTP-заголовок), то лучше использовать PUT, а не POST.

    2. Код 404 (Not Found) может возвращаться намеренно, когда на стороне сервиса определяется, что клиент не имеет доступа и надо в целях безопасности скрыть сам факт существования сервиса/ресурса.


    1. Skykharkov
      27.10.2023 06:03

      Ну, за 404 особо не спрячешься. Если вас сканируют и перебирают что-то типа https://server.com/../../../../../ На базовом уровне конечно оно да. Но тут комплексный подход нужен. Я делал так:
      https://server.com/api/{magic_number}/v1
      Где magic_number это что-то типа одноразового time based токена, который генерируется на клиенте и сервере по одним и тем-же правилам (ну как google auth, например) для каждого запроса. Единственный минус - нужна довольно точная синхронизация времени на клиенте и сервере. Решалось использованием NTP серверов. Конечно учитывается "лаг" по времени и например токен, сгенерированный на клиенте, валиден пять секунд со времени генерации. Если нет - возвращаем 404. Как показала практика - работает. Сканировать перестают.
      Конечно, это не для публичного, статического, API. Для "статического" API, разумеется статические API ключи, но ключи "утекают". Делать два запроса для каждого вызова, чтобы получить токен, а потом использовать его в следующем запросе, ну это-ж оверхеад по запросам в два раза...
      Было бы интересно почитать о плюсах и минусах такого вот подхода.


    1. masai
      27.10.2023 06:03

      По-моему, 404 для сокрытия — это очень специфический случай. Если клиент не аутентифицирован, то нужно вообще на любой запрос возвращать 401. Если аутентифицирован, но мы хотим скрыть остальные ресурсы, то можно возвращать 403 для всего кроме разрешённых ресурсов. А 404 уже использовать, если разрешённый ресурс не найден.


    1. alxsad
      27.10.2023 06:03

      1. Не важно на какой стороне генерируется id, для создания ресурсов используется неидемпотентный POST. Идемпотентный PUT используется для изменения состояния ресурса, где нужно засылать на сервер полное состояние, потому что оно должно перезаписаться, в отличие от PATCH.

      2. Если у клиента нет доступа к какому-то ресурсу, то логичнее отдавать 403 Forbidden. 404 Not Found говорит клиент о том что такого ресурса не существует.


      1. RomanSeleznev
        27.10.2023 06:03

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


      1. mayorovp
        27.10.2023 06:03
        -1

        Как раз-таки важно! Если id генерируется на клиенте, то ничего не мешает использовать идемпотентный PUT и для создания тоже.

        А вот для обновления лучше всё-таки использовать PATCH, поскольку у него лучше с обратной совместимостью, а также возникает меньше гонок.


        1. alxsad
          27.10.2023 06:03

          Все так, ничего не мешает использовать PUT вместо POST, но зачем? У данного подхода есть и минусы. Например, придется в коде ветвить логику когда запись новая и когда уже существует. Еще может поменяться логика генерации идентификаторов, в случае с POST легко меняется на бэкенде.

          PATCH используется для частичного изменения ресурса, он не обнуляет поля сущности, которые отсутсвуют в запросе в отличие от PUT. Из restfulapi.net:

          "The PATCH method is not a replacement for the POST or PUT methods. It applies a delta (diff) rather than replacing the entire resource."

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


          1. mayorovp
            27.10.2023 06:03
            -1

            Все так, ничего не мешает использовать PUT вместо POST, но зачем?

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

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

            А у PATCH никакой проблемы и нет, она есть у PUT.


            1. alxsad
              27.10.2023 06:03

              А у PATCH никакой проблемы и нет, она есть у PUT.

              Ни разу не встречал проблем и с PUT.


              1. nin-jin
                27.10.2023 06:03

                Попробуйте поредактировать поля через put несколькими пользователями одновременно.


              1. mayorovp
                27.10.2023 06:03

                Я уже перечислял эти проблемы, из две.

                Во-первых, одновременное редактирование.

                Во-вторых, обратная совместимость API. Что делать, если у ресурса появились дополнительные атрибуты, про которые клиент не знает, а потому не может передать? Семантика PUT требует, чтобы их отсутствие воспринималось либо как ошибка валидации, либо как установка в значение по умолчанию.

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

                Если вы ни разу не встречали этой проблемы - значит, либо вы никогда не сопровождали готовый продукт, либо у вас не было мобильных клиентов. Особенно "весело" работать с PUT на айфонах, там запрещено блокировать работу устаревших приложений.


  1. michael_v89
    27.10.2023 06:03

    Вот реализация RPC на примере API для публикации статей. 110 строк вместе с контроллером. Попробуйте реализовать это поведение в виде REST.

    PHP
    class Article {
      public int $id;
      public string $title;
      public string $text;
      public StatusEnum $status;
    }
    
    class ArticleService {
      public function save(SaveInput $saveInput, Article $article): Either {
        $article->title = $saveInput->title;
        $article->text = $saveInput->text;
        
        if ($article->status === StatusEnum::Published) {
          $errors = $this->validateForPublish($article);
          if (!empty($errors)) {
            return new Either(false, $errors);
          }
          // нет записи в лог, статья уже опубликована
        }
        
        $this->entityManager->save($article);
        $this->entityManager->flush();
        
        return new Either(true, []);
      }
      
      public function publish(Article $article, User $user): Either {
        $errors = $this->validateForPublish($article);
        if (!empty($errors)) {
          return new Either(false, $errors);
        }
        
        $article->status = StatusEnum::Published;
        $this->entityManager->save($article);
        
        $message = 'Статья опубликована';
        $this->entityManager->save(new ArticleLog($message, $user));
        
        $this->entityManager->flush();
        
        return new Either(true, []);
      }
    
      public function decline(DeclineInput $declineInput, Article $article, User $moderator): void {
        $article->status = StatusEnum::Draft;
        $this->entityManager->save($article);
        
        $message = 'Статья отклонена модератором по причине: ' . $declineInput->reason;
        $this->entityManager->save(new ArticleLog($message, $moderator));
        
        $this->entityManager->flush();
        
        $this->mailer->sendDeclineEmailToAuthor($article, $declineInput->reason);
      }
    
      private function validateForPublish(Article $article): string[] {
        $errors = [];
        if (strlen($article->title) === 0) {
          $errors['title'] = 'Для публикации заголовок должен быть указан';
        }
        if (strlen($article->text) === 0) {
          $errors['text'] = 'Для публикации текст должен быть указан';
        }
        
        return $errors;
      }
    }
    
    class SaveInput {
      public string $title;
      public string $text;
    }
    
    class DeclineInput {
      #[Assert\Required(message: 'Укажите причину отклонения')]
      public string $reason;
    }
    
    class ArticleController {
      #[Route('/article/<id>/save')]
      public function save(Request $request) {
        $article = $this->findEntity($request->query->get('id'));
        $saveInput = new SaveInput($request->body->get('title'), $request->body->get('text'));
        
        $result = $this->articleService->save($saveInput, $article);
        
        return new JsonResponse(empty($result->getErrors()) ? 200 : 400, $result->getErrors());
      }
      
      #[Route('/article/<id>/publish')]
      public function publish(Request $request, #[CurrentUser] User $author) {
        $article = $this->findEntity($request->query->get('id'));
        
        $result = $this->articleService->publish($article, $author);
        
        return new JsonResponse(empty($result->getErrors()) ? 200 : 400, $result->getErrors());
      }
        
      #[Route('/article/<id>/decline')]
      public function decline(Request $request, #[CurrentUser] User $moderator) {
        $article = $this->findEntity($request->query->get('id'));
        $declineInput = new DeclineInput($request->body->get('reason'));
        $errors = $this->validator->validate($declineInput);
        if (!empty($errors)) {
          return new JsonResponse(400, $errors);
        }
        
        $this->articleService->decline($declineInput, $article, $moderator);
        
        return new JsonResponse(200);
      }
    }


    1. mozg3000tm
      27.10.2023 06:03

      Вы уверены что это RPC?

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

      Т.е. вы с одного компа передаёте объект класса на другой комп и там вызываете какой-то метод у ещё какого то класса.

      А у вас обычный http контроллер.


      1. michael_v89
        27.10.2023 06:03

        https://aws.amazon.com/compare/the-difference-between-rpc-and-rest/

        RPC
        POST /addProduct
        POST /getProduct
        POST /updateProductPrice
        
        REST
        POST /products
        GET /products/123
        PUT /products/123

        При этом подразумевается, что в REST поля входных данных могут быть только полями ресурса-сущности.


        1. ASD2003ru
          27.10.2023 06:03

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

          Еще вариант вообще 1 эндпоинт и имя функции в теле :)

          Но если браузер не подразумевается то gRPC или SOAP норм.


          1. michael_v89
            27.10.2023 06:03

            Это цитата со страницы, можно делать и GET если надо. Только такое кеширование часто создает больше проблем чем решает. Мы обновили цену, запрашиваем товар, а там цена не изменилась, потому что данные закешировались. Лучше кешировать кодом на клиенте и вообще не отправлять HTTP-запрос пока данные считаются актуальными.


    1. hVostt
      27.10.2023 06:03
      +1

      POST /articles -- создать статью
      PUT /articles/{id} -- обновить статью (ваш save)
      PUT /articles/{id}/state -- обновить состояние статьи (publish/declain/etc. внутри)
      

      REST это не вызов функции, это запрос-ответ при взаимодействии с ресурсом

      путь в REST -- это ресурс, а не имя функции

      поэтому, объявляете не действие, а ресурс, глагол при этом является действием над ресурсом

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


      1. michael_v89
        27.10.2023 06:03

        путь в REST -- это ресурс, а не имя функции

        Ага, только кроме пути надо передавать еще и данные, которые в REST должны быть подмножеством полей ресурса.

        поэтому, объявляете не действие, а ресурс

        Не нужно давать советы, покажите код) Этот пример про бэкенд, а не про то, как выглядит URL.


        1. hVostt
          27.10.2023 06:03

          На пыхе не умею, сорри.

          Кроме того, меня смущает #[Route('/article/<id>/save')], я не понимаю какой глагол здесь принимается, или пофиг, хоть DELETE? :)


          1. michael_v89
            27.10.2023 06:03

            Напишите на любом. Действия на изменение, значит POST.


            1. hVostt
              27.10.2023 06:03

              Грубо, как-то так. Роут у меня только один, не надо манки-кодить "/api/articles..." для каждого метода. Невозможно сделать PUT-запрос для метода, который принимает POST. Все входящие данные валидируются, возвращаются правильные ответы.

              [Route("api/articles")]
              public class ArticlesController : ApiController
              {
                private IArticlesService _articlesService;
              
                public ArticlesController(IArticlesService articlesService)
                {
                  _articlesService = articlesService;
                }
              
                [HttpGet("{id}")]
                public async Task<IActionResult> GetDetails(long id, CancellationToken ct)
                {
                  var article = await _articlesService.FindByIdAsync(id, ct);
                  if(article is null) return NotFound("Article not found");
                  var response = article.AdaptTo<ArticleDetailsModel>(article);
                  return Ok(response);
                }
              
                [HttpPost]
                public async Task<IActionResult> Create(ArticleCreateRequest request, CancellationToken ct)
                {
                  var result = await _articlesService.CreateAsync(request.AdaptTo<ArticleCreateData>(), ct);
                  if(!result) MapError(result); // транслируется в 400,404,422, зависит от типа ошибки
                  var response = new ArticeCreateResponse(result.Id);
                  return Created(response);
                }
              
                [HttpPut("{id}")]
                public async Task<IActionResult> Update(long id, ArticleUpdateRequest request, CancellationToken ct)
                {
                  var result = await _articlesService.UpdateAsync(id, request.AdaptTo<ArticleUpdateData>(), ct);
                  return result ? Ok() : MapError(result);
                }
              
                [HttpPut("{id}/state")]
                public async Task<IActionResult> ChangeState(long id, ArticleChangeStateRequest request, CancellationToken ct)
                {
                  var result = await _articlesService.ChangeStateAsync(id, request.AdaptTo<ArticleChangeStateData>(), ct);
                  return result ? Ok() : MapError(result);
                }
              }
              


              1. michael_v89
                27.10.2023 06:03

                Так вы код сервиса тоже приведите, непонятно, что обертки должны доказывать. Create и GetDetails не нужны, нужны 3 действия - сохранение текста, публикация, отклонение модератором.

                На всякий случай, enum ArticleState содержит 2 значения - "В черновике" и "Опубликована". Значения "Отклонена" там нет, при отклонении статья переводится в состояние "В черновике".


                1. hVostt
                  27.10.2023 06:03
                  -1

                  Обёртки показывают принцип. Состояние статьи это ресурс. Никакого смысла показывать код сервиса нет, для демонстрации принципа. Этого достаточно. Присылаете новое состояние, где ArticleState должен иметь соответствующее количество значений, а не как у вас. Т.е. нужен статус Declained, иначе непонятен результат, состояние должно чётко отражать результат действия, поэтому с вашим решением не могу согласиться.


                  1. michael_v89
                    27.10.2023 06:03
                    -1

                    А я говорю про сложность реализации бэкенда при следовании этому принципу. В реализации ChangeStateAsync и будет основная сложность.

                    Т.е. нужен статус Declained

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

                    состояние должно чётко отражать результат действия

                    Оно у меня и отражает. Есть бизнес требование "При отклонении модератором переводить статью в черновики и писать причину в историю действий". Мой код так и работает.


                    1. michael_v89
                      27.10.2023 06:03

                      Видимо кто-то не согласен, объясню подробнее.

                      Для действия "Отклонение модератором" у нас есть параметр "Причина отклонения", поэтому в ArticleChangeStateData должно быть не только свойство state, а еще и reason. Если у нас 10 действий со статьей, и в каждом по два параметра аналогично reason, то ArticleChangeStateData превратится в God-object на 20 полей. Причем это не будет соответствовать принципам REST, в которых подразумевается, что для PATCH-запроса должны быть указаны поля ресурса, которые мы хотим изменить, а в ресурсе Article этих полей нет. И это сразу становится понятно, если попытаться это реализовать.


                    1. hVostt
                      27.10.2023 06:03
                      -1

                      Нет никаких проблем. Модератор переводит статус в Чёрновик. Визуально это может быть кнопкой Отклонить. Но на бекенд передаётся установка статуса Черновик. С RPC тоже нет проблем, это другой подход. Этот подход называется "Большой клубок чёрной магии" :) Т.е. вы прям вызываете метод "Отклонить", он что-то делает и совершенно неизвестно к чему приводит с точки зрения клиента. По REST-у, намерения максимально прозрачные, и глядя на запрос-ответ в логах, даже человек, совершенно не разбирающийся в недрах логики, сможет сказать что к чему. Это очень удобно для клиента, для сопровождения, администрирования и девопс. Используя подход RPC, нужно прикладывать тонну документации, или звать разработчика на разбор любых инцидентов.

                      Мне нравится RPC в подходе backend-backend, например, в gRPC. Но для взаимодействия frontend-backend, практика показывает, что подход REST значительно лучше. Это просто по опыту. Тут материала хватит на несколько статей.

                      По факту развития и сопровождения множества систем, и весьма крупных, приносящих большие прибыли компании, сопровождать RPC-style проекты сложнее и дороже. Ваш опыт может отличаться, разумеется:)

                      Но если оставить в стороне обсуждение RPC vs REST, я скажу, что надо выбирать либо одно, либо другое. Разработчики же часто просто не понимают разницы и на выходе получается Франкенштейн, наполовину RPC-шный, наполовину REST-овый, и это -- хуже некуда. То лапы ломит, то хвост отваливается.

                      Меня правда беспокоит, что действительно очень многие разработчики совершенно не понимают REST, думая что это просто использование GET/POST/PUT.. и какие-то там соглашения к путям запросов. Огромная печаль просто.


                      1. michael_v89
                        27.10.2023 06:03

                        Но на бекенд передаётся установка статуса Черновик

                        Передавать нужно еще и причину отклонения. Как это сделать и задокументировать, если такого свойства нет в Article?
                        Как это отличить от скрытия в черновик пользователем? Нам не надо, чтобы пользователь сам мог установить причину отклонения. И не надо отправлять email, если пользователь скрыл в черновик сам.

                        Т.е. вы прям вызываете метод "Отклонить", он что-то делает и совершенно неизвестно к чему приводит с точки зрения клиента. По REST-у, намерения максимально прозрачные, и глядя на запрос-ответ в логах

                        POST /article/1/decline
                        {reason: "Test"}
                        
                        PATCH /article/1
                        {status: "Declined", reason: "Test"}

                        Почему первый запрос более неизвестный чем второй? В них абсолютно одинаковые данные.
                        Как вы по второму запросу без тонны документации определите, что он отправляет email?


                      1. hVostt
                        27.10.2023 06:03
                        -1

                        POST /article/1/decline
                        {reason: "Test"}
                        

                        В каком состоянии система окажется в итоге? Что будет, если вызвать метод дважды? Что вообще этот метод делает? Вот с точки зрения человека, который занимается эксплуатацией? Может ли вообще в результате этого действия удалиться статья? Да! Или создаться какой-нибудь заказ? Да, почему нет? Никаких предположений сделать нельзя без подробной документации.

                        PUT /article/1/state
                        {status: "Declined", reason: "Test"}
                        

                        Я немного поправил, всё же PATCH здесь совершенно неуместный глагол.
                        Очевидно, что изменяется статус на указанный, с указанной причиной. И при успехе метода, статья будет иметь именно такое состояние, и никакое другое. Сигнатура совершенно прозрачная и передаёт ожидаемое состояние, к которому должен привести метод. При чём, данная операция по факту идемпотентная, т.е. это действие можно выполнить 2 раза с одним и тем же результатом.

                        Как вы по второму запросу без тонны документации определите, что он отправляет email?

                        Никак. Отправка email не меняет состояние. Я бы добавил флаг, если это нужно сохранить в результате и для бизнеса это критически важно.

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

                        А вот как может выглядеть валидный RPC-метод:

                        POST /articles/decline
                        или 
                        POST /decline-article
                        { id: 1, reason: "Test", ... }
                        

                        Никакие данные не должны передаваться в путях. Путь -- это чистое имя метода, сегменты пути могут выражать неймспейсы/модули/методы. Тогда это валидно для RPC. Зачем же смешивать не смешиваемое?


                      1. michael_v89
                        27.10.2023 06:03
                        +1

                        В каком состоянии система окажется в итоге? Что будет, если вызвать метод дважды? Что вообще этот метод делает?

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

                        Я немного поправил, всё же PATCH здесь совершенно неуместный глагол.

                        Как раз для одного поля сущности надо использовать PATCH. PUT надо использовать если передается сущность целиком
                        https://stackoverflow.com/a/34400076
                        "The entity you are supplying is complete (the entire entity)."

                        И при успехе метода, статья будет иметь именно такое состояние

                        Да нет, почему? В ней же нет поля reason. reason пишется только в историю изменений. Так вот бизнес захотел.

                        т.е. это действие можно выполнить 2 раза с одним и тем же результатом

                        Да нет, в зависимости от реализации может появиться и 2 записи в истории, и отправиться 2 email. История изменений это тоже ресурс.

                        То, что вы отправляете, то вы и получите в случае успеха.

                        Нет, это никак не гарантируется. Вы отправили статус "Declined", а сущность перешла в состояние "Draft", потому что я сделал такую реализацию.

                        ресурсы определены не верно

                        Ресурс у нас один - статья. С ней можно сделать 10 действий, которые меняют состояние статьи. Каждое действие имеет параметры, которых нет в ресурсе Article.
                        Если вы сделаете какой-то другой ресурс, и при запросе на него будете менять состояние Article, то не будет работать правило "То, что вы отправляете, то вы и получите".

                        Никакие данные не должны передаваться в путях.
                        Зачем же смешивать не смешиваемое?

                        Ну как это не должны, самый простой пример - фильтр коллекции в URL.

                        Это удобно обрабатывать на бэкенде. Когда в URL передается неправильный id статьи, надо возвращать 404, а когда в данных запроса передается неправильный id категории, то надо возвращать 400 с описанием ошибки для поля category_id. RPC не запрещает иметь ресурсы и ссылаться на них в URL.


    1. nin-jin
      27.10.2023 06:03

      Ну а вот правила обработки REST API на 60 строк, где не требуется копипаста логики контроллера:

      class Article extends Entity {
          @attr.public.author title = ''
          @attr.public.author body = ''
          @attr.author.porter concern = null as null | Concern
          @attr.public.author public_ready = false
          @attr.author.system public_block = [] as readonly string[]
          @attr.public.system public = false
          @attr.system.system public_logged = true
      }
      
      class Concern extends Entity {
          @attr.public.author message = ''
          @attr.system.system mailed = false
      }
      
      Fund( Article ).effects({
          
          public_block_calc: article => article.public_block = [
              ... this.public_ready ? [] : [ 'ready_awaiting' ],
              ... this.title.trim().length < 10 ? [ 'title_too_short' ] : [],
              ... this.body.trim().length < 128 ? [ 'body_too_short' ] : [],
              ... this.concern?.message ? [ 'concern_exists' ] : [],
          ],
          
          public_calc: article => {
              if( article.public === ( article.public = this.public_ready && !this.public_block.length ) ) return
              article.public_logged = false
          },
          
          public_log: article => {
              if( article.public_logged !== false ) return
              if( article.public ) {
                  Log.rise({
                      message: 'article_published',
                      article: article.id,
                      author: article.author.id,
                  })
              } else if( article.concern ) {
                  Log.rise({
                      message: 'article_declined',
                      article: article.id,
                      moderator: article.concern.author.id,
                      reason: article.concern.message,
                  })
              }
              article.public_logged = true
          },
          
          concern_mail: article => {
              if( article.concern?.mailed !== false ) return
              Mail.send({
                  target: article.author.mail,
                  template: 'article_declined',
                  article: article.id,
                  title: article.title,
                  moderator: article.concern.user.id,
                  reason: article.concern.message,
              })
              article.concern.mailed = true
          },
          
      })


      1. michael_v89
        27.10.2023 06:03

        Ваш код работает не так, как мой.

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

        Нет отправки кода 400 при ошибках валидации (для сообщений title_too_short / body_too_short).

        Отправка email должна происходить только после успешного сохранения данных в базу. У вас она происходит во время вычисления эффектов, то есть видимо до сохранения сущности Article.

        Log.rise() должен сохранять запись в таблицу базы данных article_log в одной транзакции с сохранением article. Неясно, как у вас это гарантируется, похоже что никак.

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

        При отклонении модератором статья не скрывается в черновики, а остается в publiс_ready. Если признак черновика показывает свойство public, то тогда непонятно, что означает ситуация 'publiс_ready = false'. Бизнес использует термины "В черновике" и "Опубликована", и хочет видеть их хотя бы в интерфейсе, поэтому нужен критерий как мапить эти свойства на эти термины.

        if (article.public === (article.public = a && b)) return

        Цель сравнения свойства с самим собой непонятна и выглядит как ошибка.

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

        60 строк, где не требуется копипаста логики контроллера

        Сначала реализуйте правильно бизнес-требования, а потом будем строки считать. Проверим, как у вас контроллер отправляет 400, и как база сохраняет 2 строки в транзакции.
        Как вы верно заметили, код в контроллере очень похож, и легко автоматизируется в виде процессора, который автоматически создает input dto, делает валидацию, вызывает нужный сервис и возвращает результат. Но это выходит за рамки обсуждения. Я его написал, чтобы было понятно, как выглядит веб-интерфейс.


        1. nin-jin
          27.10.2023 06:03
          -1

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

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

          А если вы не в состоянии прочитать и осознать тривиальный код, то запишитесь на курсы войти-в-айти. Кроме "В черновике" и "Опубликована" есть ещё статус "На проверке" для статей, переведённых на премодерацию.


          1. michael_v89
            27.10.2023 06:03
            +1

            Вы, кажется, совсем берега попутали.

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

            Мне на хрен не сдались ваши ошибки валидации

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


  1. ASD2003ru
    27.10.2023 06:03

    Вот такой вопрос. Какую ошибку выдавать если запрос верный но за вашим сервисом еще один и он помер. 500 как бы странно, ваш сервис то жив.


    1. hVostt
      27.10.2023 06:03

      Нужно отдавать 500. Ничего странного, для клиента нет никакого "вашего" и "не вашего" сервиса. Есть бекенд. А бекенд несёт ответственность за обработку поступающих запросов. Если валидный запрос он не может обработать по любым причинам: не доступен сервис, БД, диск переполнен, память кончилась, просто дефект, нужно возвращать 500, ибо клиент на это никак повлиять не может.

      Ещё нужно понимать, что ошибки 500 являются транзиентными, а ошибки 4xx нет (кроме 408). Это означает, что клиент имеет право повторить свой запрос в ответ на 500, ожидая, что ошибка уйдёт. Но не имеет право этого делать в ответ на ошибки 4xx. Ибо, если запрос некорректный, ничего не изменится при повторении запроса. А вот БД, внутренний сервис, могут и ожить :)


      1. ASD2003ru
        27.10.2023 06:03

        Почему не 502 или 503?


        1. hVostt
          27.10.2023 06:03

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