Контроллеры Vesp

На прошлых уроках мы работали с миграциями Phinx и моделями Eloquent - это всё сторонние библиотеки, а где же сам Vesp?

А он в контроллерах, которые были написаны под сильным впечатлением от процессоров MODX, и работают через Slim 4. Интересный факт - MODX 3 в своё время планировали завязать именно на Slim, но что-то пошло не так, и эти планы забросили.

Итак, все запросы от пользователей приходят в один-единственный коннектор www/api.php. Он подключает всё нужное файлом core/bootstrap.php (который можно использовать во всяких консольных скриптах) и создаёт экземпляр приложения Slim.

Дальше в это приложение грузятся наши маршруты из файла core/routes.php, и теперь система знает, какой контроллер отвечает за конкретный запрос. Давайте разберёмся с маршрутами.

Маршрутизация

Первым делом смотрим в core/routes.php:

<?php

use Slim\Routing\RouteCollectorProxy;

// Все маршруты начинаются с /api/...
$group = $app->group(
    '/api',
    function (RouteCollectorProxy $group) {
        // А дальше простое перечисление кто за что отвечает
        $group->any('/security/login', App\Controllers\Security\Login::class);
        $group->any('/security/logout', App\Controllers\Security\Logout::class);
        $group->any('/user/profile', App\Controllers\User\Profile::class);

        // Контроллер вывода загруженных изображений
        $group->get('/image/{id}', App\Controllers\Image::class);

        // Контроллеры админки - группа admin внутри группы api
        $group->group(
            '/admin',
            static function (RouteCollectorProxy $group) {
                $group->any('/users[/{id}]', App\Controllers\Admin\Users::class);
                $group->any('/user-roles[/{id}]', App\Controllers\Admin\UserRoles::class);
            }
        );
    }
);

Для примера, все запросы на авторизацию должны отправляться в /api/security/login и принимать их будет контроллер App\Controllers\Security\Login.

Так как Slim 4 использует для маршрутизации FastRoute, мы можем использовать его шаблоны для указания адресов.

$group->any('/users[/{id}]', App\Controllers\Admin\Users::class);

Эта запись означает, что контроллер App\Controllers\Admin\Users отвечает и за /api/users (вывод всех пользователей) и за /api/user/10 (вывод конкретного пользователя с id = 10). Более подробно о правилах маршрутов можно почитать в документации FastRoute.

Немного практики

Тема может быть довольно сложной, поэтому давайте чуть отвлечёмся на практическую часть.

Создаём новый контроллер по пути core/src/Controllers/Web/Users.php:

<?php

// Наше новое пространство имён
namespace App\Controllers\Web;

use App\Models\User;
use Vesp\Controllers\ModelGetController;

// Контроллер должен расширять контроллеры из Vesp
class Users extends ModelGetController
{
    // Указываем с какой моделью контроллер работает
    protected $model = User::class;
}

Контроллер есть, но мы не сможем его открыть, потому что система не знает, какой адрес он обслуживает. Нужно отредактировать core/routes.php. Добавляем новую группу сразу после admin:

        $group->group(
            '/web',
            static function (RouteCollectorProxy $group) {
                // Вот и наш контроллер, отвечает только на GET запросы
                $group->get('/users[/{id}]', App\Controllers\Web\Users::class);
            }
        );

Теперь переходим по адресу http://vesp-shop.test/api/web/users и видим наших пользователей, а таже общее количество записей - у меня 4 штуки.

Более того, можно посмотреть каждого пользователя отдельно, добавив в адрес его id: http://vesp-shop.test/api/web/users/1

Довольно просто, не так ли? Теперь давайте посмотрим за счёт чего эта магия работает.

Основной котроллер Vesp

Все контроллеры Vesp наследуются от одного, основного - Vesp\Controllers\Controller. В нём есть самые основные методы:

  • scope - строка с требуемым разрешением на запуск, по умолчанию пустая
  • getProperty() - получает переданные параметры по ключу, например $this->getProperty('id') или $this->getProperty('limit')
  • setProperty() - устанавливает параметр, можно заменять присланные параметры пользователя своими
  • unsetProperty() - удаляет установленный параметр, например если юзеру нельзя указывать limit
  • getProperties() - возвращает весь массив с параметрами контроллера
  • setProperties() - заменяет все параметры своим массивом
  • response() - базовый метод ответа на запрос пользователю в формате JSON
  • success() - ответ об успешной операции, использует response() с заранее указанным кодом ответа 200
  • failure() - тоже сокращённый вызов response() с ответом о неудачном запросе, код по умолчанию 422.
  • checkScope() - метод для проверки прав пользователя
  • __invoke() - основной метод работы контроллера

Метод __invoke() является магическим и выполняется при срабатывании маршрута. Он выставляет в контроллере все нужные свойства:

  • protected $route - текущий маршрут, наследник интерфейса Slim\Interfaces\RouteInterface
  • protected $request - объект с данными запроса пользователя, наследник Psr\Http\Message\RequestInterface
  • protected $response - наследник Psr\Http\Message\ResponseInterface, используется в методе response() для вывода JSON ответа
  • protected $user - класс с текущим пользователем App\Models\User, который сделал запрос. Если юзер не авторизовался, то будет null

После сохранения всех этих нужных штук Controller, использую $this->request определяет, а каким методом поступил запрос: OPTIONS, GET, POST, PUT или PATCH - и смотрит, есть ли соответствующая функция в контроллере? И если есть - передаёт работу ей.

То есть, приходит запрос GET, контроллер пытается запустить $this->get(). Пришёл запрос POST - будет запущен метод $this->post() - очень просто.

Если нужной функции нет, юзер получит ошибку 405 Method Not Allowed.

По умолчанию в Controller прописан ровно один метод - options():

public function options(): ResponseInterface
{
    $response = $this->success();
    if (getenv('CORS')) {
        $response = $response
            ->withHeader('Access-Control-Allow-Headers', 'X-Requested-With, Content-Type, Authorization')
            ->withHeader('Access-Control-Allow-Methods', 'POST, GET, HEAD, OPTIONS, DELETE, PUT, PATCH, UPDATE');
    }

    return $response;
}

Этот метод отвечает на кросс-доменные запросы и, если параметр CORS в файле .env не выключен, то добавляет разрешающие заголовки.

Думаю, каждый разработчик встречался с запретом кросдоменных запросов в браузере, когда пытался обратиться через Ajax на другой домен. Так вот, Vesp по умолчанию отвечает на эти запросы без ошибок.

Расширяющие контроллеры

Как видите, базовый контроллер не работает с моделями, а только проверяет права, выставляет нужные свойства и передаёт дальнейшую работу функции, отвечающий за метод запроса.

А для работы с моделями у нас есть второй, самый популярный контроллер - Vesp\Controllers\ModelController, именно его почти всегда раширяют контроллеры админки.

Здесь добавляются новые свойства:

  • protected $model - обязательная модель этого контроллера, с которой он работает
  • protected $primaryKey = 'id' - первичный ключ модели
  • protected $maxLimit = 1000 - максимальное количество выбираемых строк через GET, можно отключить, указав 0 - нет лимита

Дальше идут основные методы работы с моделью:

  • put() - создание новой модели, то есть сохранение новой записи в её таблиц базы данных
  • patch() - изменение модели по её первичному ключу
  • delete() - удаление модели по ключу
  • get() - получение одной или нескольких моделей для просмотра, если количество результатов больше $this->maxLimit, то ограничивает их до этого числа

А теперь вспомогательные методы, которые вызываются из основных:

  • beforeSave() - проверка свойств модели перед сохранением. Здесь можно вернуть ошибку, типа "такое-то поле не заполнено" через $this->failure() или null, если всё в порядке.

  • afterSave() - модификация уже сохранённой модели, можно довыбирать к ней какие-то данные перед возвращением юзеру. Возвращает модель, никаких ошибок тут уже нет.

  • beforeGet() - метод модификации выборки одной модели по первиному ключу

  • beforeCount() - метод модификации выборки коллекции моделей, когда первичного ключа не указано. Вызывается перед подсчётом итогового количества выбираемых записей, поэтому именно здесь нужно указывать условия where(), join() и т.д.

  • afterCount() - метод для добавления в выборку сортировки, связей моделей и прочего, что не влияет на количество выбираемых моделей.

  • prepareRow() - метод, через который проходит каждая модель перед выводом наружу пользователю. Превращает класс модели в массив, используется и в put() и в patch(), и конечно же, в get(). В этом методе вы можете стандартизировать что именно вернётся юзеру в ответ на любой запрос.

  • beforeDelete() - проверка модели перед удалением, можно вернуть null если всё ок, или $this->failure() - если нет.

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

Остался последний, самый просто контроллер Vesp\Controllers\ModelGetController. Она расширяет ModelController и отключает все методы, кроме get(). Таким образом именно его удобнее всего использовать для публичных контроллеров без авторизации, когда пользователи не могут менять модели.

Еще немного практики

Давайте теперь вернёмся к нашему публичному контроллеру с пользователями core/src/Controllers/Web/Users.php, и добавим в него несколько условий:

<?php

namespace App\Controllers\Web;

use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Vesp\Controllers\ModelGetController;

class Users extends ModelGetController
{
    protected $model = User::class;

    // Меняем уловия перед подсчётом результатов
    protected function beforeCount(Builder $c): Builder
    {
        // Выбираем только активных пользователей
        $c->where('active', true);

        // Если прислали параметр query - это поиск
        if ($query = $this->getProperty('query')) {
            // Добавляем вложенное условие для фильтрации по username и fullname
            $c->where(static function (Builder $c) use ($query) {
                $c->where('username', 'LIKE', "%$query%");
                $c->orWhere('fullname', 'LIKE', "%$query%");
            });
        }

        // И возвращаем модифицированный запрос
        return $c;
    }

    // Меняем запрос после подсчёта результатов
    protected function afterCount(Builder $c): Builder
    {
        // Если не указана сортировка - сортируем по username
        if (!$this->getProperty('sort')) {
            $c->orderBy('username');
        }

        return $c;
    }

    // Ну и подготовка ответа
    public function prepareRow(Model $object): array
    {
        /** @var User $object */
        // Здесь выбираем только 3 колонки из всей модели
        $array = [
            'id' => $object->id,
            'username' => $object->username,
            'fullname' => $object->fullname,
        ];

        // И возвращаем массив
        return $array;
    }
}

Проверяем:

Как видите, расширять базовые контроллеры очень весело. Вы прописываете только то, что нужно вам, остальное работает по-умолчанию.

Заключение

Урок получился большой и сложный, но это база, без которой дальше никуда. Вся работа стротся на том, что мы будем создавать новые модели и контроллеры для работы с ними, которые будут вызываться с фронтенда пользователями. Так что нужно хорошо понимать, как это всё работает.

На следующем уроке мы узнаем как работает авторизация пользователя и проверка разрешений в контроллере.

Следующая заметка →
Проверка прав доступа в контроллерах
Комментарии (10)
bezumkinВасилий Наумкин
03.06.2022 12:25

Моя вина, не импортировал в контроллер нужные классы, поэтому PHP не может правильно использовать класс Builder, на что и ругается.

Должно быть вот так:

<?php

namespace App\Controllers\Web;

use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Vesp\Controllers\ModelGetController;

class Users extends ModelGetController
{
// ....
}

Обновил заметку, спасибо за замечание!

bezumkinВасилий Наумкин
11.06.2022 15:22

А ты не забыл, случайно, прописать новый маршрут в core/routes.php?

bezumkinВасилий Наумкин
12.06.2022 05:11

На здоровье!

bezumkinВасилий Наумкин
16.03.2023 13:34

Базовые контроллеры у тебя уже установлены как зависимость composer, иначе ничего бы не работало.

Можешь их поискать в /core/vendor/vesp

bezumkin
Василий Наумкин
09.04.2024 01:45
Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. Во...
futuris
Futuris
04.04.2024 05:56
Я просто немного запутался. Когда в абзаце &quot;Vesp/Core&quot; ты пишешь про &quot;новый trait Fil...
bezumkin
Василий Наумкин
20.03.2024 18:21
Volledig!
Андрей
14.03.2024 10:47
Василий! Как всегда очень круто! Моё почтение!
russelgal
russel gal
09.03.2024 17:17
А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал ...
inetlover
Александр Наумов
27.01.2024 00:06
Василий, спасибо! Извини, тупанул.
bezumkin
Василий Наумкин
22.01.2024 04:43
Давай-давай!
bezumkin
Василий Наумкин
24.12.2023 11:26
Спасибо!
bezumkin
Василий Наумкин
27.11.2023 02:43
Ура!
bezumkin
Василий Наумкин
25.11.2023 08:30
Vesp тянет 2 зависимости: vesp-frontent для фронта и vesp-core для бэкенда. Их можно обновлять, но э...