Иногда бывает необходимо развернуть не большое рест апи для своего сайта, сделанного по технологии СПА (Vue, React или др.) без использования каких-либо фреймворков, CMS или чего-то подобного, и при этом хочется воспользоваться обычным php хостингом с минимальными усилиями на внедрение и разработку. При этом там же желательно разместить и сам сайт СПА (в нашем случае на vue).
Использование php позволяет для построения ендпоинтов апи использовать даже статические php файлы, размещаемые просто в папках на хостинге, которые предоставляют результат при непосредственном обращении к ним. И хотя, видимо в своё время, такой подход послужил широкому распространению php мы рассмотрим далее более программистский подход к созданию апи, который очень похож на используемый в библиотеке Node.js Express и поэтому интуитивно понятен, и прост для освоения. Для это нам понадобиться библиотека "pecee/simple-router".
Далее мы предполагаем, что у вас уже есть среда для запуска кода локально (LAMP, XAMP, docker) или как-то иначе и у вас настроено перенаправление всех запросов на индексный файл (index.php). Кроме, того мы предполагаем, что вы можете устанавливать зависимости через composer.
Структура проекта
index.phpв папке web. Сама папка webявляется публично доступной папкой, и должна быть указана в настройках сервера как корневая. В папке configбудут находится настройки роутов наших ендпоинтов. В папке controllerбудут обработчики ендпоинтов маршрутов. В папке middlewaresмы разместим промежуточные обработчике роутов для выполнения авторизации перед началом основного кода ендпоинта. В папках exceptions, views и models будут соответственно исключения, html шаблон и объектные модели. Полный код проекта тут.
Инсталляция и запуск
Для работы необходимо инсталлировать следующее содержимое composer.json (composer install в корне проекта).
// composer.json
{
"require": {
"pecee/simple-router": "*",
"lcobucci/jwt": "^3.4",
"ext-json": "*"
},
"autoload": {
"psr-4": {
"app\\": ""
}
}
}
Обратите внимание, что ‘app\’ объявлено как префикс для namespace. Данный префикс будет использоваться при объявлении неймспейсов классов.
Запуск всего остального кода происходит вызовом статического метода Router::route() в файле index.php
<?php
//index.php
use Pecee\SimpleRouter\SimpleRouter as Router;
require_once __DIR__ . '/../vendor/autoload.php';
require_once (__DIR__ . '/../config/routes.php');
Router::start();
Так же тут подключаются роуты определённые в файле config/routes.php.
Подключение SPA на Vue.js 2 к проекту на php
Если вы развёртываете сборку vue отдельно от апи, то этот раздел можно пропустить.
Рассмотрим теперь то, как подключить проект на vue в данной конфигурации с использованием соответствующих маршрутов. Для этого содержимое сборки необходимо поместить в папку web. В файле маршрутов (‘/config/routes.php’) прописываем два правила:
<?php
use Pecee\{
SimpleRouter\SimpleRouter as Router
};
Router::setDefaultNamespace('app\controllers');
Router::get('/', 'VueController@run'); // правило 1
Router::get('/controller', 'VueController@run')
->setMatch('/\/([\w]+)/'); // правило 2
Для пустого (корневого) маршрута '/' вызывается метод run класса VueController. Второе правило указывает что для любого явно незаданного пути будет тоже вызываться VueController, чтобы обработка маршрута происходила на стороне vue. Это правило всегда должно быть последним, чтобы оно срабатывало только тогда, когда другие уже не сработали. Метод run представляет собой просто рендеринг файла представления с помощью метода renderTemplate(), определённого в родительском классе контроллера. Здесь мы также устанавливаем префикс для классов методы которых используются в роутах с помощью setDefaultNamespace.
<?php
namespace app\controllers;
class VueController extends AbstractController
{
public function run()
{
return $this->renderTemplate('../views/vue/vue_page.php');
}
}
В свою очередь представление vue_page.php тоже просто отрисовка индексного файла сборки vue.
<?php
// vue_page.php
include (__DIR__ . '/../../web/index.html');
Итого мы подключили проект на vue к проекту на php, который уже готов к развертыванию на хостинге. Данный подход можно использовать для любых проектов на php. Осталось только рассмотреть, что собой представляет родительский класс AbstractController.
<?php
namespace app\controllers;
use Pecee\Http\Request;
use Pecee\Http\Response;
use Pecee\SimpleRouter\SimpleRouter as Router;
abstract class AbstractController
{
/**
* @var Response
*/
protected $response;
/**
* @var Request
*/
protected $request;
public function __construct()
{
$this->request = Router::router()->getRequest();
$this->response = new Response($this->request);
}
public function renderTemplate($template) {
ob_start();
include $template;
return ob_get_clean();
}
public function setCors()
{
$this->response->header('Access-Control-Allow-Origin: *');
$this->response->header('Access-Control-Request-Method: OPTIONS');
$this->response->header('Access-Control-Allow-Credentials: true');
$this->response->header('Access-Control-Max-Age: 3600');
}
}
В конструкторе класса AbstractController определяются поля $request и $response. В $request хранится распарсенный классом Pecee\Http\Router запрос. А $response будет использоваться для создания ответов на запросы к апи. Определённый здесь метод renderTemplate используется для рендеринга представлений (html страниц). Кроме того, здесь определён метод устанавливающий заголовки для работы с политикой CORS. Его следует использовать если запросы к апи происходят не с того же адреса, т.е. если сборка vue запускается на другом веб-сервере. Теперь перейдём непосредственно к созданию апи.
Создание REST API эндпоинтов
Для работы с апи нам нужно произвести дополнительную обработку входящего запроса, потому что используемая библиотека не производит парсинг сырых данных. Для этого создадим промежуточный слой ProccessRawBody и добавим его как middleware в роуты для запросов к апи.
<?php
namespace app\middlewares;
use Pecee\Http\Middleware\IMiddleware;
use Pecee\Http\Request;
class ProccessRawBody implements IMiddleware
{
/**
* @inheritDoc
*/
public function handle(Request $request): void
{
$rawBody = file_get_contents('php://input');
if ($rawBody) {
try {
$body = json_decode($rawBody, true);
foreach ($body as $key => $value) {
$request->$key = $value;
}
} catch (\Throwable $e) {
}
}
}
}
Здесь мы считываем из входного потока и помещаем полученное в объект $request для дальнейшего доступа из кода в контроллерах. ProccessRawBody реализует интерфейс IMIddleware обязательный для всех middleware.
Теперь создадим группу роутов для работы с апи использующее данный промежуточный слой.
<?php
// routes.php
Router::group([
'prefix' => 'api/v1',
'middleware' => [
ProccessRawBody::class
]
], function () {
Router::post('/auth/sign-in', 'AuthController@signin');
Router::get('/project', 'ProjectController@index');
});
У этой группы определён префикс «api/v1» (т.е. полный путь запроса должен быть например '/api/v1/auth/sign-in'), и ранее определённое нами middleware ProccessRawBody::class, так что в контроллерах наследованных от AbstractController доступны входные переменные через $request. AuthController рассмотрим чуть позже сейчас же мы уже можем воспользоваться методами не требующими авторизации, как например ProjectController::index.
<?php
namespace app\controllers;
class ProjectController extends AbstractController
{
public function index():string
{
// Какая-то логика для получения данных тут
return $this->response->json([
[
'name' => 'project 1'
],
[
'name' => 'project 2'
]
]);
}
}
Как видим, на входящий запрос, в ответе возвращаются данные о проектах.
Остальные роуты создаются аналогичным образом.
Авторизация по JWT токену
Теперь перейдём к роутам требующим авторизации. Но перед этим реализуем вход и получение jwt-токена. Для создания токена и его валидации мы будем использовать библиотеку “ lcobucci/jwt” Всё это будет у нас выполнятся по роуту определённому ранее '/auth/sign-in'. Соответственно в AuthController::singin у нас прописана логика выдачи jwt-токена после авторизации пользователя.
<?php
namespace app\controllers;
use app\models\Request;
use ArgumentCountError;
use DateTimeImmutable;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
class AuthController extends AbstractController
{
public function signin()
{
// Тут код авторизующий пользователя
$config = Configuration::forSymmetricSigner(
new Sha256(),
InMemory::plainText('секретный_ключ')
);
$now = new DateTimeImmutable();
$token = $config->builder()
// Configures the issuer (iss claim)
->issuedBy('http://example.com')
// Configures the audience (aud claim)
->permittedFor('http://example.org')
// Configures the id (jti claim)
->identifiedBy('4f1g23a12aa')
// Configures the time that the token was issue (iat claim)
->issuedAt($now)
// Configures the expiration time of the token (exp claim)
->expiresAt($now->modify('+2 minutes'))
// Configures a new claim, called "uid"
->withClaim('uid', $user->id)
// Configures a new header, called "foo"
->withHeader('foo', 'bar')
// Builds a new token
->getToken($config->signer(), $config->signingKey());
return $this->response->json([
'accessToken' => $token->toString()
]);
}
}
Здесь используется симметричная подпись для jwt с использованием секретного ключа 'секретный_ключ'. По нему будет проверятся валидность токена при запросах к апи. Ещё можно использовать асимметричную подпись с использованием пары ключей.
Можно также отметить, что можно создавать сколько угодно клаймов ->withClaim('uid', $user->id) и сохранять там данные которые можно будет потом извлекать из ключа. Например, id пользователя для дальнейшей идентификации запросов от этого пользователя. Токен выдан на 2 минуты (->expiresAt($now->modify('+2 minutes'))) после чего он становится не валидным. ->issuedBy и ->permittedFor используются для oath2.
Теперь создадим группу роутов защищённую авторизацией. Для этого определим для группы роутов промежуточный слой Authenticate::class.
<?php
//routes.php
Router::group([
'prefix' => 'api/v1',
'middleware' => [
ProccessRawBody::class
]
], function () {
Router::post('/auth/sign-in', 'AuthController@signin');
Router::get('/project', 'ProjectController@index');
Router::group([
'middleware' => [
Authenticate::class
]
], function () {
// authenticated routes
Router::post('/project/create', 'ProjectController@create');
Router::post('/project/update/{id}', 'ProjectController@update')
->where(['id' => '[\d]+']);
});
});
Как видите, группа с авторизацией объявлена внутри группы с префиксом “api/v1 ”. Рассмотрим роут '/project/update/{id}'. Здесь объявлен параметр id который определён как число. В метод update, контроллера Projectcontroller будет передана переменная $id содержащая значение этого параметра. Ниже приведён пример запроса и ответ.
<?php
namespace app\controllers;
class ProjectController extends AbstractController
{
/**
* post /api/v1/project/update/3
* body:
{
"project": {
"prop": "value"
}
}
*/
public function update(int $id): string
{
// код обновляющий проект
return $this->response->json([
[
'response' => 'OK',
'request' => $this->request->project,
'id' => $id
]
]);
}
}
Вернёмся теперь к промежуточному слою Authenticate::class с помощью которого происходит авторизация запросов к апи.
<?php
namespace app\middlewares;
use app\exceptions\NotAuthorizedHttpException;
use DateTimeImmutable;
use Lcobucci\Clock\FrozenClock;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Validation\Constraint\SignedWith;
use Lcobucci\JWT\Validation\Constraint\ValidAt;
use Pecee\Http\Middleware\IMiddleware;
use Pecee\Http\Request;
class Authenticate implements IMiddleware
{
public function handle(Request $request): void
{
$headers = getallheaders();
$tokenString = substr($headers['Authorization'] ?? '', 7);
$config = Configuration::forSymmetricSigner(
new Sha256(),
InMemory::plainText('секретный_ключ')
);
$token = $config->parser()->parse($tokenString);
if (
!$config->validator()->validate(
$token,
new SignedWith(
new Sha256(),
InMemory::plainText('секретный_ключ')
),
new ValidAt(new FrozenClock(new DateTimeImmutable()))
)
) {
throw new NotAuthorizedHttpException('Токен доступа не валиден или просрочен');
}
$userId = $token->claims()->get('uid');
$request['uid'] = $userId;
}
}
Здесь, считывается заголовок ‘Authorization: Bearer [token]’ (так называемая bearer авторизация) и извлекается оттуда токен, которые клиенты получают после логина и должны посылать со всеми запросами, требующими авторизацию. Далее с помощью парсера jwt-токен-строчка парсится. И дальше с помощью валидатора распарсенный токен валидируется. Метод validate() возвращает true or false. В случае не валидного токена выбрасывается исключение NotAuthorizedException. Если токен валидный, то мы извлекаем из него id пользователя $token->claims()->get('uid') и сохраняем в переменную запроса $request, чтобы его можно было использовать дальше в контроллере. NotAuthorizedException определяется следующим образом:
<?php
namespace app\exceptions;
class NotAuthorizedHttpException extends \Exception
{
}
В завершении рассмотрим ещё обработку ошибок. В файле routes.php запишем следующие строчки:
<?php
//routes.php
Router::error(function(Request $request, Exception $exception) {
$response = Router::response();
switch (get_class($exception)) {
case NotAuthorizedHttpException::class: {
$response->httpCode(401);
break;
}
case Exception::class: {
$response->httpCode(500);
break;
}
}
if (PROD) {
return $response->json([]);
} else {
return $response->json([
'status' => 'error',
'message' => $exception->getMessage()
]);
}
});
В итоге файл routes.php будет выглядеть следующим образом:
<?php
//routes.php
use app\exceptions\{
NotAuthorizedHttpException
};
use app\middlewares\{
Authenticate,
ProccessRawBody
};
use Pecee\{
Http\Request,
SimpleRouter\SimpleRouter as Router
};
const PROD = false;
Router::setDefaultNamespace('app\controllers');
Router::get('/', 'VueController@run');
Router::group([
'prefix' => 'api/v1',
'middleware' => [
ProccessRawBody::class
]
], function () {
Router::post('/auth/sign-in', 'AuthController@signin');
Router::get('/project', 'ProjectController@index');
Router::group([
'middleware' => [
Authenticate::class
]
], function () {
// authenticated routes
Router::post('/project/create', 'ProjectController@create');
Router::post('/project/update/{id}', 'ProjectController@update')
->where(['id' => '[\d]+']);
});
});
Router::get('/controller', 'VueController@run')
->setMatch('/\/([\w]+)/');
Router::error(function(Request $request, Exception $exception) {
$response = Router::response();
switch (get_class($exception)) {
case NotAuthorizedHttpException::class: {
$response->httpCode(401);
break;
}
case Exception::class: {
$response->httpCode(500);
break;
}
}
if (PROD) {
return $response->json([]);
} else {
return $response->json([
'status' => 'error',
'message' => $exception->getMessage()
]);
}
});
Заключение
В итоге у нас получилось небольшое, простое REST api для небольших проектов которое можно использовать на обычном php хостинге с минимальными трудозатратами на его (хостинга) настройку. Полный код проекта тут.
Больше настроек роутов можно найти здесь. Вместо рассмотренной библиотеки "pecee/simple-router" можно использовать любую другую аналогичную библиотеку или даже микрофреймворк Slim.
Пс. Если вы используете публичный репозиторий или придерживаетесь бестпрактис, то не следует хранит секретный ключ в коде. Для этого можно использовать переменные среды или локальные файлы, которые не добавляются в репозиторий. Код работы с jwt токенами можно выделить в отдельный класс в папке services.