Контроллеры 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 комментариев

Пишет такую ошибку: Fatal error: Could not check compatibility between App\Controllers\Web\Users::beforeCount(App\Controllers\Web\Builder $c): App\Controllers\Web\Builder and Vesp\Controllers\ModelController::beforeCount(Illuminate\Database\Eloquent\Builder $c): Illuminate\Database\Eloquent\Builder, because class App\Controllers\Web\Builder is not available in /Users/max/projects/vesp/VespShop/core/src/Controllers/Web/Users.php on line 17
а на 17 строке у меня объявление метода:
protected function beforeCount(Builder $c): Builder
Василий Наумкин
Моя вина, не импортировал в контроллер нужные классы, поэтому 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
{
// ....
}
Обновил заметку, спасибо за замечание!
Спасибо за оперативный ответ)
Александр Наумов
Теперь переходим по адресу http://vesp-shop.test/api/web/users 
Я вижу "Not found." 404 ошибка и логи пустые.
А если реально напишу необрабатываемый url например http://vesp-shop.test/api+/web/users, то вижу заглушку от Nuxt.
Василий Наумкин
А ты не забыл, случайно, прописать новый маршрут в core/routes.php?
Александр Наумов
Спасибо Василий!
Я подумал, что уже все за меня создано и нужно только перейти по адресу )).
Создал контроллер Users.php, прописал маршрут в core/routes.php и все заработало.
Круто все спроектировано!
Василий Наумкин
На здоровье!
Василий, я пытаюсь курс. Но из-за того, что мне сложно на Винде воспроизвести все необходимое окружение, я начал не по уроку "Создание нового проекта", где VespShop ставится из Packagist при помощи composer. А я просто продолжил работу в Докере, создав сначала проект по заметке "Vesp в Docker".
И первый вопрос в том - можно ли так делать, или есть различия в исходниках?
По этому уроку я выполнил практическую часть и получил все твои выводы по адресам:
http://127.0.0.1:8080/api/web/users
http://127.0.0.1:8080/api/web/users/1
http://127.0.0.1:8080/api/web/users?query=user
http://127.0.0.1:8080/api/web/users?sort=created_at&dir=desc
Для этого создал директорию Web в core/src/Controllers и там создал Users.php, который затем и расширил, как здесь написано. Т.е. все работает. Но только в этом уроке ты пишешь о контроллерах Controller.php, ModelController.php и ModelGetController.php в Vesp\Controllers. Эти файлы присутствуют в https://github.com/bezumkin/vesp-core/tree/master/src/Controllers . А вот в моей "докерном" проекте этих файлов нет. Хотя я конечно могу их взять из твоего репозитория). Но вопрос в том - нужны ли они?
Василий Наумкин
Базовые контроллеры у тебя уже установлены как зависимость composer, иначе ничего бы не работало.
Можешь их поискать в /core/vendor/vesp
Ок, вижу \core\vendor\vesp\core\src\Controllers Спасибо!
bezumkin.ru
Personal website of Vasily Naumkin
Прямой эфир
Александр Наумов
23.07.2024, 00:20:37
Василий, спасибо большое!!
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так. А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен. Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. ...
Василий Наумкин
20.03.2024, 21:21:52
Volledig!
Андрей
14.03.2024, 13:47:10
Василий! Как всегда очень круто! Моё почтение!
russel gal
09.03.2024, 20:17:18
> А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал...
Александр Наумов
27.01.2024, 03:06:18
Василий, спасибо! Извини, тупанул.
Василий Наумкин
22.01.2024, 07:43:20
Давай-давай!
Василий Наумкин
24.12.2023, 14:26:13
Спасибо!