Иногда бывает необходимо развернуть не большое рест апи для своего сайта, сделанного по технологии СПА (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.
FanatPHP
В целом интересно, хотя на мой взгляд немного сумбурно. Причем как в статье, так и в коде. Я понимаю, что пример небольшой, но файл routes.php получился какой-то и швец, и жнец и на дуде игрец. Плюс стандартное замечание про vendor в гите.
Не совсем понятно про обработку ошибок. В частности, пустой try-catch в ProccessRawBody (во-первых, непонятен смысл пустого try-catch, а во-вторых, вроде бы, json_decode сам по себе исключения не кидает, а только с определённым флагом). Не лучше ли было бы переписать как-то так:
И по функции-обработчику, я бы поменял Exception::class на Throwable::class и добавил бы логирование в PROD режиме, а то на бою долго придется причину ошибки искать.
mozg3000tm Автор
Да, получилось сумбурно). Не хотел очень в подробности закапываться, чтобы сам подход остался виден. В конечном итоге это просто использование "pecee/simple-router" или аналогичной библиотеки + немного ООП стиля.
mozg3000tm Автор
Да, я тоже так подумал, но эта библиотека, как я понял, выбрасывает именно Exception. Сам этот код из их примера.
FanatPHP
Но сам-то РНР выбрасывает и те и другие. И если было брошено Throwable, то статус тоже должен быть 500, а не 200.
mozg3000tm Автор
Да, но сюда Router::error() оно не попадёт.
Для общего случая подойдёт:
Но я ориентировался на целевую аудиторию фронтендеров и тех кто не гнушается простыми php файлами для создания сайта, поэтому такие вещи думаю выходят за рамки.
FanatPHP
Прекрасно всё попадёт. И даже если представить, что не не попадёт, то чем Throwable помешает?
И опять этот пустой try-catch, который в сто раз хуже. Это, блин, как страус, который голову в песок воткнул, и думает что если он хищника не видит, то и хищник его не видит. Если я ошибок не вижу, то их типа и нет?
В данном случае я не вижу, что здесь "выходит за рамки". Убрать try-catch? не убирать, но добавить хотя бы error_log? Сделать код в handle осмысленным, чтобы он не пытался ловить исключение, которое никто и не выбрасывал?
mozg3000tm Автор
Ну, я тут не спорю с вашим посылом, потому что сам думал аналогично, и добавлял Throwable туда, но ничего не приходило.
Сейчас я глянул исходники и там прописано Exception.
FanatPHP
Да, теперь я вижу, что там такой обработчик исключений на пальцах и try-catch, который ловит только Exception. Судя по всему, либо этот роутер писался лет 10 назад, либо автор у ну совсем не понимает языка, на котором пишет. Потому что сейчас этот "обработчик" пропускает половину исключений.
mozg3000tm Автор
Пустой try-catch для того чтобы на публику не выбрасывалось описание ошибки, ибо чего там только может не быть. Вплоть до паролей. Тут не фреймворк и в прод окружении само собой исключения не пропадают (это магия? :-))
Поэтому считаю этот try-catch must have хоть и пустой.
mozg3000tm Автор
вот это всё и выходит :)))
Я не силён в этой теме.
Почему я считаю что пустой try-catch нужен я написал в другом ответе.
Пс. Тут недавно узнал что можно задать свой обработчик исключений через set_error_handler. Но опять же...
FanatPHP
Ну вот "я сам не силён" это гораздо честнее, чем "я пишу для тех, кто не силён, поэтому попроще" )))
Пустой try-catch — это не "must have", а дурость. За которую очень больно бьют. Это полное, стопроцентное отсутствие опыта. Потому что любой мало-мальчки опытный программист быстро учится ценить ошибки на вес золота.
Поэтому для тех, у кого своего опыта ещё нету: если произошла ошибка, то программист должен её увидеть. Должен. Увидеть. Несмотря ни на что. Чтобы не сидеть, тупить в пустой экран, когда что-то не работает. потому что именно для этого ошибки и придуманы — чтобы сообщить программисту, что с его программой не так. А не для того, чтобы такие вот, которые "не сильны", не глядя их на помойку выбрасывали.
А с публикой всё ещё смешнее. Надо просто не путать выброс ошибки и её отображение. Выброс должен быть всегда. Отображение — по потребности.
Достаточно всего лишь поставить в настройках РНР display_errors=0, и никаких "паролей" никакая "публика" не увидит. При этом у программиста возможность увидеть ошибку сохранится. Поэтому никаких пустых try-catch и прочих радостей, типа @, error_reporting(0) в коде быть не должно.
mozg3000tm Автор
Спасибо за "display_errors".
Хорошо. Я плохо ориентируюсь в ... (не знаю к чему относится "display_errors", настройка php?).
FanatPHP
Да, это одна из настроек php.ini
Она, разумеется, на любом боевом сервере должна быть выключена.
Только поправочка — не "try-catch", а именно пустой try-catch — это дурость.
Сам по себе try-catch бывает полезен, но только не для управления отображением ошибок.
По поводу set_error_handler. Это очень хорошее решение, позволяет избавиться от всего этого зоопарка из try-catch. Но ей одной не обойдёшься, нужно ещё set_exception_handler(), и, по-хорошему, register_shutdown_function(), чтобы ловить фатальные ошибки. Вот есть статья с примером, https://phpdelusions.net/articles/error_reporting
Здорово всё проясняет. Я всё хочу её на Хабр перевести, но никак не соберусь.
mozg3000tm Автор
Кстати, изначально я сделал не пустой try-catch, и только потом увидел что там есть их родной обработчик и решил использовать его.
Конечно, этой теме не учат в школе, поэтому получается так как получается, пока не найдётся кто-то кто по рукам даст :).
вот что я начинал делать тут.
FanatPHP
Ну кстати вот сильно лучше чем у него. Только всё это лучше писать в set_exception_handler — просто чтобы вынести это всё в отдельный файл/класс, а не городить прямо в индекс.
Только ловить, опять же, не Error а Throwable, как самое высокоуровневое
Плюс надо добавить логирование, поскольку если мы сами обрабатываем ошибку, то РНР её уже не залогирует.
И в ПРОД режиме я бы отправлял не пустой массив, а всё-таки что-то осмысленное. 'status' => 'Server error' и 'code' => 500 по-любому можно отдать.
mozg3000tm Автор
Именно этим мне не понравилось использование этой библиотеки. Тут, как я вижу, остаётся только выносить роуты в массив и динамически создавать их, но я пока отказался от развития этого направления, потому что не смог пока интегрировать сюда контейнер зависимостей.
FanatPHP
Ну вот и то что этот "роутер" ещё и обработкой ошибок занимается — это тоже минус в эту же копилку. Он слишком много на себя берёт, и в итоге получается каша.