Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Это главы 33-35 раздела «HTTP API & REST» моей книги «API». Второе издание книги будет содержать три новых раздела: «Паттерны API», «HTTP API и REST», «SDK и UI‑библиотеки». Если эта работа была для вас полезна, пожалуйста, оцените книгу на GitHub, Amazon или GoodReads. English version on Substack.
Глава 33. О концепции HTTP API и терминологии
Вопросы организации HTTP API — к большому сожалению, одни из самых «холиварных». Будучи одной из самых популярных и притом весьма непростых в понимании технологий (ввиду большого по объёму и фрагментированного на отдельные RFC стандарта), спецификация HTTP обречена быть плохо понятой и превратно истолкованной миллионами разработчиков и многими тысячами учебных пособий. Поэтому, прежде чем переходить непосредственно к полезной части настоящего раздела, мы обязаны дать уточнения, о чём же всё-таки пойдёт речь.
Так сложилось, что в настоящий момент сетевой стек, используемый для разработки клиент-серверных API, практически полностью унифицирован в двух точках. Одна из них — это Internet protocol suite, состоящий из базового протокола IP и надстройки в виде TCP или UDP над ним. На сегодняшний день альтернативы TCP/IP используются в чрезвычайно ограниченном спектре задач, и средний разработчик практически не сталкивается ни с каким другим сетевым стеком. Однако у TCP/IP с прикладной точки зрения есть существенный недостаток — он оперирует поверх системы IP-адресов, которые плохо подходят для организации распределённых систем:
во-первых, люди не запоминают IP-адреса и предпочитают оперировать «говорящими» именами;
во-вторых, IP-адрес является технической сущностью, связанной с узлом сети, а разработчики хотели бы иметь возможность добавлять и изменять узлы, не нарушая работы своих приложений.
Удобной (и опять же имеющей почти стопроцентное проникновение) абстракцией над IP-адресами оказалась система доменных имён, позволяющий назначить узлам сети человекочитаемые синонимы.
Появление доменных имён потребовало разработки клиент-серверных протоколов более высокого, чем TCP/IP, уровня, и для передачи текстовых (гипертекстовых) данных таким протоколом стал HTTP 0.9, разработанный Тимом Бёрнерсом-Ли опубликованный в 1991 году. Помимо поддержки обращения к узлам сети по именам, HTTP также предоставил ещё одну очень удобную абстракцию, а именно назначение собственных адресов эндпойнтам, работающих на одном сетевом узле.
Протокол был очень прост и всего лишь описывал способ получить документ, открыв TCP/IP соединение с сервером и передав строку вида GET адрес_документа
. Позднее протокол был дополнен стандартом URL, позволяющим детализировать адрес документа, и далее протокол начал развиваться стремительно: появились новые глаголы помимо GET
, статусы ответов, заголовки, типы данных и так далее.
HTTP появился изначально для передачи размеченного гипертекста, что для программных интерфейсов подходит слабо. Однако HTML быстро эволюционировал в более строгий и машиночитаемый XML, который быстро стал одним из общепринятых форматов описания вызовов API. С начала 2000-х XML начал вытесняться более простым и интероперабельным JSON, и сегодня говоря о HTTP API, чаще всего имеют в виду такие интерфейсы, в которых данные передаются в формате JSON по протоколу HTTP.
Поскольку, с одной стороны, HTTP был простым и понятным протоколом, позволяющим осуществлять произвольные запросы к удаленным серверам по их доменным именам, и, с другой стороны, быстро оброс почти бесконечным количеством разнообразных расширений над базовой функциональностью, он довольно быстро стал второй точкой, к которой сходятся сетевые технологии: практически все запросы к API внутри TCP/IP-сетей осуществляются по протоколу HTTP (и даже если используется альтернативный протокол, запросы в нём всё равно зачастую оформлены в виде HTTP-пакетов просто ради удобства). При этом, однако, в отличие от TCP/IP-уровня, каждый разработчик сам для себя решает, какой объём функциональности, предоставляемой протоколом и многочисленными расширениями к нему, он готов применить. В частности, gRPC и GraphQL работают поверх HTTP, но используют крайне ограниченное подмножество его возможностей.
Тем не менее, обычно словосочетание «HTTP API» используется не просто в значении «любой API, использующий протокол HTTP»; говоря «HTTP API» мы скорее подразумеваем, что он используется не как дополнительный третий протокол транспортного уровня (как это происходит в GRPC и GraphQL), а именно как протокол уровня приложения, то есть составляющие протокола (такие как: URL, заголовки, HTTP-глаголы, статусы ответа, политики кэширования и т.д.) используются в соответствии с их семантикой, определённой в стандартах. Обычно также подразумевается, что в HTTP API использует какой-то из текстовых форматов передачи данных (JSON, XML) для описания вызовов.
В рамках настоящего раздела мы поговорим о дизайне сетевых API, обладающих следующими характеристиками:
протоколом взаимодействия является HTTP версий 1.1 и выше;
форматом данных является JSON (за исключением эндпойнтов, специально предназначенных для передачи данных, как правило, файлов, в других форматах);
в качестве идентификаторов ресурсов используется URL в соответствии со стандартом;
семантика вызовов HTTP-эндпойнтов соответствует спецификации
никакие из веб-стандартов нигде не нарушается специально.
Такое API мы будем для краткости называть просто «HTTP API» или «JSON-over-HTTP API». Мы понимаем, что такое использование терминологически некорректно, но писать каждый раз «JSON-over-HTTP эндпойнты, утилизирующие семантику, описанную в стандартах HTTP и URL» не представляется возможным.
Глава 34. Мифология REST
Прежде, чем перейти непосредственно к паттернам проектирования HTTP API, мы должны сделать ещё одно терминологическое отступление. Очень часто API, соответствующие данному нами в предыдущей главе определению, называют «REST API» или «RESTful API». В настоящем разделе мы эти термины не используем, поскольку оба этих термина неформальные и не несут никакого конкретного смысла.
Что такое «REST»? В 2000 году один из авторов спецификаций HTTP и URI Рой Филдинг защитил докторскую диссертацию на тему «Архитектурные стили и дизайн архитектуры сетевого программного обеспечения», пятая глава которой была озаглавлена как «Representational State Transfer (REST)».
Как нетрудно убедиться, прочитав эту главу, она представляет собой абстрактный обзор распределённой сетевой архитектуры, вообще не привязанной ни к HTTP, ни к URL. Более того, она вовсе не посвящена правилам дизайна API — в этой главе Филдинг методично перечисляет ограничения, с которыми приходится сталкиваться разработчику распределённого сетевого программного обеспечения. Вот они:
клиент и сервер не знают внутреннего устройства друг друга (клиент-серверная архитектура);
сессия хранится на клиенте (stateless-дизайн);
данные должны размечаться как кэшируемые или некэшируемые;
интерфейсы взаимодействия между компонентами должны быть стандартизированы;
сетевые системы являются многослойными, т.е. сервер может быть только прокси к другим серверам;
функциональность клиента может быть расширена через поставку кода с сервера.
На этом определение REST заканчивается. Дальше Филдинг конкретизирует аспекты имплементации систем в указанных ограничениях, но все они точно так же являются совершенно абстрактными. Буквально: «ключевая информационная абстракция в REST — ресурс; любая информация, которой можно дать наименование, может быть ресурсом».
Ключевой вывод, который следует из определения REST по Филдингу-2000, вообще говоря, таков: любое сетевое ПО в мире соответствует принципам REST, за очень-очень редкими исключениями.
В самом деле:
очень сложно представить себе систему, в которой не было бы хоть какой-нибудь стандартизации взаимодействия между компонентами, иначе её просто невозможно будет разрабатывать — в частности, как мы уже отмечали, почти всё сетевое взаимодействие в мире использует стек TCP/IP;
раз есть интерфейс взаимодействия, значит, под него всегда можно мимикрировать, а значит, требование независимости имплементации клиента и сервера всегда выполнимо;
раз можно сделать альтернативную имплементацию сервера — значит, можно сделать и многослойную архитектуру, поставив дополнительный прокси между клиентом и сервером;
поскольку клиент представляет собой вычислительную машину, он всегда хранит хоть какое-то состояние и кэширует хоть какие-то данные;
наконец, code-on-demand вообще лукавое требование, поскольку в архитектуре фон Неймана всегда можно объявить данные, полученные по сети, «инструкциями» на некотором формальном языке, а код клиента — их интерпретатором.
Да, конечно, вышеприведённое рассуждение является софизмом, доведением до абсурда. Самое забавное в этом упражнении состоит в том, что мы можем довести его до абсурда и в другую сторону, объявив ограничения REST неисполнимыми. Например, очевидно, что требование code-on-demand противоречит требованию независимости клиента и сервера — клиент должен уметь интерпретировать код с сервера, написанный на вполне конкретном языке. Что касается правила на букву S («stateless»), то систем, в которых сервер вообще не хранит никакого контекста клиента в мире вообще практически нет, поскольку почти ничего полезного для клиента в такой системе сделать нельзя. (Чего, кстати, Филдинг прямым текстом требует: «коммуникация … не может получать никаких преимуществ от того, что на сервере хранится какой-то контекст».)
Наконец, сам Филдинг внёс дополнительную энтропию в вопрос, выпустив в 2008 году разъяснение, что же он имел в виду. В частности, в этой статье утверждается, что:
разработка REST API должна фокусироваться на описании медиатипов, представляющих ресурсы; при этом клиент вообще ничего про эти медиатипы знать не должен;
в REST API не должно быть фиксированных имён ресурсов и операций над ними, клиент должен извлекать эту информацию из ответов сервера.
REST по Филдингу-2008 подразумевает, что клиент, получив каким-то образом ссылку на точку входа в REST API, далее должен быть в состоянии полностью выстроить взаимодействие с API, не обладая вообще никаким априорным знанием о нём, и уж тем более не должен содержать никакого специально написанного кода для работы с этим API. Это требование — гораздо более сильное, нежели принципы, описанные в диссертации 2000 года. В частности, из идеи REST-2008 вытекает отсутствие фиксированных шаблонов URL для выполнения операций над ресурсами — предполагается, что такие URL присутствуют в виде гиперссылок в представлениях ресурсов (эта концепция известна также под названием HATEOAS). Диссертация же 2000 года никаких строгих определений «гипермедиа», которые препятствовали бы идее конструирования ссылок на основе априорных знаний об API (например, по спецификации), не содержит.
NB: оставляя за скобками тот факт, что Филдинг весьма вольно истолковал свою собственную диссертацию, просто отметим, что ни одна существующая система в мире не удовлетворяет описанию REST по Филдингу-2008.
Нам неизвестно, почему из всех обзоров абстрактной сетевой архитектуры именно концепция Филдинга обрела столь широкую популярность; очевидно другое: теория Филдинга, преломившись в умах миллионов программистов (включая самого Филдинга), превратилась в целую инженерную субкультуру. Путём редукции абстракций REST применительно конкретно к протоколу HTTP и стандарту URL родилась химера «RESTful API», конкретного смысла которой никто не знает.
Хотим ли мы тем самым сказать, что REST является бессмысленной концепцией? Отнюдь нет. Мы только хотели показать, что она допускает чересчур широкую интерпретацию, в чём одновременно кроется и её сила, и её слабость.
С одной стороны, благодаря многообразию интерпретаций, разработчики API выстроили какое-то размытое, но всё-таки полезное представление о «правильной» архитектуре HTTP API. С другой стороны, за отсутствием чётких определений тема REST API превратилась в один из самых больших источников холиваров среди программистов — притом холиваров совершенно бессмысленных, поскольку популярное представление о REST не имеет вообще никакого отношения ни к тому REST, который описан в диссертации Филдинга (и тем более к тому REST, который Филдинг описал в своём манифесте 2008 года).
Термин «архитектурный стиль REST» и производный от него «REST API» в последующих главах мы использовать не будем, поскольку, как видно из написанного выше, в этом нет никакой нужды — на все описанные Филдингом ограничения мы многократно ссылались по ходу предыдущих глав, поскольку, повторимся, распределённое сетевое API попросту невозможно разработать, не руководствуясь ими. Однако HTTP API (подразумевая под этим JSON-over-HTTP эндпойнты, утилизирующие семантику, описанную в стандартах HTTP и URL), как мы его будем определять в последующих главах, фактически соответствует усреднённому представлению о «REST/RESTful API», как его можно найти в многочисленных учебных пособиях.
Глава 35. Составляющие HTTP запросов и их семантика
Третье важное подготовительное упражнение, которое мы должны сделать — это дать описание формата HTTP-запросов и ответов и прояснить базовые понятия. Многое из написанного ниже может показаться читателю самоочевидным, но, увы, специфика протокола такова, что даже базовые сведения о нём, без которых мы не сможем двигаться дальше, разбросаны по обширной и фрагментированной документации, и даже опытные разработчики могут не знать тех или иных нюансов. Ниже мы попытаемся дать структурированный обзор протокола в том объёме, который необходим нам для проектирования HTTP API.
В описании семантики и формата протокола мы будем руководствоваться свежевышедшим RFC 9110, который заменил аж девять предыдущих спецификаций, описывавших разные аспекты технологии (при этом большое количество различной дополнительной функциональности всё ещё покрывается отдельными стандартами. В частности, принципы HTTP-кэширования описаны в отдельном RFC 9111, а широко используемый в API метод PATCH
так и не вошёл в основной RFC и регулируется RFC 5789).
HTTP-запрос представляет собой (1) применение определённого глагола к URL с (2) указанием версии протокола, (3) передачей дополнительной мета-информации в заголовках и, возможно, (4) каких-то данных в теле запроса:
POST /v1/orders HTTP/1.1
Host: our-api-host.tld
Content-Type: application/json
{
"coffee_machine_id": 123,
"currency_code": "MNT",
"price": "10.23",
"recipe": "lungo",
"offer_id": 321,
"volume": "800ml"
}
Ответом на HTTP-запрос будет являться конструкция, состоящая из (1) версии протокола, (2) статус-кода ответа, (3) сообщения, (4) заголовков и, возможно, (5) тела ответа:
HTTP/1.1 201 Created
Location: /v1/orders/123
Content-Type: application/json
{
"id": 123
}
NB: в HTTP/2 (и будущем HTTP/3) вместо единого текстового формата используются отдельные бинарные фреймы для передачи заголовков и данных. Этот факт не влияет на излагаемые архитектурные принципы, но во избежание двусмысленности мы будем давать примеры в формате HTTP/1.1. Подробнее о формате HTTP/2 можно прочитать здесь.
1. URL
URL — единица адресации в HTTP API (некоторые евангелисты технологии даже используют термин «пространство URL» как синоним для Мировой паутины). Предполагается, что HTTP API должен использовать систему адресов столь же гранулярную, как и предметная область; иными словами, у любых сущностей, которыми мы можем манипулировать независимо, должен быть свой URL.
Формат URL регулируется отдельным стандартом, который развивает независимое сообщество Web Hypertext Application Technology Working Group (WHATWG). Считается, что концепция URL (вместе с понятием универсального имени ресурса, URN) составляет более общую сущность URI (универсальный идентификатор ресурса). (Разница между URL и URN заключается в том, что URL позволяет найти некоторый ресурс в рамках некоторого протокола доступа, в то время как URN — «внутреннее» имя объекта, которое само по себе никак не помогает получить к нему доступ.)
URL принято раскладывать на составляющие, каждая из которых опциональна:
схема (scheme) — протокол обращения (в нашем случае всегда
https:
);хост (host; домен или IP-адрес) — самая крупная единица адресации;
домен может включать в себя поддомены;
порт (port);
путь (path) — часть URL между именем хоста (с портом) и символами
?
,#
или концом строкипуть принято разбивать по символу
/
и работать с каждой частью как отдельным токеном — но стандарт, вообще говоря, не предписывает никакой семантики такому разбиению;пути с символом
/
и без символа/
в конце (скажем/root/leaf
и/root/leaf/
) с точки зрения стандарта являются разными (и URL, отличающиеся только наличием/отсутствием слэша, считаются разными URL), хотя практически нам неизвестны аргументы в пользу того, чтобы не считать такие пути эквивалентными;пути могут содержать секции
.
и/или..
, которые предлагается трактовать по аналогии с такими же символами в путях на файловой системе (и, соответственно, считать URL/root/leaf
,/root/./leaf
,/root/branch/../leaf
эквивалентными);
запрос (query) — часть URL после знака
?
до знака#
или конца строки;query принято раскладывать на пары
ключ=значение
, разделённые символом&
; следует вновь иметь в виду, что стандарт не предписывает query строго соответствовать этому формату и не определяет никакой семантики;также стандарт не предписывает никакой нормализации — два URL, которые различаются только порядком ключей в query, по стандарту являются разными URL;
фрагмент (fragment; также якорь, anchor) — часть URL после знака
#
;фрагмент традиционно рассматривается как адресация внутри запрошенного документа, поэтому многими агентами опускается при выполнении запроса;
два URL, отличающихся только значением фрагмента, могут считаться одинаковыми — а могут не считаться, зависит от контекста.
В HTTP-запросах, как правило (но не обязательно) схема, хост и порт опускаются (и считаются совпадающими с параметрами соединения). (Это соглашение, кстати, Филдинг считает самой большой проблемой дизайна протокола.)
NB: помимо указанных компонентов в стандарте перечислены разнообразные исторические наслоения (например, передача логинов и паролей в URL или использование не-UTF кодировки), которые нам в рамках вопросов дизайна API неинтересны. Также стандарт содержит правила сериализации, нормализации и сравнения URL, которые в целом полезно знать разработчику HTTP API.
2. Заголовки
Заголовки — это метаинформация, привязанная к запросу или ответу. Она может описывать какие-то свойства передаваемых данных (например, Content-Length
), дополнительные сведения о клиенте или сервере (User-Agent
, Date
), или просто содержать поля, не относящиеся непосредственно к смыслу запроса/ответа (например, Authorization
).
Важное свойство заголовков — это возможность считывать их до того, как получено тело сообщения. Таким образом, заголовки могут, во-первых, сами по себе влиять на обработку запроса или ответа, и ими можно относительно легко манипулировать при проксировании — и многие сетевые агенты действительно это делают, добавляя или модифицируя заголовки по своему усмотрению (в частности, современные веб-браузеры добавляют к запросам целую коллекцию заголовков: User-Agent
, Origin
, Accept-Language
, Connection
, Referer
, Sec-Fetch-*
и так далее, а современное ПО веб-серверов, в свою очередь, автоматически добавляет или модифицирует такие заголовки как X-Powered-By
, Date
, Content-Length
, Content-Encoding
, X-Forwarded-For
).
Подобное вольное обращение с заголовками создаёт определённые проблемы, если ваш API предусматривает передачу дополнительных полей метаданных, поскольку придуманные вами имена полей могут случайно совпасть с какими-то из существующих стандартных имён (или ещё хуже — в будущем появится новое стандартное поле, совпадающее с вашим). Долгое время во избежание подобных коллизий использовался префикс X-
; уже более 10 лет как эта практика объявлена устаревшей и не рекомендуется к использованию (см. подробный разбор вопроса в RFC 6648), однако отказа от этого префикса по факту не произошло (и многие широко распространённые нестандартные заголовки, например, X-Forwarded-For
, его всё ещё содержат). Таким образом, использование префикса X-
вероятность коллизий снижает, но не устраняет. Тот же RFC вполне разумно предлагает использовать вместо X-
префикс в виде имени компании. (Мы со своей стороны склонны рекомендовать использовать оба префикса в формате X-ApiName-FieldName
; префикс X-
для читабельности [чтобы отличать специальные заголовки от стандартных], а префикс с именем компании или API — чтобы не произошло коллизий с каким-нибудь другим нестандартным префиксом.)
Помимо прочего заголовки используются как управляющие конструкции — это т.н. «content negotiation», т.е. договорённость клиента и сервера о формате ответа (через заголовки Accept*
) и условные запросы, позволяющие сэкономить трафик на возврате ответа целиком или частично (через заголовки If-*
-заголовки, такие как If-Range
, If-Modified-Since
и так далее).
3. HTTP-глаголы
Важнейшая составляющая HTTP запроса — это глагол (метод), описывающий операцию, применяемую к ресурсу. RFC 9110 стандартизирует восемь глаголов — GET
, POST
, PUT
, DELETE
, HEAD
, CONNECT
, OPTIONS
и TRACE
— из которых нас как разработчиков API интересует первые четыре. CONNECT
, OPTIONS
и TRACE
— технические методы, которые очень редко используются в HTTP API (за исключением OPTIONS
, который необходимо реализовать, если необходим доступ к API из браузера). Теоретически, HEAD
(метод получения только метаданных, то есть заголовков, ресурса) мог бы быть весьма полезен в HTTP API, но по неизвестным нам причинам практически в этом смысле не используется.
Помимо RFC 9110, множество других RFC предлагают использовать дополнительные HTTP-глаголы (такие, например, как COPY
, LOCK
, SEARCH
— полный список можно найти здесь), однако из всего разнообразия предложенных стандартов лишь один имеет широкое хождение — метод PATCH
. Причины такого положения дел довольно тривиальны — этих пяти методов (GET
, POST
, PUT
, DELETE
, PATCH
) достаточно для почти любого HTTP API.
HTTP-глагол определяет два важных свойства HTTP-вызова:
его семантику (что представляет собой операция);
его побочные действия, а именно:
является ли запрос модифицирующим (и можно ли кэшировать ответ);
является ли запрос идемпотентным.
Глагол | Семантика | Безопасный (немодифицирующий) | Идемпотентный | Может иметь тело |
---|---|---|---|---|
GET | Возвращает представление ресурса | да | да | нет |
PUT | Заменяет (полностью перезаписывает) ресурс согласно данным, переданным в теле запроса | нет | да | да |
DELETE | Удаляет ресурс | нет | да | нет |
POST | Обрабатывает запрос в соответствии со своим внутренним устройством | нет | нет | да |
PATCH | Модифицирует (частично перезаписывает) ресурс согласно данным, переданным в теле запроса | нет | нет | да |
Важное свойство модифицирующих идемпотентных глаголов — это то, что URL запроса является его ключом идемпотентности. PUT /url
полностью перезаписывает ресурс, заданный своим URL (/url
), и, таким образом, повтор запроса не изменяет ресурс. Аналогично, повторный вызов DELETE /url
должен оставить систему в том же состоянии (ресурс /url
удалён). Учитывая, что метод GET /url
семантически должен вернуть представление целевого ресурса /url
, то, если этот метод реализован, он должен возвращать консистентное предыдущим PUT
/ DELETE
представление. Если ресурс был перезаписан через PUR /url
, GET /url
должен вернуть представление, соответствующее переданном в PUT /url
телу (в случае JSON-over-HTTP API это, как правило, просто означает, что GET /url
возвращает в точности тот же контент, чтобы передан в PUT /url
, с точностью до значений полей по умолчанию). DELETE /url
обязан удалить указанный ресурс — так, что GET /url
должен вернуть 404
или 410
.
Идемпотентность и симметричность методов GET
/ PUT
/ DELETE
влечёт за собой запрет GET
и DELETE
иметь тело запроса (поскольку этому телу невозможно приписать никакой осмысленной роли). Однако (по-видимому в связи с тем, что многие разработчики попросту не знают семантику этих методов) распространённое ПО веб-серверов обычно разрешает этим методам иметь тело запроса и транслирует его дальше к коду обработки эндпойнта (использование этой практики мы решительно не рекомендуем).
Достаточно очевидным образом ответы на модифицирующие запросы не кэшируются (хотя при определённых условиях закэшированный ответ метода POST
может быть использован при последующем GET
-запросе) и, таким образом, повторный POST
/ PUT
/ DELETE
/ PATCH
запрос обязательно будет доставлен до конечного сервера (ни один промежуточный агент не имеет права ответить из кэша). В случае GET
-запроса это, вообще говоря, неверно — гарантией может служить только наличие в ответе директив кэширования no-store
или no-cache
.
Один из самых частых антипаттернов разработки HTTP API — это использование HTTP-глаголов в нарушение их семантики:
Размещение модифицирующих операций за
GET
:промежуточные агенты могут ответить на такой запрос из кэша, если какая-то из директив кэширования отсутствует, либо, напротив, повторить запрос при получении сетевого таймаута;
некоторые агенты считают себя вправе переходить по таким ссылкам без явного волеизъявления пользователя или разработчика; например, социальные сети и мессенджеры выполняют такие вызовы для генерации оформления ссылки, если пользователь пытается ей поделиться.
Размещение неидемпотентных операций за идемпотентными методами
PUT
/DELETE
. хотя промежуточные агенты редко автоматически повторяют модифицирующие запросы, тем не менее это легко может сделать используемый разработчиком клиента или сервера фреймворк. Обычно эта ошибка сочетается с наличием уDELETE
-запроса тела (чтобы всё-таки отличать, что конкретно нужно перезаписать или удалить), что является само по себе проблемой, так как любой сетевой агент вправе это тело проигнорировать.Несоблюдение требования симметричности операций
GET
/PUT
/DELETE
:например, после выполнения
DELETE /url
операцияGET /url
продолжает возвращать какие-то данные илиPUT /url
ориентируется не на URL, а на данные внутри тела запроса для определения сущности, над которой выполняется операция, и, таким образом,GET /url
никак не может вернуть представление объекта, только что переданного вPUT /url
.
4. Статус-коды
Статус-код — это машиночитаемое описание результата HTTP-запроса в виде трёхзначного числа. Все статус-коды делятся на пять больших групп:
1xx
— информационные (фактически, какое-то хождение имеет разве что100 Continue
);2xx
— коды успеха операции;3xx
— коды перенаправлений (индицируют необходимость выполнения дополнительных действий, чтобы считать операцию успешной);4xx
— клиентские ошибки;5xx
— серверные ошибки.
NB: разделение на группы по первой цифре кода имеет очень важное практическое значение. В случае, если возвращаемый сервером код ошибки xyz
неизвестен клиенту, согласно спецификации клиент обязан выполнить то действие, которое выполнил бы при получении ошибки x00
.
В основе технологии статус-кодов лежит понятное желание сделать ошибки машиночитаемыми, так, чтобы все промежуточные агенты могли понять, что конкретно произошло с запросом. Номенклатура статус-кодов HTTP действительно подробно описывает почти любые проблемы, которые могут случиться с HTTP-запросом: недопустимые значения Accept-*
-заголовков, отсутствующий Content-Length
, неподдерживаемый HTTP-метод, слишком длинный URI и так далее.
К сожалению, для описаний ошибок, возникающих в бизнес-логике, номенклатура статус-кодов HTTP совершенно недостаточна и вынуждает использовать статус-коды в нарушение стандарта и/или обогащать ответ дополнительной информацией об ошибке. Проблемы имплементации системы ошибок в HTTP API мы обсудим подробнее в главе «Работа с ошибками в HTTP API».
NB: обратите внимание на проблему дизайна спецификации. По умолчанию все 4xx
коды не кэшируются, за исключением: 404
, 405
, 410
, 414
. Мы не сомневаемся, что это было сделано из благих намерений, но подозреваем, что множество людей, знающих об этих тонкостях, примерно совпадает с множеством редакторов спецификации HTTP.
Важное замечание о кэшировании
Кэширование — исключительно важная часть любой современной микросервисной архитектуры, и велик соблазн управлять им на уровне протокола — благо, стандарт предоставляет весьма функциональную и продвинутую функциональность работы с кэшами. Однако автор этой книги должен предостеречь читателя: если вы планируете имплементировать такую логику, прочитайте стандарт очень внимательно. Неверное толкование тех или иных параметров кэширования может приводить к крайне неприятным ситуациям; например, в практике автора был случай, когда случайное удаление настроек для определённой географической области (эндпойнт начал возвращать 404
) привело к неработоспособности сервиса на протяжении нескольких часов, поскольку разработчики протокола не учли, что статус 404
по умолчанию кэшируется, и клиенты просто не запрашивают новую версию настроек, пока не истечёт время жизни кэша.
Важное замечание о консистентности
Один и тот же параметр в разных ситуациях может находиться в разных частях запроса. Скажем, идентификатор партнёра, совершающего запрос, может быть передан:
в имени поддомена
{partner_id}.domain.tld
;как часть пути
/v1/{partner_id}/orders
;как query-параметр
/v1/orders?partner_id=<partner_id>
;как заголовок
GET /v1/orders HTTP/1.1 X-ApiName-Partner-Id: <partner_id>
как поле в теле запроса
POST /v1/orders/retrieve HTTP/1.1 { "partner_id": <partner_id> }
Возможны и более экзотические варианты: размещение параметра в схеме запроса или заголовке Content-Type
.
Однако при перемещении параметра между различными составляющими запроса мы столкнёмся с тремя неприятными явлениями:
некоторые значения чувствительны к регистру (путь, query-параметры, имена полей в JSON), некоторые нет (домен, имена заголовков);
при этом со значениями заголовков и вовсе неразбериха: часть из них по стандарту обязательно нечувствительна к регистру (в частности,
Content-Type
), а часть, напротив, обязательно чувствительна (например,ETag
);
наборы допустимых символов и правила экранирования также различны для разных частей запроса
для path, например, стандарта экранирования символов
/
,?
и#
не существует;символы unicode могут использоваться в доменных именах (хотя эта функциональность не везде поддерживается) только через своеобразную технику кодирования под названием Punycode;
для разных частей запросов используется разный кейсинг:
kebab-case
для домена, заголовков и пути;snake_case
для query-параметров;snake_case
илиcamelCase
для тела запроса;
При этом использование и
snake_case
, иcamelCase
в доменном имени невозможно, так как знак подчеркивания в доменных именах недопустим, а заглавные буквы будут приведены к строчным.
Чисто теоретически возможно использование kebab-case
во всех случаях, но в большинстве языков программирования имена переменных и полей объектов в kebab-case
недопустимы, что приведёт к неудобству работы с таким API.
Короче говоря, ситуация с кейсингом настолько плоха и запутанна, что консистентного и удобного решения попросту нет. В этой книге мы придерживаемся следующего правила: токены даются в том кейсинге, который является общепринятым для той секции запроса, в которой находится токен; если положение токена меняется, то меняется и кейсинг. (Мы далеки от того, чтобы рекомендовать этот подход всюду; наша общая рекомендация, скорее — не умножать энтропию и пытаться минимизировать такого рода коллизии.)
NB: вообще говоря, JSON исходно — это JavaScript Object Notation, а в языке JavaScript кейсинг по умолчанию - camelCase
. Мы, тем не менее, позволим себе утверждать, что JSON давно перестал быть форматом данных, привязанным к JavaScript, и в настоящее время используется для организации взаимодействия агентов, реализованных на любых языках программирования. Использование snake_case
, по крайней мере, позволяет легко перебрасывать параметр из query в тело и обратно, что, обычно, является наиболее частотным кейсом при разработке HTTP API. Впрочем, обратный вариант (использование camelCase
в именах query-параметров) тоже допустим.