В блог

Разработка RESTful API

В каждом проекте, где есть связь с внешним миром, необходима разработка API (Application programming interface).

С помощью API мы можем обмениваться данными между нашим web-приложением и любой внешней системой.

Один из примеров – мобильные приложения. Чтобы в мобильном приложении появились какие-то данные: лента новостей, список заказов или просто график работы, необходимо разработать API.

Slim Framework

Для сложных реализаций API мы используем Slim Framework – микрофреймворк, позволяющий быстро и качественно разработать API.

Устанавливается при помощи composer:

php composer.phar create-project slim/slim-skeleton [app-name]

Далее вся работа будет основываться на двух вещах: маршруты (routes) и посредники (middleware).

Маршруты

Маршрутами мы определяем какой запрос надо выполнить в зависимости от URL.

Маршруты можно разграничить по HTTP-методам:

<?php

$app->get('/action/{id}', function ($request, $response, $args) {
    // Получаем информацию по акции $args['id']
    // Будет приниматься только GET-запрос
});

$app->post('/action', function ($request, $response, $args) {
    // Добавляем новую акцию
    // Будет приниматься только POST-запрос
});

$app->put('/action/{id}', function ($request, $response, $args) {
    // Обновляем акцию $args['id']
    // Будет приниматься только PUT-запрос
});

$app->delete('/action/{id}', function ($request, $response, $args) {
    // Удаляем акцию $args['id']
    // Будет приниматься только DELETE-запрос
});

$app->any('/action/{id}', function ($request, $response, $args) {
    // Будет доступно для любого HTTP-метода
});

Параметры запроса могут быть обязательными или не обязательными.

<?php

// {city} - обязательный
$app->get('/{city}/actions', function (Request $request, Response $response, array $args) {
    $items = [];

   // Подбираем акции в зависимости от города.
   // Если город не указан – выдаем ошибку.

   return $response->withJson(['response' => 'success', 'actions' => $items]);
});

// {city} - не обязательный
$app->get('/actions[/{city}]', function (Request $request, Response $response, array $args) {
    $items = [];

   // Выбираем все акции, если город отсутствует, либо фильтруем по городу, если он указан

   return $response->withJson(['response' => 'success', 'actions' => $items]);
});

$app->get('/actions/{id:[0-9]+}', function ($request, $response, $args) {
    // В качестве $args['id'] будут приниматься только числа
});

В Slim Framework есть штатный обработчик ошибок:

  • Если маршрут не найден – notFoundHandler
  • Если доступ к маршруту запрещен с данным HTTP-методом – notAllowedHandler
  • Если был выброшен Exception – errorHandler
  • Runtime-ошибка PHP (PHP 7.0+) – phpErrorHandler

Стандартно в Slim ошибки выдаются в формате text/html. Так как мы используем формат JSON, то нам необходимо переопределить стандартный вывод ошибок.

Для этого в файле settings.php дописываем:

'notFoundHandler' => function () {
        return function (Request $request, Response $response) {
            return $response->withStatus(404)
                ->withJson([
                    'error' => 'not_found',
                    'error_description' => 'Method not found'
                ]);
        };
    },
    'errorHandler' => function () {
        return function (Request $request, Response $response, Exception $exception) {
            return $response->withStatus(500)
                ->withJson([
                    'error' => 'bad_request',
                    'error_description' => 'Error: ' . $exception->getMessage()
                ]);
        };
    },
    'notAllowedHandler' => function () {
        return function (Request $request, Response $response) {
            return $response->withStatus(405)
                ->withJson([
                    'error' => 'not_allowed',
                    'error_description' => 'Method not allowed'
                ]);
        };
    },
    'phpErrorHandler' => function () {
        return function (Request $request, Response $response, Exception $exception) {
            return $response->withStatus(500)
                ->withJson([
                    'error' => 'bad_request',
                    'error_description' => 'Error: ' . $exception->getMessage()
                ]);
        };
    },

Middleware

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

Посредников можно использовать для логирования запросов, добавления CORS-заголовков и многого другого.

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

$app->add(function ($request, $response, $next) {
    // Глобальный посредник
    // Делаем что-то с $request или $response

    $response = $next($request, $response);

    return $response;
});

$middleware = function ($request, $response, $next) {
    // Локальный посредник
    // Делаем что-то с $request или $response

    $response = $next($request, $response);

    return $response;
};

$app->get('/users', function (Request $request, Response $response, array $args) {
    $items = [];

    return $response->withJson(['response' => 'success', 'users' => $items]);
})->add($middleware); // Возможно использовать цепочку посредников

Все посредники должны соответствовать стандарту PSR-7

Помимо всего прочего, посредников можно использовать для авторизации пользователя. В нашем руководстве мы рассмотрим авторизацию по протоколу OAuth 2.0.

OAuth 2.0

OAuth 2.0 — протокол авторизации, позволяющий выдать приложению права на доступ к ресурсам пользователя на другом сервисе. Протокол избавляет от необходимости доверять приложению логин и пароль, а также позволяет выдавать ограниченный набор прав, а не все сразу.

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

ac5fba47ac58f958235b2.jpg

Рассмотрим, как мы пришли к OAuth 2.0 и почему другие варианты не прижились:

1. Первый вариант, который мы попробовали был – авторизационные токены (access_token), привязывались к аккаунту на сайте.

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

Минусы такого подхода обнаружились практически сразу: если пользователь использовал несколько устройств, то его разлогинивало на всех устройствах, кроме последнего (ведь access_token поменялся при последнем входе и актуален только для нового устройства).

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

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

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

К минусам способа можно отнести слабую защиту: access_token выдавался без ограничения по времени, а также нельзя было контролировать права доступа.

3. OAuth 2.0. Протокол по которому access_token действителен в течение ограниченного количества времени (например, 1 час), у каждого access_token есть специальный токен обновления (refresh_token), действующий тоже ограниченное время (например, 1 месяц). После того, как access_token истек, можно запросить новый с использованием refresh_token, если и он истек (пользователь месяц не заходил в приложение) – необходимо пройти авторизацию заново.

Плюсы этого подхода в первую очередь – безопасность. Ведь ключи доступа, ограниченные по времени надежнее безлимитных :)

Также по протоколу можно выдавать права доступа: с помощью одного access_token можно, например, только получать данные, а с помощью другого еще и записывать.

Подключаем OAuth 2.0 к Slim Framework

Для начала подключим зависимости и создаем необходимые таблицы в базе данных:

php composer.phar require chadicus/slim-oauth2
CREATE TABLE oauth_clients (
  client_id    VARCHAR(80)    NOT NULL,
  client_secret    VARCHAR(80),
  redirect_uri    VARCHAR(2000),
  grant_types    VARCHAR(80),
  scope    VARCHAR(4000),
  user_id    VARCHAR(80),
  PRIMARY KEY (client_id)
);

CREATE TABLE oauth_access_tokens (
  access_token    VARCHAR(40)    NOT NULL,
  client_id    VARCHAR(80)    NOT NULL,
  user_id    VARCHAR(80),
  expires    TIMESTAMP    NOT NULL,
  scope    VARCHAR(4000),
  PRIMARY KEY (access_token)
);

CREATE TABLE oauth_authorization_codes (
  authorization_code    VARCHAR(40)    NOT NULL,
  client_id    VARCHAR(80)    NOT NULL,
  user_id    VARCHAR(80),
  redirect_uri    VARCHAR(2000),
  expires    TIMESTAMP    NOT NULL,
  scope    VARCHAR(4000),
  id_token    VARCHAR(1000),
  PRIMARY KEY (authorization_code)
);

CREATE TABLE oauth_refresh_tokens (
  refresh_token    VARCHAR(40)    NOT NULL,
  client_id    VARCHAR(80)    NOT NULL,
  user_id    VARCHAR(80),
  expires    TIMESTAMP    NOT NULL,
  scope    VARCHAR(4000),
  PRIMARY KEY (refresh_token)
);

CREATE TABLE oauth_scopes (
  scope                VARCHAR(80)     NOT NULL,
  is_default          BOOLEAN,
  PRIMARY KEY (scope)
);

Далее нам необходимо создать класс для проверки авторизации пользователей, рассмотрим на примере системы 1С-Битрикс.

<?php

namespace App\Storage;

use Bitrix\Main\UserTable;
use OAuth2\Storage\Pdo;

class Bitrix extends Pdo
{
    public function __construct($connection, array $config = [])
    {
        $config['user_table'] = 'b_user';

        parent::__construct($connection, $config);
    }

    public function getUser($username)
    {
        $userInfo = UserTable::getList([
            'filter' => ['LOGIN' => $username],
            'select' => ['ID', 'UF_SMS_CODE']
        ])->fetch();

        if (!$userInfo) {
            return false;
        }

        return array_merge([
            'user_id' => $userInfo['ID']
        ], $userInfo);
    }

    public function setUser($username, $password, $firstName = null, $lastName = null)
    {
        // do nothing
    }

    protected function checkPassword($user, $password)
    {
        return $password === $user['UF_SMS_CODE'];
    }
}

Далее в файле dependencies.php настраиваем OAuth 2.0 сервер:

<?php

use App\Storage\Bitrix;
use OAuth2\GrantType;
use OAuth2\Server;

$pdo = new \PDO('mysql:host=localhost;dbname=dbname', 'dbuser', 'dbpass');

$storage = new Bitrix($pdo);

$server = new Server(
    $storage,
    [
        'access_lifetime' => 3600, // 1 час
        'refresh_token_lifetime' => 2592000, // 30 дней
    ],
    [
        new GrantType\UserCredentials($storage),
        new GrantType\RefreshToken($storage, [
            'always_issue_new_refresh_token' => true,
            'unset_refresh_token_after_use' => true,
        ]),
    ]
);

В файле middleware.php:

<?php

use Chadicus\Slim\OAuth2\Middleware;

$container = $app->getContainer();

$oAuthMiddleware = new Middleware\Authorization($server, $container);

И добавляем авторизацию к маршрутам:

<?php

use Chadicus\Slim\OAuth2\Routes\Revoke;
use Chadicus\Slim\OAuth2\Routes\Token;

// Служебные маршруты
$app->post('/oauth/token', new Token($server));
$app->post('/oauth/revoke', new Revoke($server));

$app->get('/me', function (Request $request, Response $response, array $args) use ($server) {
    $token = $server->getAccessTokenData(OAuth2\Request::createFromGlobals());
    $userId = $token['user_id']; // ID Пользователя, которого мы определили в классе App\Bitrix

    $data = [];

    return $response->withJson(['me' => $data]);
})->add($oAuthMiddleware);

После этого запрос /me будет доступен либо с параметром ?access_token=token, либо с заголовком Authorization: Bearer token.

Получить access_token можно запросом:

curl "http://localhost/oauth/token" -d 'grant_type=password&username=someuser&password=somepassword&client_id=app&client_secret=app_secret&scope=users,messages,events'
{"access_token":"206c80413b9a96c1312cc346b7d2517b84463edd","expires_in":3600,"token_type":"bearer","scope":"users,messages,events", "refresh_token": "c54adcfdb1d99d10be3be3b77ec32a2e402ef7e3"}

Обновить access_token:

curl "http://localhost/oauth/token" -d 'grant_type=refresh_token&refresh_token=c54adcfdb1d99d10be3be3b77ec32a2e402ef7e3'
{"access_token":"206c80413b9a96c1312cc346b7d2517b84463edd","expires_in":3600,"token_type":"bearer","scope":"users,messages,events"}

Тестирование

Чтобы при добавлении нового API случайно не сломать старый, мы добавляем автотесты.

Для тестирования RESTful API мы используем Codeception, который ставится также через composer:

php composer.phar require "codeception/codeception" --dev
После установки, выполняем команду:
./vendor/bin/codecept init api
В результате чего, должен появиться файл codeception.yml с примерно таким содержимым и папка tests:
# suite config
suites:
  api:
    actor: ApiTester
    path: .
    modules:
      enabled:
        - REST:
            url: http://localhost/api
            depends: PhpBrowser

paths:
  tests: tests
  output: tests/_output
  data: tests/_data
  support: tests/_support

settings:
  shuffle: false
  lint: true

Файлы с тестами должны иметь постфикс Cest. Создадим наш первый тест: ApiDictionariesCest.php.

<?php

class ApiDictionariesCest
{
    public function tryToGetLanguages(ApiTester $I)
    {
        $I->sendGET('/languages'); // Отправляем GET-запрос на /languages
        $I->seeResponseCodeIs(200); // В ответ должны получить статус 200
        $I->seeResponseIsJson(); // Ответ должен быть в валидном JSON формате
        $I->seeResponseMatchesJsonType([ // Ответ должен соответствовать структуре
            'languages' => [
                [
                    'id' => 'integer|string',
                    'name' => 'string',
                ]
            ],
        ]);
    }
}

Усложним задачу, добавив к тестам добавление, изменение и удаление данных:

<?php

class ApiUserCest
{
    protected $userId = 0;
    protected $access_token = '';

    public function _before(ApiTester $I) // Выполняется перед тестированием
    {
        // Регистрируем пользователя
        $I->sendPOST('/register', [
            'phone' => '+79999999999'
        ]);
        $I->seeResponseCodeIs(200);
        $I->seeResponseIsJson();

        $response = json_decode($I->grabResponse(), true); // Парсим ответ
        $this->userId = $response['id']; // Получаем ID нового пользователя

        // Выполняем авторизацию с помощью OAuth 2.0
        $I->sendPOST('/oauth/token', [
            'grant_type' => 'password',
            'client_id' => 'app',
            'client_secret' => 'app_secret',
            'username' => '79999999999',
            'password' => '9999',
            'scope' => 'users',
        ]);

        $I->seeResponseCodeIs(200);
        $I->seeResponseIsJson();

        $token = json_decode($I->grabResponse(), true);
        $token = $token['access_token'];

        $this->access_token = $token;
        $I->amBearerAuthenticated($token);
    }

    public function tryToGetInfoAboutMe(ApiTester $I)
    {
        $I->sendGET('/me');
        $I->seeResponseCodeIs(200);
        $I->seeResponseIsJson();
    }

    public function tryToUpdateMyName(ApiTester $I)
    {
        $I->sendPOST('/me', [
            'last_name' => 'Тестовый',
            'name' => 'Тест'
        ]);
        $I->seeResponseCodeIs(200);
        $I->seeResponseIsJson();
    }

    public function tryToDeleteMe(ApiTester $I)
    {
        $I->sendDELETE('/me');
        $I->seeResponseCodeIs(200);
        $I->seeResponseIsJson();
    }

    public function tryToRevokeToken(ApiTester $I)
    {
        $I->sendPOST('/oauth/revoke', [
            'token' => $this->access_token
        ]);
        $I->seeResponseCodeIs(200);
        $I->seeResponseIsJson();
    }
}

Для запуска тестов запускаем команду:

./vendor/bin/codecept run

// Результат
Api Tests (5) --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
✔ ApiDictionariesCest: Try to get languages (0.81s)
✔ ApiUserCest: Try to get info about me (1.19s)
✔ ApiUserCest: Try to update my name (0.21s)
✔ ApiUserCest: Try to delete me (0.71s)
✔ ApiUserCest: Try to revoke token (0.52s)
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Time: 6.69 seconds, Memory: 10.00MB

OK (5 tests, 26 assertions)

Таким образом мы добавили API к нашему проекту, настроили авторизацию и все протестировали.

Хочу проект
Закрыть