Антон Шевчук // Web-разработчик

Создание RESTful API // PHP

RESTful API

При разработке фреймворка Bluz передо мной встала задача реализовать полноценный RESTful сервис. Задача с первого взгляда простая, но камнем преткновения стало отсутствие полноценного RFC для реализации оного, так что пришлось покопать и поразмыслить, с результатами моих «раскопок» я и спешу с вами поделиться.

Если хотите познакомиться с первоисточниками, которые я нашёл, то вот они:

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

«Стал трендом» — это я конечно же утрирую, данный подход был описан ещё в 2000-м году, и с тех пор по чуть-чуть отвоёвывает позиции у RPC

Я бы с радостью вам начал рассказывать основы основ, но только боюсь сделать медвежью услугу, поэтому посчитаю читателя уже подготовленным и начну с небольшой таблицы, которая наглядно свяжет REST c CRUD (табличка с wikipedia):

Create Read Update Delete
POST GET PUT DELETE
/books Создание записи Список книг Обновить данные книг Удалить все
/books/42 Ошибка Данные книги Обновить данные Удалить книгу

В данном примере наглядно показана структура URL которую следует наследовать:

/:collection/
/:collection/:uid

В первом случае мы будем манипулировать набором элементов, во втором — какой-то конкретной записью под неким UID.

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

Чтение данных

Для начала предположим наличие у нас некой базы данных пользователей:

id firstName lastName
1 Ivan Ivanov
2 Petr Petrov
3 Sidr Sidorov
.. .. ..

Ну, а теперь можно начать работать с ней, первым делом получим список записей с сервера:

>> GET /users/

<< HTTP/1.1 200 OK
[
  {id:1, firstName:"Ivan", lastName:"Ivanov"},
  {id:2, firstName:"Petr", lastName:"Petrov"},
  {id:3, firstName:"Sidr", lastName:"Sidorov"}
]

Если нам сразу все записи не нужны, то можно получить данные какой-либо конкретной записи:

>> GET /users/2

<< HTTP/1.1 200 OK
{id:2, firstName:"Petr", lastName:"Petrov"}

Если запрашиваемой записи нет, то и ответ будет соответствующим:

>> GET /users/42

<< HTTP/1.1 404 Not Found

Количество записей в базе данных может быть неподъёмным для одного запроса, так что лучше их бить на страницы, но для реализации постраничной навигации никакой RFC не принято, и тут можно пойти несколькими путями:
— используя заголовки предназначенные для работы с Partial Content (так работает Dojo Toolkit REST Store):

>> GET /users/
>> Range: items=0-2

<< HTTP/1.1 206 Partial Content
<< Content-Range: items 0-2/3
[
  {id:1, firstName:"Ivan", lastName:"Ivanov"},
  {id:2, firstName:"Petr", lastName:"Petrov"}
]

— используя GET параметры для запроса, а информации о количестве записей передавать непосредственно в теле ответа:

>> GET /users/?offset=0&limit=2

<< HTTP/1.1 200 OK
{
  rows:[
    {id:1, firstName:"Ivan", lastName:"Ivanov"},
    {id:2, firstName:"Petr", lastName:"Petrov"}
  ],
  meta:{
    total:3,
    offset:0,
    limit:2
  }
]

Оба способа имеют ряд преимуществ и недостатков, в первом случае это работа с заголовками, хоть это и true-way, но не очень прозрачно и проверять работу не так уж и просто становится (к примеру в IDE PHPStorm есть тулза, которая позволит проверить работу REST сервиса ;). Второй вариант хорош всем, кроме изменения структуры данных в ответе, к примеру для backbone вам потребуется написать обработчик, который позволит работать с такой коллекцией.

В действительности, я бы рекомендовал не надеяться на разработчиков, и в любом случае использовать некий limit по умолчанию, будет это 10, 100 или 1 000 — зависит от решаемой задачи

Что ещё может нам понадобиться? Фильтрация и сортировка:

>> GET /users/?filters=created(2012-01-01+)
>> GET /users/?sort=lastname-,firstname+
>> GET /users/?sort=lastname&order=desc

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

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

>> GET /users/?fields=id,lastname

Создание записи

Для создания записи используется наш старый знакомый — метод POST:

>> POST /users/
firstName=Petrik&lastName=Petrenko

<< HTTP/1.1 201 Created
<< Location: /users/4

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

Заголовок Location в данном примере не заставит браузер перейти на данный URL, он лишь информационный, а что с ним делать в дальнейшем – это уже решать клиентскому приложению

Ещё один момент, если вы создаёте сервис для приложения на backbone, то данная библиотека отправляет данные в JSON формате, следовательно запрос на сервер будет выглядеть несколько иначе:

>> POST /users/
>> Content-Type:application/json
{"firstName":"Petrik","lastName":"Petrenko"}

На PHP подобный запрос можно обработать следующим образом:

$request = file_get_contents('php://input');
$data = (array) json_decode($request);

О чём ещё стоит упомянуть — это проверка данных на сервере, если говорить привычным языком, то речь пойдёт о «валидации», и как вы уже понимаете, RFC нам тут не помощник, и надо что-то сочинить, вот моё сочинение:

>> POST /users/
>> Content-Type:application/json
{"firstName":"#"}

<< HTTP/1.1 400 Bad Request
<< Content-Type:application/json
{"error":"Invalid format of 'firstName'","errorCode":12345,"errorInfo":"http://developers.api.example.com/?error=12345"}
// or
{"errors":{"firstName":"Invalid format"},"errorCode":12345,"errorInfo":"http://developers.api.example.com/?error=12345"}

Обращу ваше внимание на следующие нюансы — это текст ошибки для пользователя (а не только разработчика), код ошибки для разработчика приложения и да, самая замечательная часть это ссылка на документацию к API, где разработчик сможет найти информацию о том, как устранить ошибку в своём коде (или добавить обработчик). Этот последний пункт такой «няшный», всем рекомендую.

И не забывайте, если вы попробуете передать POST’ом данные для обновление записи, то сервер вернёт ошибку:

>> POST /users/3
firstName=Petrik&lastName=Petrenko

<< HTTP/1.1 400 Bad Request

Возможно ответ 501 Not Implemented тоже будет уместен, но мне кажется 400-ая ошибка тут больше подходит, т.к. это не проблема сервера, это у клиента ошибка в коде, и лезет его код не тем методом, да и не потому адресу

Изменение данных

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

Начнём с метода PUT, присылаем значение всех полей:

>> PUT /users/2
firstName=Petr&lastName=Petrenko

<< HTTP/1.1 200 OK

Можно данные передавать и как JSON:

>> PUT /users/2
>> Content-Type:application/json
{"firstName":"Petr","lastName":"Petrenko"}

<< HTTP/1.1 200 OK

Если же запись не найдена:

>> PUT /users/42
firstName=Petr&lastName=Petrenko

<< HTTP/1.1 404 Not Found

Если ничего не изменилось (у меня есть сомнения на этот счёт):

>> PUT /users/2
firstName=Petr&lastName=Petrov

<< HTTP/1.1 304 Not Modified

Если же рассматривать метод PATCH, то на сервер отправляем лишь различия:

>> PATCH /users/2
lastName=Petrov

<< HTTP/1.1 200 OK

В API можно так же реализовать изменение нескольких записей за раз (это верно и для PUT и для PATCH методов):

>> PATCH /users
>> Content-Type:application/json
[{"id":1,firstName":"Petrik"}, {"id":2,"firstName":"Metrik"}]

<< HTTP/1.1 200 OK

Как быть в случае, если получилось внести изменения лишь в часть данных, возможно это 207 Multi-Status, но в спецификацию не вчитывался

Если уж я начал разговор о PHP, то в нём с PUT и PATCH методами не всё так гладко, и для получения данных потребуется чутка изловчиться:

$request = file_get_contents('php://input');

if ($_SERVER['CONTENT_TYPE'] == 'application/x-www-form-urlencoded') {
    // plain
    parse_str($request, $data);
} elseif ($_SERVER['CONTENT_TYPE'] == 'application/json') {
    // or JSON
    $data = (array) json_decode($request);
}

// result
print_r($data); // ['firstName'=>'Petr', 'lastName'=>'Petrenko']

Многие сервисы используют метод PUT как псевдоним к методу PATCH, да и наш «любимый» браузер не так давно научился работать с методом PATCH, так что можете пойти проверенным путём, никто на вас обиды держать не будет

Удаление данных

О, ну тут всё просто, нам потребуется метод DELETE:

>> DELETE /users/3

<< HTTP/1.1 204 No Content

Самое интересное, но вариант удаления всех записей за раз тоже возможен:

>> DELETE /users/

<< HTTP/1.1 204 No Content

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

Связанные данные

Если в вашем проекте больше одной сущности и они переплелись в хитрых взаимосвязях — значит пора добавлять в API выборку связанных записей, предположим, что наши пользователи решили обзавестись домашними животными:

>> GET /users/1/pets

<< HTTP/1.1 200 OK
[
  {id:1, petName:"Barsik", petFamily:"Cat"},
  {id:1, petName:"Tuzik", petFamily:"Dog"}
]

Если нас заинтересовал лишь кот:

>> GET /users/1/pets/1

<< HTTP/1.1 200 OK
{id:1, petName:"Barsik", petFamily:"Cat"}

Получается следующая структура URL:

/:collection/:uid/:relation
/:collection/:uid/:relation/:rid

Формат данных

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

>> GET /users/?format=xml

— указывать формат как часть пути (именно этот путь рекомендуют в книге Web API Design)

>> GET /users.xml

— передавать заголовок Accept (мне этот вариант наиболее симпатичен)

>> GET /users
>> Accept: application/json

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

>> GET /users
>> Accept: application/xhtml+xml

<< HTTP/1.1 406 Not Acceptable
<< Content-Type:application/json
{"accept": ["application/json", "application/javascript"]}

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

Локализация

Рецепт реализации мультиязычности для сервера схож с поддержкой форматов:

>> GET /users
>> Accept-Language: da, en-gb;q=0.8, en;q=0.7

<< HTTP/1.1 406 Not Acceptable
<< Content-Type:application/json
{"accept-language": ["ru", "ua"]}

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

Существует ещё один способ указания доступного типа данных и языков — это использование заголовка Alternates, но как это будет стыковаться с 406-м заголовком мне не совсем понятно (вроде как Alternates предполагает 200 код ответа), но всё же приведу пример:

>> GET /users
>> Accept-Language: da, en-gb;q=0.8, en;q=0.7

<< HTTP/1.1 406 Not Acceptable
<< Alternates: {"/users" 1.0 {language ru}}
<< Content-Type:application/json
{"accept-language": ["ru", "ua"]}

Версионность API

Если ваш сервис просуществует довольно продолжительное время, то скорей всего его постигнет участь любого другого долгожителя, а именно — расширение и изменение функционала, и как следствие у вас появится несколько версий API, и тут я полностью согласен с авторами книги «Web API Design» — лучше всего указывать версию API как часть пути:

>> GET /v1/users

Данный подход удобен, практичен и нагляден.

Вместо вывода

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

P.S.

А ещё хочу порекомендовать замечательную тулзу для документирования и тестирования RESTful API — Swagger

© Антон Шевчук 2007-2014