Разработка 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, Error $error) { return $response->withStatus(500) ->withJson([ 'error' => 'bad_request', 'error_description' => 'Error: ' . $error->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
, нам надо как-то идентифицировать пользователей.
Рассмотрим, как мы пришли к 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
к нашему проекту, настроили авторизацию и все протестировали.