Создание 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 выборку связанных записей, предположим, что наши пользователи решили обзавестись домашними животными:

>> POST /users/1/pets
>> Content-Type:application/json
{petName:"Barsik", petFamily:"Cat"}

>> POST /users/1/pets
>> Content-Type:application/json
{id:1, petName:"Tuzik", petFamily:"Dog"}

Получаем список всех животных:

>> 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

Обратите внимание, у нас тут имено связанные сущности, питомцы в нашей системе не могут существовать без хозяина, поэтому нет endpoint’а /pets/ и методов которые к нему обращаются. Но возможна ситуация, когда у вас в проекте связанные сущности могут спокойно существовать самостоятельно, возможно это сайт для питомника или ветеринарной клиники.

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

Ещё один важный момент — это формат возвращаемых данных, по умолчанию рекомендую использовать 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 /1.0/users

>> GET /v1.0/users

Данный подход удобен, практичен и нагляден, ему следуют и Twitter (1й вариант) и Facebook (второй пример).

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

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

P.S.

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

P.P.S.

А есть ещё тулза для тестирования RESTful API — Postman, и её можно прикрутить к вашей CI.

P.P.P.S.

Недавное читал в ХНУРЭ лекцию про RESTful API, теперь по этой теме у меня появились слайды:

26 thoughts on “Создание RESTful API”

  1. Swagger конечно очень мощная вещь, но все же ее для работы с POST нужно напильником обточить (имеется ввиду Swagger UI). По умолчанию он не умеет отправлять данные в POST. Может это уже поменялось, но в Феврале это было так.

  2. Для изменения данных наших вымышленных пользователей следует использовать метод PUT:

    Точнее для изменения таки POST или PATCH, PUT же используется для перезаписи (т.е. нужно не разницу передавать, а весь ресурс).

  3. Не совсем понял пункт про локализацию.
    Какой смысл это дело передавать в заголовках? Почему не просто ?locale=eng
    ?

  4. Еще неплохо было бы реализовать прием PATCH/PUT/DELETE через POST, а то не все клиенты умеют. Через загловок X-Http-Method-Override или через $_POST[‘_method’] напр.

  5. Тип посылаемого формата данных передаете через HTTP заголоки: Content-Type:application/json а почуме с фильтрами не поступить также?
    Добавляем заголовоки:
    Content-Type: application/json
    Content-Sort: base64_encode(json_encode([‘firstName’ => ‘DESC’, ‘created_at’ => ‘ASC’]))
    Content-Filter: base64_encode(json_encode([‘firstName’ => ‘filteredName’]))
    И это все отправляем через GET:
    GET http://api.mysite.com/users HTTP/1.1

    на стороне сервера добавляем:

    $filters = array_merge($_GET, json_decode(base64_decode($request->getHttpHeader('Content-Filter'))));

    и такоеже для сортировки.

    и добавить проверки на отсутствия заголовка Content-Sort или Content-Filter

  6. Антон, а не сталкивались ли вы с готовыми решениями REST CRUD работающей на прямую со структурой БД? Т.е. простейшем варианте использования это имена таблиц соответствуют вызовам, а валидация на основе типа полей.

  7. По поводу:

    if ($_SERVER['CONTENT_TYPE'] == 'application/json')

    firefox например шлет $_SERVER[‘CONTENT_TYPE’] как “application/json;UTF-8” . Поэтому следует использовать поиск вхождения, а не равенство строк

    например так:

    if (mb_stristr($_SERVER['CONTENT_TYPE'], 'application/json'))
  8. Конечно статьи очень полезная, мне бы ее прочитать пару лет назад, но уже все это красиво реализовано давно в Yii например.
    Также хочу посоветовать Advance REST client для Chrome удобная штука.

  9. Связанные данные
    На самом деле, иерархию взаимосвязей нежелательно представлять в URI. Т.е. /users/Ivanov/Pets/Barsik подталкивает в кодированию ВСЕЙ иерерхии сразу. Т.е. при добавлении нового уровня, например, /town/Kharkov или /food/Viskas URI разрастается теряя гибкость. Да и надо как-то транслировать эти взяимосвязи в коде.
    При следовании правила “3-х секций” (с)Я – мы можем добавить в URI только имя взаимосвязи. Итак, чтобы получить инфо по вискасу, который жрёт Барсик Иванова, который живёт в Харькове – надо будет сделать 4 запроса

    >> /town/Kharkov/Users
    << {["name":"Ivanov"],...}
    
    >> /users/Ivanov/pets
    << {["name":"Хулимяу"],...}
    
    >> /pets/Хулимяу/food
    << {["name":"Гречка"],...}
    
    >> /food/Гречка
    << {"name":"Гречка"}
    

    Такая жёсткая привязка к родителю даже может снизить сетевой обмен если кулинарные привязанности кота Хулимяу не менялись. Но гарантированно увеличит количество HTTP-запросов.

  10. PUT vs POST vs PATCH
    PUT и PATCH – идемпотентны априори (крутая фраза), POST – нет. Т.е. POST ДОЛЖЕН создавать дубликаты, а PUT – нет.

    Версионность
    ИМХО, Accept: application/*+json;version=2 намного гибче и не надо придумывать реврайты :) (подсмотрено в API для Vmware vCloud Director)

    Формат ответа
    Должен содержать либо однозначный идентификатор сущности и её тип, либо ссылку на неё. Мой выбор – всё сразу

    {
        "type" : "pets", // или Cat если он точно определён и такая сущность есть в API
        "id" : "Any_identifier"
        "href" : "/api/pet/Any_identifier"
    }
    
  11. Базовые взаимосвязи
    Полезно в описание сущности добавлять ссылки на самые используемые части API. Проще объяснить на примере:

    {
        "type" : "pets", // или Cat если он точно определён и такая сущность есть в API
        "id" : "Any_identifier"
        "href" : "/api/pet/Any_identifier"
        "name" : "Хулимяу"
        "food" : {
            "type" : "FoodCollection",
            "href" : "/api/Хулимяу/food"
        }
    }
    

    Тогда становятся легкореализуемые няшные API вида

    //    /town/Khakrov    /users
    $api->town('Kharkov')->users()
    

    И дополнительно сущности можно подменять. Т.е. такой себе IoC – типа, эй, UI, за списком еды для Хулимяу ходи сюда. Генерация такого URI простая, но складывает с клиента знание о структуре

  12. Кнопка Make Pizdato (сорри)
    Обязательно должен быть локатор сущностей, query и т.д. чтоб хотя бы свой клиент не заморачивался хождением по цепочкам
    /api/query?type=pet&name=Хулимяу&location=Kharkov&user=Иванов
    И самое прикольное, что этот URI можно дополнить, чтобы избежать конфликта имён:
    /api/query?type=pet&name=Хулимяу&user.location=Kharkov&user.name=Иванов&pet.haveChildrens=true&fields=name
    Главная идея – это сохранить иерархию “вверх”. Т.е. название еды барсика такой запрос не выдаст, а вот координаты города – пожалуйста.

    Фууух.

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

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

  14. А как сделать если: запрос приходит на один контроллер а ответ долже прийти другому?

  15. А как сделать если: запрос приходит на один контроллер а ответ должен прийти пользователю через другой контроллер?

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.