You've already forked The-API-Book
mirror of
https://github.com/twirl/The-API-Book.git
synced 2025-08-10 21:51:42 +02:00
Дописана глава 10, завершен раздел I
This commit is contained in:
292
docs/API.ru.html
292
docs/API.ru.html
@@ -150,28 +150,35 @@ h4, h5 {
|
||||
<p>Рассмотрим следующую запись:</p>
|
||||
<pre><code>// Описание метода
|
||||
POST /v1/bucket/{id}/some-resource
|
||||
X-Idempotency-Token: <токен идемпотентости>
|
||||
{
|
||||
…
|
||||
// Это однострочный комментарий
|
||||
"some_parameter": "value",
|
||||
…
|
||||
}
|
||||
→
|
||||
→ 404 Not Found
|
||||
Cache-Control: no-cache
|
||||
{
|
||||
/* А это многострочный
|
||||
комментарий */
|
||||
"operation_id"
|
||||
"error_message"
|
||||
}
|
||||
</code></pre>
|
||||
<p>Её следует читать так:</p>
|
||||
<ul>
|
||||
<li>выполняется POST-запрос к ресурсу <code>/v1/bucket/{id}/some-resource</code>, где <code>{id}</code> заменяется на некоторый идентификатор <code>bucket</code>-а (при отсутствии уточнений подстановки вида <code>{something}</code> следует относить к ближайшему термину слева);</li>
|
||||
<li>запрос сопровождается (помимо стандартных заголовков, которые мы опускаем) дополнительным заголовком X-Idempotency-Token;</li>
|
||||
<li>фразы в угловых скобках (<code><токен идемпотентности></code>) описывают семантику значения сущности (поля, заголовка, параметра);</li>
|
||||
<li>в качестве тела запроса передаётся JSON, содержащий поле <code>some_parameter</code> со значением <code>value</code> и ещё какие-то поля, которые для краткости опущены (что показано многоточием);</li>
|
||||
<li>телом ответа является JSON, состоящий из единственного поля <code>operation_id</code>; отсутствие значения поля означает, что его значением является именно то, что в этом поле и ожидается — в данном случае какой-то идентификатор операции.</li>
|
||||
<li>в ответ (индицируется стрелкой <code>→</code>) сервер возвращает статус 404 Not Found; статус может быть опущен (отсутствие статуса следует трактовать как <code>200 OK</code>);</li>
|
||||
<li>в ответе также могут находиться дополнительные заголовки, на которые мы обращаем внимание;</li>
|
||||
<li>телом ответа является JSON, состоящий из единственного поля <code>error_message</code>; отсутствие значения поля означает, что его значением является именно то, что в этом поле и ожидается — в данном случае какое-то сообщение об ошибке.</li>
|
||||
</ul>
|
||||
<p>Тело ответа или запроса может быть опущено, если в контексте обсуждаемого вопроса его содержание не имеет значения.</p>
|
||||
<p>Ответ (частично или целиком) и тело запроса могут быть опущены, если в контексте обсуждаемого вопроса их содержание не имеют значения.</p>
|
||||
<p>Для упрощения возможна сокращенная запись вида: <code>POST /v1/bucket/{id}/some-resource</code> <code>{…,"some_parameter",…}</code> → <code>{ "operation_id" }</code>; тело запроса и/или ответа может опускаться аналогично полной записи.</p>
|
||||
<p>Чтобы сослаться на это описание будут использоваться выражения типа «метод <code>POST /v1/bucket/{id}/some-resource</code>» или, для простоты, «метод <code>some-resource</code>» или «метод <code>bucket/some-resource</code>» (если никаких других <code>some-resource</code> в контексте главы не упоминается и перепутать не с чем).</p><div class="page-break"></div><h2>I. Проектирование API</h2><h3 id="7api">Глава 7. Пирамида контекстов API</h3>
|
||||
<p>Чтобы сослаться на это описание будут использоваться выражения типа «метод <code>POST /v1/bucket/{id}/some-resource</code>» или, для простоты, «метод <code>some-resource</code>» или «метод <code>bucket/some-resource</code>» (если никаких других <code>some-resource</code> в контексте главы не упоминается и перепутать не с чем).</p>
|
||||
<p>Помимо HTTP API-нотации мы будем активно использовать C-подобный псевдокод — точнее будет сказать, JavaScript или Python-подобный, поскольку нотации типов мы будем опускать. Мы предполагаем, что подобного рода императивные конструкции достаточно читабельны, и не будем здесь описывать грамматику подробно.</p><div class="page-break"></div><h2>I. Проектирование API</h2><h3 id="7api">Глава 7. Пирамида контекстов API</h3>
|
||||
<p>Подход, который мы используем для проектирования, состоит из четырёх шагов:</p>
|
||||
<ul>
|
||||
<li>определение области применения;</li>
|
||||
@@ -599,9 +606,224 @@ GET /sensors
|
||||
</ol>
|
||||
<p>Если внимательно посмотреть на каждый объект, то мы увидим, что, в итоге, каждый объект оказался в смысле своей ответственности составным. Например, <code>program</code> будет оперировать данными высшего уровня (рецепт и кофе-машина), дополняя их терминами своего уровня (идентификатор запуска). Это совершенно нормально: API должно связывать контексты.</p>
|
||||
<h4 id="">Сценарии использования</h4>
|
||||
<p>На этом уровне, когда наше API уже в целом понятно устроено и спроектированы, мы должны поставить себя на место разработчика и попробовать написать код.</p>
|
||||
<p>// TODO
|
||||
// Хелперы, бойлерплейт</p><div class="page-break"></div><h3 id="11">Глава 11. Описание конечных интерфейсов</h3>
|
||||
<p>На этом уровне, когда наше API уже в целом понятно устроено и спроектированы, мы должны поставить себя на место разработчика и попробовать написать код. Наша задача — взглянуть на номенклатуру сущностей и понять, как ими будут пользоваться.</p>
|
||||
<p>Представим, что нам поставили задачу, пользуясь нашим кофейным API, разработать приложение для заказа кофе. Какой код мы напишем?</p>
|
||||
<p>Очевидно, первый шаг — нужно предоставить пользователю возможность выбора, чего он, собственно хочет. И первый же шаг обнажает неудобство использования нашего API: никаких методов, позволяющих пользователю что-то выбрать в нашем API нет. Разработчику придётся сделать что-то типа такого:</p>
|
||||
<ul>
|
||||
<li>получить все доступные рецепты из <code>GET /v1/recipes</code>;</li>
|
||||
<li>получить список всех кофе-машины из <code>GET /v1/coffee-machines</code>;</li>
|
||||
<li>самостоятельно выбрать нужные данные.</li>
|
||||
</ul>
|
||||
<p>В псевдокоде это будет выглядеть примерно вот так:</p>
|
||||
<pre><code>// Получить все доступные рецепты
|
||||
let recipes = api.getRecipes();
|
||||
// Получить все доступные кофе-машины
|
||||
let coffeeMachines = api.getCoffeeMachines();
|
||||
// Построить пространственный индекс
|
||||
let coffeeMachineRecipesIndex = buildGeoIndex(recipes, coffee-machines);
|
||||
// Выбрать кофе-машины, соответствующие запросу пользователя
|
||||
let matchingCoffeeMachines = coffeeMachineRecipesIndex.query(
|
||||
parameters,
|
||||
{ "sort_by": "distance" }
|
||||
);
|
||||
// Наконец, показать предложения пользователю
|
||||
app.display(coffeeMachines);
|
||||
</code></pre>
|
||||
<p>Как видите, разработчику придётся написать немало лишнего кода (это не упоминая о сложности имплементации геопространственных индексов!). Притом, учитывая наши наполеоновские планы по покрытию нашим API всех кофе-машин мира, такой алгоритм выглядит заведомо бессмысленной тратой ресурсов на получение списков и поиск по ним.</p>
|
||||
<p>Напрашивается добавление нового эндпойнта поиска. Для того, чтобы разработать этот интерфейс, нам придётся самим встать на место UX-дизайнера и подумать, каким образом приложение будет пытаться заинтересовать пользователя. Два сценария довольно очевидны:</p>
|
||||
<ul>
|
||||
<li>показать ближайшие кофейни и виды предлагаемого кофе в них («service discovery»-сценарий) — для пользователей-новичков, или просто людей без определённых предпочтений;</li>
|
||||
<li>показать ближайшие кофейни, где можно заказать конкретный вид кофе — для пользователей, которым нужен конкретный напиток.</li>
|
||||
</ul>
|
||||
<p>Тогда наш новый интерфейс будет выглядеть примерно вот так:</p>
|
||||
<pre><code>POST /v1/coffee-machines/search
|
||||
{
|
||||
// опционально
|
||||
"recipes": ["lungo", "americano"],
|
||||
"position": <географические координаты>,
|
||||
"sort_by": [
|
||||
{ "field": "distance" }
|
||||
],
|
||||
"limit": 10
|
||||
}
|
||||
→
|
||||
{
|
||||
"results": [
|
||||
{ "coffee_machine", "place", "distance", "offer" }
|
||||
],
|
||||
"cursor"
|
||||
}
|
||||
</code></pre>
|
||||
<p>Здесь:</p>
|
||||
<ul>
|
||||
<li><code>offer</code> — некоторое «предложение»: на каких условиях можно заказать запрошенные виды кофе, если они были указаны, либо какое-то маркетинговое предложение — цены на самые популярные / интересные напитки, если пользователь не указал конкретные рецепты для поиска;</li>
|
||||
<li><code>place</code> — место (кафе, автомат, ресторан), где находится машина; мы не вводили эту сущность ранее, но, очевидно, пользователю потребуются какие-то более понятные ориентиры, нежели географические координаты, чтобы найти нужную кофе-машину.</li>
|
||||
</ul>
|
||||
<p><strong>NB</strong>. Мы могли бы не добавлять новый эндпойнт, а обогатить существующий <code>/coffee-machines</code>. Однако такое решение выглядит менее семантично: не стоит в рамках одного интерфейса смешивать способ перечисления объектов по порядку и по релевантности запросу, поскольку эти два вида ранжирования обладают существенно разными свойствами и сценариями использования.</p>
|
||||
<p>Вернёмся к коду, который напишет разработчик. Теперь он будет выглядеть примерно так:</p>
|
||||
<pre><code>// Ищем кофе-машины, соответствующие запросу пользователя
|
||||
let coffeeMachines = api.search(parameters);
|
||||
// Показываем пользователю
|
||||
app.display(coffeeMachines);
|
||||
</code></pre>
|
||||
<h4 id="-1">Хэлперы</h4>
|
||||
<p>Методы, подобные только что изобретённому нами <code>coffee-machines/search</code>, принято называть <em>хэлперами</em>. Цель их существования — обобщить понятные сценарии использования API и облегчить их. Под «облегчить» мы имеем в виду не только сократить многословность («бойлерплейт»), но и помочь разработчику избежать частых проблем и ошибок.</p>
|
||||
<p>Рассмотрим, например, вопрос стоимости заказа. Наша функция поиска возвращает какие-то «предложения» с ценой. Но ведь цена может меняться: в «счастливый час» кофе может стоить меньше. Разработчик может ошибиться в имплементации этой функциональности трижды:</p>
|
||||
<ul>
|
||||
<li>кэшировать на клиентском устройстве результаты поиска слишком долго (в результате цена всегда будет неактуальна),</li>
|
||||
<li>либо, наоборот, слишком часто вызывать операцию поиска только лишь для того, чтобы актуализировать цены, создавая лишнюю нагрузку на сеть и наш сервер;</li>
|
||||
<li>создать заказ, не проверив актуальность цены (т.е. фактически обмануть пользователя, списав не ту стоимость, которая была показана).</li>
|
||||
</ul>
|
||||
<p>Для решения третьей проблемы мы могли бы потребовать передать в функцию создания заказа его стоимость, и возвращать ошибку в случае несовпадения суммы с актуальной на текущий момент. (Более того, конечно же в любом API, работающем с деньгами, это нужно делать <em>обязательно</em>.) Но это не поможет с первым вопросом: гораздо более удобно с точки зрения UX не отображать ошибку в момент нажатия кнопки «разместить заказ», а всегда показывать пользователю актуальную цену.</p>
|
||||
<p>Для решения этой проблемы мы можем поступить следующим образом: снабдить каждое предложение идентификатором, который необходимо указывать при создании заказа.</p>
|
||||
<pre><code>{
|
||||
"results": [
|
||||
{
|
||||
"coffee_machine", "place", "distance",
|
||||
"offer": {
|
||||
"id",
|
||||
"price",
|
||||
"currency_code",
|
||||
// Указываем дату и время, до наступления которых
|
||||
// предложение является актуальным
|
||||
"valid_until"
|
||||
}
|
||||
}
|
||||
],
|
||||
"cursor"
|
||||
}
|
||||
</code></pre>
|
||||
<p>Поступая так, мы не только помогаем разработчику понять, когда ему надо обновить цены, но и решаем UX-задачу: как показать пользователю, что «счастливый час» скоро закончится. Идентификатор предложения может при этом быть stateful (фактически, аналогом сессии пользователя) или stateless (если мы точно знаем, до какого времени действительна цены, мы может просто закодировать это время в идентификаторе).</p>
|
||||
<p>Альтернативно, кстати, можно было бы разделить функциональность поиска по заданным параметрам и получения офферов, т.е. добавить эндпойнт, только актуализирующий цены в конкретных кофейнях.</p>
|
||||
<h4 id="-2">Обработка ошибок</h4>
|
||||
<p>Сделаем ещё один небольшой шаг в сторону улучшения жизни разработчика. А каким образом будет выглядеть ошибка «неверная цена»?</p>
|
||||
<pre><code>POST /v1/orders
|
||||
{ … "offer_id" …}
|
||||
→ 409 Conflict
|
||||
{
|
||||
"message": "Неверная цена"
|
||||
}
|
||||
</code></pre>
|
||||
<p>С формальной точки зрения такой ошибки достаточно: пользователю будет показано сообщение «неверная цена», и он должен будет повторить заказ. Конечно, это будет очень плохое решение с точки зрения UX (пользователь ведь не совершал никаких ошибок, да и толку ему от этого сообщения никакого).</p>
|
||||
<p>Главное правило интерфейсов ошибок в API таково: из содержимого ошибки клиент должен в первую очередь понять, <em>что ему делать с этой ошибкой</em>. Всё остальное вторично; если бы ошибка была программно читаема, мы могли бы вовсе не снабжать её никаким сообщением для пользователя.</p>
|
||||
<p>Содержимое ошибки должно отвечать на следующие вопросы:</p>
|
||||
<ol>
|
||||
<li>На чьей стороне ошибка — сервера или клиента?<br />
|
||||
В HTTP API для индикации источника проблемы традиционно используются коды ответа: <code>4xx</code> проблема клиента, <code>5xx</code> проблема сервера (за исключением «статуса неопределённости» <code>404</code>).</li>
|
||||
<li>Если проблема на стороне сервера — то имеет ли смысл повторить запрос, и, если да, то когда?</li>
|
||||
<li>Если проблема на стороне клиента — является ли она устранимой или нет?<br />
|
||||
Проблема с неправильной ценой является устранимой: клиент может получить новое предложение цены и создать заказ с ним. Однако если ошибка возникает из-за неправильно написанного клиентского кода — устранить её не представляется возможным, и не нужно заставлять пользователя повторно нажимать «создать заказ»: этот запрос не завершится успехом никогда.<br />
|
||||
Здесь и далее неустранимые проблемы мы индицируем кодом <code>400 Bad Request</code>, а устранимые — кодом <code>409 Conflict</code>.</li>
|
||||
<li>Если проблема устранимая, то какого рода? Очевидно, клиент не сможет устранить проблему, о которой не знает, для каждой такой ошибки должен быть написан код (в нашем случае — перезапроса цены), т.е. должен существовать какой-то описанный набор таких ошибок.</li>
|
||||
<li>Если один и тот же род ошибок возникает вследствие некорректной передачи какого-то одного или нескольких разных параметров — то какие конкретно параметры были переданы неверно?</li>
|
||||
<li>Наконец, если какие-то параметры операции имеют недопустимые значения, то какие значения допустимы?</li>
|
||||
</ol>
|
||||
<p>В нашем случае несовпадения цены ответ должен выглядеть так:</p>
|
||||
<pre><code>409 Conflict
|
||||
{
|
||||
// Род ошибки
|
||||
"reason": "offer_invalid",
|
||||
"localized_message":
|
||||
"Что-то пошло не так. Попробуйте перезагрузить приложение."
|
||||
"details": {
|
||||
// Что конкретно неправильно?
|
||||
// Какие из проверок валидности предложения
|
||||
// отработали с ошибкой?
|
||||
"checks_failed": [
|
||||
"offer_lifetime"
|
||||
]
|
||||
}
|
||||
}
|
||||
</code></pre>
|
||||
<p>Получив такую ошибку, клиент должен проверить её род (что-то с предложением), проверить конкретную причину ошибки (срок жизни оффера истёк) и отправить повторный запрос цены. При этом если бы <code>checks_failed</code> показал другую причину ошибки — например, указанный <code>offer_id</code> не принадлежит данному пользователю — действия клиента были бы иными (отправить пользователя повторно авторизоваться, а затем перезапросить цену). Если же обработка такого рода ошибок в коде не предусмотрено — следует показать пользователю сообщение <code>localized_message</code> и вернуться к обработке ошибок по умолчанию.</p>
|
||||
<p>Важно также отметить, что неустранимые ошибки в моменте для клиента бесполезны (не зная причины ошибки клиент не может ничего разумного предложить пользователю), но это не значит, что у них не должно быть расширенной информации: их все равно будет просматривать разработчик, когда будет исправлять эту проблему в коде. Подробнее об этом в пп. 11-12 следующей главы.</p>
|
||||
<h4 id="72">Декомпозиция интерфейсов. Правило «7±2»</h4>
|
||||
<p>Исходя из нашего собственного опыта использования разных API, мы можем, не колеблясь, сказать, что самая большая ошибка проектирования сущностей в API (и, соответственно, головная боль разработчиков) — чрезмерная перегруженность интерфейсов полями, методами, событиями, параметрами и прочими атрибутами сущностей.</p>
|
||||
<p>При этом существует «золотое правило», применимое не только к API, но ко множеству других областей проектирования: человек комфортно удерживает в краткосрочной памяти 7±2 различных объекта. Манипулировать большим числом сущностей человеку уже сложно. Это правило также известно как «закон Миллера».</p>
|
||||
<p>Бороться с этим законом можно только одним способом: декомпозицией. На каждом уровне работы с вашим API нужно стремиться там, где это возможно, логически группировать сущности под одним именем — так, чтобы разработчику никогда не приходилось оперировать более чем 10 сущностями одновременно.</p>
|
||||
<p>Рассмотрим простой пример: что должна возвращать функция поиска подходящей кофе-машины. Для обеспечения хорошего UX приложения необходимо передать довольно значительные объёмы информации.</p>
|
||||
<pre><code>{
|
||||
"results": [
|
||||
{
|
||||
// Тип кофе-машины
|
||||
"coffee_machine_type": "drip_coffee_maker",
|
||||
// Марка кофе-машины
|
||||
"coffee_machine_brand",
|
||||
// Название заведения
|
||||
"place_name": "Кафе «Ромашка»",
|
||||
// Координаты
|
||||
"place_location_latitude",
|
||||
"place_location_longitude",
|
||||
// Флаг «открыто сейчас
|
||||
"place_open_now",
|
||||
// Часы работы
|
||||
"working_hours",
|
||||
// Сколько идти: время и расстояние
|
||||
"walking_distance",
|
||||
"walking_time",
|
||||
// Как найти заведение и кофе-машину
|
||||
"place_location_tip",
|
||||
"offers": [
|
||||
{
|
||||
"recipe": "lungo",
|
||||
"recipe_name": "Наш фирменный лунго®™",
|
||||
"recipe_description",
|
||||
"volume": "800ml",
|
||||
"offer_id",
|
||||
"offer_valid_until",
|
||||
"localized_price": "Большая чашка всего за 19 баксов",
|
||||
"price": "19.00",
|
||||
"currency_code": "USD",
|
||||
"estimated_waiting_time": "20s"
|
||||
},
|
||||
…
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
}
|
||||
]
|
||||
}
|
||||
</code></pre>
|
||||
<p>Подход, увы, совершенно стандартный, его можно встретить практически в любом API. Как мы видим, количество полей сущностей вышло далеко за рекомендованные 7, и даже 9. При этом набор полей идёт плоским списком вперемешку, часто с одинаковыми префиксами.</p>
|
||||
<p>В такой ситуации мы должны выделить в структуре информационные домены: какие поля логически относятся к одной предметной области. В данном случае мы можем выделить как минимум:</p>
|
||||
<ul>
|
||||
<li>местоположение кофе машины;</li>
|
||||
<li>данные о самой кофе-машине;</li>
|
||||
<li>данные о пути до точки;</li>
|
||||
<li>данные о рецепте;</li>
|
||||
<li>особенности рецепта в конкретном заведении;</li>
|
||||
<li>данные о предложении;</li>
|
||||
<li>данные о цене.</li>
|
||||
</ul>
|
||||
<p>Попробуем сгруппировать:</p>
|
||||
<pre><code>{
|
||||
"results": {
|
||||
// Данные о кофе-машине
|
||||
"coffee-machine": { "brand", "type" },
|
||||
// Данные о заведении
|
||||
"place": { "name", "location" },
|
||||
// Как добраться
|
||||
"route": { "distance", "duration", "location_tip" },
|
||||
// Предложения напитков
|
||||
"offers": {
|
||||
// Рецепт
|
||||
"recipe": { "id", "name", "description" },
|
||||
// Данные относительно того,
|
||||
// как рецепт готовят на конкретной кофе-машине
|
||||
"options": { "volume" },
|
||||
// Метаданные предложения
|
||||
"offer": { "id", "valid_until" },
|
||||
// Цена
|
||||
"pricing": { "currency_code", "price", "localized_price" },
|
||||
"estimated_waiting_time"
|
||||
}
|
||||
}
|
||||
}
|
||||
</code></pre>
|
||||
<p>Такое API читать и воспринимать гораздо удобнее, нежели сплошную простыню различных атрибутов. Более того, возможно, стоит на будущее сразу дополнительно сгруппировать, например, <code>place</code> и <code>route</code> в одну структуру <code>location</code>, или <code>offer</code> и <code>pricing</code> в одну более общую структуру.</p>
|
||||
<p>Важно, что читабельность достигается не просто снижением количества сущностей на одном уровне. Декомпозиция должна производиться таким образом, чтобы разработчик при чтении интерфейса сразу понимал: так, вот здесь находится описание заведения, оно мне пока неинтересно и углубляться в эту ветку я пока не буду. Если перемешать данные, которые одновременно в моменте нужны для выполнения действия, по разным композитам — это только ухудшит читабельность, а не улучшит.</p>
|
||||
<p>Дополнительно правильная декомпозиция поможет нам в решении задачи расширения и развития API, о чем мы поговорим в разделе II.</p><div class="page-break"></div><h3 id="11">Глава 11. Описание конечных интерфейсов</h3>
|
||||
<p>Определив все сущности, их ответственность и отношения друг с другом, мы переходим непосредственно к разработке API: нам осталось прописать номенклатуру всех объектов, полей, методов и функций в деталях. В этой главе мы дадим сугубо практические советы, как сделать API удобным и понятным.</p>
|
||||
<p>Важное уточнение под номером ноль:</p>
|
||||
<h4 id="0">0. Правила — это всего лишь обобщения</h4>
|
||||
@@ -792,7 +1014,7 @@ GET /price?recipe=lungo&longitude={longitude}&latitude={latitude}
|
||||
"id",
|
||||
"currency_code",
|
||||
"price",
|
||||
"terms": {
|
||||
"conditions": {
|
||||
// До какого времени валидно предложение
|
||||
"valid_until",
|
||||
// Где валидно предложение:
|
||||
@@ -882,5 +1104,55 @@ GET /v1/record-views/{id}?cursor={cursor}
|
||||
}
|
||||
</code></pre>
|
||||
<p>Недостатком этой схемы является необходимость заводить отдельные списки под каждый вид сортировки, а также появление множества событий для одной записи, если данные меняются часто.</p></li>
|
||||
</ul><div class="page-break"></div></article>
|
||||
</ul>
|
||||
<h4 id="12">12. Ошибки должны быть информативными</h4>
|
||||
<p>При написании кода разработчик неизбежно столкнётся с ошибками, в том числе самого примитивного толка — неправильный тип параметра или неверное значение. Чем понятнее ошибки, возвращаемые вашим API, тем меньше времени разработчик потратит на борьбу с ними, и тем приятнее работать с таким API.</p>
|
||||
<p><strong>Плохо</strong>:</p>
|
||||
<pre><code>POST /v1/coffee-machines/search
|
||||
{
|
||||
"recipes": ["lngo"],
|
||||
"position": {
|
||||
"latitude": 110,
|
||||
"longitude": 55
|
||||
}
|
||||
}
|
||||
→ 400 Bad Request
|
||||
{}
|
||||
</code></pre>
|
||||
<p>— да, конечно, допущенные ошибки (опечатка в <code>"lngo"</code> и неправильные координаты) очевидны. Но раз наш сервер все равно их проверяет, почему не вернуть описание ошибок в читаемом виде?</p>
|
||||
<pre><code>{
|
||||
"reason": "wrong_parameter_value",
|
||||
"localized_message":
|
||||
"Что-то пошло не так. Обратитесь к разработчику приложения."
|
||||
"details": {
|
||||
"checks_failed": [
|
||||
{
|
||||
"field": "recipe",
|
||||
"error_type": "wrong_value",
|
||||
"message":
|
||||
"Value 'lngo' unknown. Do you mean 'lungo'?"
|
||||
},
|
||||
{
|
||||
"field": "position.latitude",
|
||||
"error_type": "constraint_violation",
|
||||
"constraints": {
|
||||
"min": -180,
|
||||
"max": 180
|
||||
},
|
||||
"message":
|
||||
"'position.latitude' value must fall in [-180, 180] interval"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
</code></pre>
|
||||
<p>Также хорошей практикой является указание всех допущенных ошибок, а не только первой найденной.</p>
|
||||
<h4 id="13">13. Локализация и интернационализация</h4>
|
||||
<p>Все эндпойнты должны принимать на вход языковые параметры (например, в виде заголовка <code>Accept-Language</code>), даже если на текущем этапе нужды в локализации нет.</p>
|
||||
<p>Важно понимать, что язык пользователя и юрисдикция, в которой пользователь находится — разные вещи. Цикл работы вашего API всегда должен хранить локацию пользователя. Либо она задаётся явно (в запросе указываются географические координаты), либо неявно (первый запрос с географическими координатами инициировал создание сессии, в которой сохранена локация) — но без локации корректная локализация невозможна. В большинстве случаев локацию допустимо редуцировать до кода страны.</p>
|
||||
<p>Дело в том, что множество параметров, потенциально влияющих на работу API, зависят не от языка, а именно от расположения пользователя. В частности, правила форматирования чисел (разделители целой и дробной частей, разделители разрядов) и дат, первый день недели, раскладка клавиатуры, система единиц измерения (которая к тому же может оказаться не десятичной!) и так далее. В некоторых ситуациях необходимо хранить две локации: та, в которой пользователь находится, и та, которую пользователь сейчас просматривает. Например, если пользователь из США планирует туристическую поездку в Европу, то цены ему желательно показывать в местной валюте, но отформатированными согласно правилам американского письма.</p>
|
||||
<p>Следует иметь в виду, что явной передачи локации может оказаться недостаточно, поскольку в мире существуют территориальные конфликты и спорные территории. Каким образом API должно себя вести при попадании координат пользователя на такие территории — вопрос, к сожалению, в первую очередь юридический. Автору этой книги приходилось как-то разрабатывать API, в котором пришлось вводить концепцию «территория государства A по мнению официальных органов государства Б».</p>
|
||||
<p><strong>Важно</strong>: различайте локализацию для конечного пользователя и локализацию для разработчика. В примере из п. 12 сообщение <code>localized_message</code> адресовано пользователю — его должно показать приложение, если в коде обработка такой ошибки невозможно. Это сообщение должно быть написано на указанном в запросе языке и отформатировано согласно правилам локации пользователя. А вот сообщение <code>details.checks_failed[].message</code> написано не для пользователя, а для разработчика, который будет разбираться с проблемой. Соответственно, написано и отформатировано оно должно быть понятно для разработчика — что, скорее всего, означает «на английском языке», т.к. английский де факто является стандартом в мире разработки программного обеспечения.</p>
|
||||
<p>Следует отметить, что индикация, какие сообщения следует показать пользователю, а какие написаны для разработчика, должна, разумеется, быть явной конвенцией вашего API. В примере для этого используется префикс <code>localized_</code>.</p>
|
||||
<p>И ещё одна вещь: все строки должны быть в кодировке UTF-8 и никакой другой.</p><div class="page-break"></div></article>
|
||||
</body></html>
|
BIN
docs/API.ru.pdf
BIN
docs/API.ru.pdf
Binary file not shown.
@@ -10,27 +10,35 @@
|
||||
```
|
||||
// Описание метода
|
||||
POST /v1/bucket/{id}/some-resource
|
||||
X-Idempotency-Token: <токен идемпотентости>
|
||||
{
|
||||
…
|
||||
// Это однострочный комментарий
|
||||
"some_parameter": "value",
|
||||
…
|
||||
}
|
||||
→
|
||||
→ 404 Not Found
|
||||
Cache-Control: no-cache
|
||||
{
|
||||
/* А это многострочный
|
||||
комментарий */
|
||||
"operation_id"
|
||||
"error_message"
|
||||
}
|
||||
```
|
||||
|
||||
Её следует читать так:
|
||||
* выполняется POST-запрос к ресурсу `/v1/bucket/{id}/some-resource`, где `{id}` заменяется на некоторый идентификатор `bucket`-а (при отсутствии уточнений подстановки вида `{something}` следует относить к ближайшему термину слева);
|
||||
* запрос сопровождается (помимо стандартных заголовков, которые мы опускаем) дополнительным заголовком X-Idempotency-Token;
|
||||
* фразы в угловых скобках (`<токен идемпотентности>`) описывают семантику значения сущности (поля, заголовка, параметра);
|
||||
* в качестве тела запроса передаётся JSON, содержащий поле `some_parameter` со значением `value` и ещё какие-то поля, которые для краткости опущены (что показано многоточием);
|
||||
* телом ответа является JSON, состоящий из единственного поля `operation_id`; отсутствие значения поля означает, что его значением является именно то, что в этом поле и ожидается — в данном случае какой-то идентификатор операции.
|
||||
* в ответ (индицируется стрелкой `→`) сервер возвращает статус 404 Not Found; статус может быть опущен (отсутствие статуса следует трактовать как `200 OK`);
|
||||
* в ответе также могут находиться дополнительные заголовки, на которые мы обращаем внимание;
|
||||
* телом ответа является JSON, состоящий из единственного поля `error_message`; отсутствие значения поля означает, что его значением является именно то, что в этом поле и ожидается — в данном случае какое-то сообщение об ошибке.
|
||||
|
||||
Тело ответа или запроса может быть опущено, если в контексте обсуждаемого вопроса его содержание не имеет значения.
|
||||
Ответ (частично или целиком) и тело запроса могут быть опущены, если в контексте обсуждаемого вопроса их содержание не имеют значения.
|
||||
|
||||
Для упрощения возможна сокращенная запись вида: `POST /v1/bucket/{id}/some-resource` `{…,"some_parameter",…}` → `{ "operation_id" }`; тело запроса и/или ответа может опускаться аналогично полной записи.
|
||||
|
||||
Чтобы сослаться на это описание будут использоваться выражения типа «метод `POST /v1/bucket/{id}/some-resource`» или, для простоты, «метод `some-resource`» или «метод `bucket/some-resource`» (если никаких других `some-resource` в контексте главы не упоминается и перепутать не с чем).
|
||||
Чтобы сослаться на это описание будут использоваться выражения типа «метод `POST /v1/bucket/{id}/some-resource`» или, для простоты, «метод `some-resource`» или «метод `bucket/some-resource`» (если никаких других `some-resource` в контексте главы не упоминается и перепутать не с чем).
|
||||
|
||||
Помимо HTTP API-нотации мы будем активно использовать C-подобный псевдокод — точнее будет сказать, JavaScript или Python-подобный, поскольку нотации типов мы будем опускать. Мы предполагаем, что подобного рода императивные конструкции достаточно читабельны, и не будем здесь описывать грамматику подробно.
|
@@ -36,7 +36,252 @@
|
||||
|
||||
#### Сценарии использования
|
||||
|
||||
На этом уровне, когда наше API уже в целом понятно устроено и спроектированы, мы должны поставить себя на место разработчика и попробовать написать код.
|
||||
На этом уровне, когда наше API уже в целом понятно устроено и спроектированы, мы должны поставить себя на место разработчика и попробовать написать код. Наша задача — взглянуть на номенклатуру сущностей и понять, как ими будут пользоваться.
|
||||
|
||||
// TODO
|
||||
// Хелперы, бойлерплейт
|
||||
Представим, что нам поставили задачу, пользуясь нашим кофейным API, разработать приложение для заказа кофе. Какой код мы напишем?
|
||||
|
||||
Очевидно, первый шаг — нужно предоставить пользователю возможность выбора, чего он, собственно хочет. И первый же шаг обнажает неудобство использования нашего API: никаких методов, позволяющих пользователю что-то выбрать в нашем API нет. Разработчику придётся сделать что-то типа такого:
|
||||
* получить все доступные рецепты из `GET /v1/recipes`;
|
||||
* получить список всех кофе-машины из `GET /v1/coffee-machines`;
|
||||
* самостоятельно выбрать нужные данные.
|
||||
|
||||
В псевдокоде это будет выглядеть примерно вот так:
|
||||
```
|
||||
// Получить все доступные рецепты
|
||||
let recipes = api.getRecipes();
|
||||
// Получить все доступные кофе-машины
|
||||
let coffeeMachines = api.getCoffeeMachines();
|
||||
// Построить пространственный индекс
|
||||
let coffeeMachineRecipesIndex = buildGeoIndex(recipes, coffee-machines);
|
||||
// Выбрать кофе-машины, соответствующие запросу пользователя
|
||||
let matchingCoffeeMachines = coffeeMachineRecipesIndex.query(
|
||||
parameters,
|
||||
{ "sort_by": "distance" }
|
||||
);
|
||||
// Наконец, показать предложения пользователю
|
||||
app.display(coffeeMachines);
|
||||
```
|
||||
|
||||
Как видите, разработчику придётся написать немало лишнего кода (это не упоминая о сложности имплементации геопространственных индексов!). Притом, учитывая наши наполеоновские планы по покрытию нашим API всех кофе-машин мира, такой алгоритм выглядит заведомо бессмысленной тратой ресурсов на получение списков и поиск по ним.
|
||||
|
||||
Напрашивается добавление нового эндпойнта поиска. Для того, чтобы разработать этот интерфейс, нам придётся самим встать на место UX-дизайнера и подумать, каким образом приложение будет пытаться заинтересовать пользователя. Два сценария довольно очевидны:
|
||||
* показать ближайшие кофейни и виды предлагаемого кофе в них («service discovery»-сценарий) — для пользователей-новичков, или просто людей без определённых предпочтений;
|
||||
* показать ближайшие кофейни, где можно заказать конкретный вид кофе — для пользователей, которым нужен конкретный напиток.
|
||||
|
||||
Тогда наш новый интерфейс будет выглядеть примерно вот так:
|
||||
```
|
||||
POST /v1/coffee-machines/search
|
||||
{
|
||||
// опционально
|
||||
"recipes": ["lungo", "americano"],
|
||||
"position": <географические координаты>,
|
||||
"sort_by": [
|
||||
{ "field": "distance" }
|
||||
],
|
||||
"limit": 10
|
||||
}
|
||||
→
|
||||
{
|
||||
"results": [
|
||||
{ "coffee_machine", "place", "distance", "offer" }
|
||||
],
|
||||
"cursor"
|
||||
}
|
||||
```
|
||||
|
||||
Здесь:
|
||||
* `offer` — некоторое «предложение»: на каких условиях можно заказать запрошенные виды кофе, если они были указаны, либо какое-то маркетинговое предложение — цены на самые популярные / интересные напитки, если пользователь не указал конкретные рецепты для поиска;
|
||||
* `place` — место (кафе, автомат, ресторан), где находится машина; мы не вводили эту сущность ранее, но, очевидно, пользователю потребуются какие-то более понятные ориентиры, нежели географические координаты, чтобы найти нужную кофе-машину.
|
||||
|
||||
**NB**. Мы могли бы не добавлять новый эндпойнт, а обогатить существующий `/coffee-machines`. Однако такое решение выглядит менее семантично: не стоит в рамках одного интерфейса смешивать способ перечисления объектов по порядку и по релевантности запросу, поскольку эти два вида ранжирования обладают существенно разными свойствами и сценариями использования.
|
||||
|
||||
Вернёмся к коду, который напишет разработчик. Теперь он будет выглядеть примерно так:
|
||||
```
|
||||
// Ищем кофе-машины, соответствующие запросу пользователя
|
||||
let coffeeMachines = api.search(parameters);
|
||||
// Показываем пользователю
|
||||
app.display(coffeeMachines);
|
||||
```
|
||||
|
||||
#### Хэлперы
|
||||
|
||||
Методы, подобные только что изобретённому нами `coffee-machines/search`, принято называть *хэлперами*. Цель их существования — обобщить понятные сценарии использования API и облегчить их. Под «облегчить» мы имеем в виду не только сократить многословность («бойлерплейт»), но и помочь разработчику избежать частых проблем и ошибок.
|
||||
|
||||
Рассмотрим, например, вопрос стоимости заказа. Наша функция поиска возвращает какие-то «предложения» с ценой. Но ведь цена может меняться: в «счастливый час» кофе может стоить меньше. Разработчик может ошибиться в имплементации этой функциональности трижды:
|
||||
* кэшировать на клиентском устройстве результаты поиска слишком долго (в результате цена всегда будет неактуальна),
|
||||
* либо, наоборот, слишком часто вызывать операцию поиска только лишь для того, чтобы актуализировать цены, создавая лишнюю нагрузку на сеть и наш сервер;
|
||||
* создать заказ, не проверив актуальность цены (т.е. фактически обмануть пользователя, списав не ту стоимость, которая была показана).
|
||||
|
||||
Для решения третьей проблемы мы могли бы потребовать передать в функцию создания заказа его стоимость, и возвращать ошибку в случае несовпадения суммы с актуальной на текущий момент. (Более того, конечно же в любом API, работающем с деньгами, это нужно делать *обязательно*.) Но это не поможет с первым вопросом: гораздо более удобно с точки зрения UX не отображать ошибку в момент нажатия кнопки «разместить заказ», а всегда показывать пользователю актуальную цену.
|
||||
|
||||
Для решения этой проблемы мы можем поступить следующим образом: снабдить каждое предложение идентификатором, который необходимо указывать при создании заказа.
|
||||
```
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"coffee_machine", "place", "distance",
|
||||
"offer": {
|
||||
"id",
|
||||
"price",
|
||||
"currency_code",
|
||||
// Указываем дату и время, до наступления которых
|
||||
// предложение является актуальным
|
||||
"valid_until"
|
||||
}
|
||||
}
|
||||
],
|
||||
"cursor"
|
||||
}
|
||||
```
|
||||
Поступая так, мы не только помогаем разработчику понять, когда ему надо обновить цены, но и решаем UX-задачу: как показать пользователю, что «счастливый час» скоро закончится. Идентификатор предложения может при этом быть stateful (фактически, аналогом сессии пользователя) или stateless (если мы точно знаем, до какого времени действительна цены, мы может просто закодировать это время в идентификаторе).
|
||||
|
||||
Альтернативно, кстати, можно было бы разделить функциональность поиска по заданным параметрам и получения офферов, т.е. добавить эндпойнт, только актуализирующий цены в конкретных кофейнях.
|
||||
|
||||
#### Обработка ошибок
|
||||
|
||||
Сделаем ещё один небольшой шаг в сторону улучшения жизни разработчика. А каким образом будет выглядеть ошибка «неверная цена»?
|
||||
|
||||
```
|
||||
POST /v1/orders
|
||||
{ … "offer_id" …}
|
||||
→ 409 Conflict
|
||||
{
|
||||
"message": "Неверная цена"
|
||||
}
|
||||
```
|
||||
|
||||
С формальной точки зрения такой ошибки достаточно: пользователю будет показано сообщение «неверная цена», и он должен будет повторить заказ. Конечно, это будет очень плохое решение с точки зрения UX (пользователь ведь не совершал никаких ошибок, да и толку ему от этого сообщения никакого).
|
||||
|
||||
Главное правило интерфейсов ошибок в API таково: из содержимого ошибки клиент должен в первую очередь понять, *что ему делать с этой ошибкой*. Всё остальное вторично; если бы ошибка была программно читаема, мы могли бы вовсе не снабжать её никаким сообщением для пользователя.
|
||||
|
||||
Содержимое ошибки должно отвечать на следующие вопросы:
|
||||
|
||||
1. На чьей стороне ошибка — сервера или клиента?
|
||||
В HTTP API для индикации источника проблемы традиционно используются коды ответа: `4xx` проблема клиента, `5xx` проблема сервера (за исключением «статуса неопределённости» `404`).
|
||||
2. Если проблема на стороне сервера — то имеет ли смысл повторить запрос, и, если да, то когда?
|
||||
3. Если проблема на стороне клиента — является ли она устранимой или нет?
|
||||
Проблема с неправильной ценой является устранимой: клиент может получить новое предложение цены и создать заказ с ним. Однако если ошибка возникает из-за неправильно написанного клиентского кода — устранить её не представляется возможным, и не нужно заставлять пользователя повторно нажимать «создать заказ»: этот запрос не завершится успехом никогда.
|
||||
Здесь и далее неустранимые проблемы мы индицируем кодом `400 Bad Request`, а устранимые — кодом `409 Conflict`.
|
||||
4. Если проблема устранимая, то какого рода? Очевидно, клиент не сможет устранить проблему, о которой не знает, для каждой такой ошибки должен быть написан код (в нашем случае — перезапроса цены), т.е. должен существовать какой-то описанный набор таких ошибок.
|
||||
5. Если один и тот же род ошибок возникает вследствие некорректной передачи какого-то одного или нескольких разных параметров — то какие конкретно параметры были переданы неверно?
|
||||
6. Наконец, если какие-то параметры операции имеют недопустимые значения, то какие значения допустимы?
|
||||
|
||||
В нашем случае несовпадения цены ответ должен выглядеть так:
|
||||
```
|
||||
409 Conflict
|
||||
{
|
||||
// Род ошибки
|
||||
"reason": "offer_invalid",
|
||||
"localized_message":
|
||||
"Что-то пошло не так. Попробуйте перезагрузить приложение."
|
||||
"details": {
|
||||
// Что конкретно неправильно?
|
||||
// Какие из проверок валидности предложения
|
||||
// отработали с ошибкой?
|
||||
"checks_failed": [
|
||||
"offer_lifetime"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Получив такую ошибку, клиент должен проверить её род (что-то с предложением), проверить конкретную причину ошибки (срок жизни оффера истёк) и отправить повторный запрос цены. При этом если бы `checks_failed` показал другую причину ошибки — например, указанный `offer_id` не принадлежит данному пользователю — действия клиента были бы иными (отправить пользователя повторно авторизоваться, а затем перезапросить цену). Если же обработка такого рода ошибок в коде не предусмотрено — следует показать пользователю сообщение `localized_message` и вернуться к обработке ошибок по умолчанию.
|
||||
|
||||
Важно также отметить, что неустранимые ошибки в моменте для клиента бесполезны (не зная причины ошибки клиент не может ничего разумного предложить пользователю), но это не значит, что у них не должно быть расширенной информации: их все равно будет просматривать разработчик, когда будет исправлять эту проблему в коде. Подробнее об этом в пп. 11-12 следующей главы.
|
||||
|
||||
#### Декомпозиция интерфейсов. Правило «7±2»
|
||||
|
||||
Исходя из нашего собственного опыта использования разных API, мы можем, не колеблясь, сказать, что самая большая ошибка проектирования сущностей в API (и, соответственно, головная боль разработчиков) — чрезмерная перегруженность интерфейсов полями, методами, событиями, параметрами и прочими атрибутами сущностей.
|
||||
|
||||
При этом существует «золотое правило», применимое не только к API, но ко множеству других областей проектирования: человек комфортно удерживает в краткосрочной памяти 7±2 различных объекта. Манипулировать большим числом сущностей человеку уже сложно. Это правило также известно как «закон Миллера».
|
||||
|
||||
Бороться с этим законом можно только одним способом: декомпозицией. На каждом уровне работы с вашим API нужно стремиться там, где это возможно, логически группировать сущности под одним именем — так, чтобы разработчику никогда не приходилось оперировать более чем 10 сущностями одновременно.
|
||||
|
||||
Рассмотрим простой пример: что должна возвращать функция поиска подходящей кофе-машины. Для обеспечения хорошего UX приложения необходимо передать довольно значительные объёмы информации.
|
||||
```
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
// Тип кофе-машины
|
||||
"coffee_machine_type": "drip_coffee_maker",
|
||||
// Марка кофе-машины
|
||||
"coffee_machine_brand",
|
||||
// Название заведения
|
||||
"place_name": "Кафе «Ромашка»",
|
||||
// Координаты
|
||||
"place_location_latitude",
|
||||
"place_location_longitude",
|
||||
// Флаг «открыто сейчас
|
||||
"place_open_now",
|
||||
// Часы работы
|
||||
"working_hours",
|
||||
// Сколько идти: время и расстояние
|
||||
"walking_distance",
|
||||
"walking_time",
|
||||
// Как найти заведение и кофе-машину
|
||||
"place_location_tip",
|
||||
"offers": [
|
||||
{
|
||||
"recipe": "lungo",
|
||||
"recipe_name": "Наш фирменный лунго®™",
|
||||
"recipe_description",
|
||||
"volume": "800ml",
|
||||
"offer_id",
|
||||
"offer_valid_until",
|
||||
"localized_price": "Большая чашка всего за 19 баксов",
|
||||
"price": "19.00",
|
||||
"currency_code": "USD",
|
||||
"estimated_waiting_time": "20s"
|
||||
},
|
||||
…
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Подход, увы, совершенно стандартный, его можно встретить практически в любом API. Как мы видим, количество полей сущностей вышло далеко за рекомендованные 7, и даже 9. При этом набор полей идёт плоским списком вперемешку, часто с одинаковыми префиксами.
|
||||
|
||||
В такой ситуации мы должны выделить в структуре информационные домены: какие поля логически относятся к одной предметной области. В данном случае мы можем выделить как минимум:
|
||||
* местоположение кофе машины;
|
||||
* данные о самой кофе-машине;
|
||||
* данные о пути до точки;
|
||||
* данные о рецепте;
|
||||
* особенности рецепта в конкретном заведении;
|
||||
* данные о предложении;
|
||||
* данные о цене.
|
||||
|
||||
Попробуем сгруппировать:
|
||||
```
|
||||
{
|
||||
"results": {
|
||||
// Данные о кофе-машине
|
||||
"coffee-machine": { "brand", "type" },
|
||||
// Данные о заведении
|
||||
"place": { "name", "location" },
|
||||
// Как добраться
|
||||
"route": { "distance", "duration", "location_tip" },
|
||||
// Предложения напитков
|
||||
"offers": {
|
||||
// Рецепт
|
||||
"recipe": { "id", "name", "description" },
|
||||
// Данные относительно того,
|
||||
// как рецепт готовят на конкретной кофе-машине
|
||||
"options": { "volume" },
|
||||
// Метаданные предложения
|
||||
"offer": { "id", "valid_until" },
|
||||
// Цена
|
||||
"pricing": { "currency_code", "price", "localized_price" },
|
||||
"estimated_waiting_time"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Такое API читать и воспринимать гораздо удобнее, нежели сплошную простыню различных атрибутов. Более того, возможно, стоит на будущее сразу дополнительно сгруппировать, например, `place` и `route` в одну структуру `location`, или `offer` и `pricing` в одну более общую структуру.
|
||||
|
||||
Важно, что читабельность достигается не просто снижением количества сущностей на одном уровне. Декомпозиция должна производиться таким образом, чтобы разработчик при чтении интерфейса сразу понимал: так, вот здесь находится описание заведения, оно мне пока неинтересно и углубляться в эту ветку я пока не буду. Если перемешать данные, которые одновременно в моменте нужны для выполнения действия, по разным композитам — это только ухудшит читабельность, а не улучшит.
|
||||
|
||||
Дополнительно правильная декомпозиция поможет нам в решении задачи расширения и развития API, о чем мы поговорим в разделе II.
|
@@ -274,7 +274,7 @@ GET /price?recipe=lungo&longitude={longitude}&latitude={latitude}
|
||||
"id",
|
||||
"currency_code",
|
||||
"price",
|
||||
"terms": {
|
||||
"conditions": {
|
||||
// До какого времени валидно предложение
|
||||
"valid_until",
|
||||
// Где валидно предложение:
|
||||
@@ -380,3 +380,65 @@ GET /records?sort_by=date_modified&sort_order=desc&limit=10&offset=100
|
||||
|
||||
Недостатком этой схемы является необходимость заводить отдельные списки под каждый вид сортировки, а также появление множества событий для одной записи, если данные меняются часто.
|
||||
|
||||
#### 12. Ошибки должны быть информативными
|
||||
|
||||
При написании кода разработчик неизбежно столкнётся с ошибками, в том числе самого примитивного толка — неправильный тип параметра или неверное значение. Чем понятнее ошибки, возвращаемые вашим API, тем меньше времени разработчик потратит на борьбу с ними, и тем приятнее работать с таким API.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
POST /v1/coffee-machines/search
|
||||
{
|
||||
"recipes": ["lngo"],
|
||||
"position": {
|
||||
"latitude": 110,
|
||||
"longitude": 55
|
||||
}
|
||||
}
|
||||
→ 400 Bad Request
|
||||
{}
|
||||
```
|
||||
— да, конечно, допущенные ошибки (опечатка в `"lngo"` и неправильные координаты) очевидны. Но раз наш сервер все равно их проверяет, почему не вернуть описание ошибок в читаемом виде?
|
||||
```
|
||||
{
|
||||
"reason": "wrong_parameter_value",
|
||||
"localized_message":
|
||||
"Что-то пошло не так. Обратитесь к разработчику приложения."
|
||||
"details": {
|
||||
"checks_failed": [
|
||||
{
|
||||
"field": "recipe",
|
||||
"error_type": "wrong_value",
|
||||
"message":
|
||||
"Value 'lngo' unknown. Do you mean 'lungo'?"
|
||||
},
|
||||
{
|
||||
"field": "position.latitude",
|
||||
"error_type": "constraint_violation",
|
||||
"constraints": {
|
||||
"min": -180,
|
||||
"max": 180
|
||||
},
|
||||
"message":
|
||||
"'position.latitude' value must fall in [-180, 180] interval"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
Также хорошей практикой является указание всех допущенных ошибок, а не только первой найденной.
|
||||
|
||||
#### 13. Локализация и интернационализация
|
||||
|
||||
Все эндпойнты должны принимать на вход языковые параметры (например, в виде заголовка `Accept-Language`), даже если на текущем этапе нужды в локализации нет.
|
||||
|
||||
Важно понимать, что язык пользователя и юрисдикция, в которой пользователь находится — разные вещи. Цикл работы вашего API всегда должен хранить локацию пользователя. Либо она задаётся явно (в запросе указываются географические координаты), либо неявно (первый запрос с географическими координатами инициировал создание сессии, в которой сохранена локация) — но без локации корректная локализация невозможна. В большинстве случаев локацию допустимо редуцировать до кода страны.
|
||||
|
||||
Дело в том, что множество параметров, потенциально влияющих на работу API, зависят не от языка, а именно от расположения пользователя. В частности, правила форматирования чисел (разделители целой и дробной частей, разделители разрядов) и дат, первый день недели, раскладка клавиатуры, система единиц измерения (которая к тому же может оказаться не десятичной!) и так далее. В некоторых ситуациях необходимо хранить две локации: та, в которой пользователь находится, и та, которую пользователь сейчас просматривает. Например, если пользователь из США планирует туристическую поездку в Европу, то цены ему желательно показывать в местной валюте, но отформатированными согласно правилам американского письма.
|
||||
|
||||
Следует иметь в виду, что явной передачи локации может оказаться недостаточно, поскольку в мире существуют территориальные конфликты и спорные территории. Каким образом API должно себя вести при попадании координат пользователя на такие территории — вопрос, к сожалению, в первую очередь юридический. Автору этой книги приходилось как-то разрабатывать API, в котором пришлось вводить концепцию «территория государства A по мнению официальных органов государства Б».
|
||||
|
||||
**Важно**: различайте локализацию для конечного пользователя и локализацию для разработчика. В примере из п. 12 сообщение `localized_message` адресовано пользователю — его должно показать приложение, если в коде обработка такой ошибки невозможно. Это сообщение должно быть написано на указанном в запросе языке и отформатировано согласно правилам локации пользователя. А вот сообщение `details.checks_failed[].message` написано не для пользователя, а для разработчика, который будет разбираться с проблемой. Соответственно, написано и отформатировано оно должно быть понятно для разработчика — что, скорее всего, означает «на английском языке», т.к. английский де факто является стандартом в мире разработки программного обеспечения.
|
||||
|
||||
Следует отметить, что индикация, какие сообщения следует показать пользователю, а какие написаны для разработчика, должна, разумеется, быть явной конвенцией вашего API. В примере для этого используется префикс `localized_`.
|
||||
|
||||
И ещё одна вещь: все строки должны быть в кодировке UTF-8 и никакой другой.
|
Reference in New Issue
Block a user