Разработка 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 к нашему проекту, настроили авторизацию и все протестировали.
