Очередное решение для разработки API и не только

от автора

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

В 2018 году передо мной встал выбор маршрутизатора с четкими требованиями:

  • Легковесность

  • В основе PSR-стандарты

  • Поддержка аннотаций

  • Интеграция со Swagger

На тот момент подходящего для себя решения я не нашел, ввиду чего появился Sunrise Router. Вторая версия, выпущенная год спустя, закрыла основные потребности, но мне было нужно нечто большее. Под влиянием Spring и других фреймворков я стремился к комплексному решению, включающему:

  • Иммутабельную архитектуру

  • Самодокументируемое API

  • Получение DTO и других атрибутов запроса сразу в параметрах экшена

  • Отправку View-объектов сразу из экшена

  • Минимальную зависимость от Request/Response

  • Обработку ошибок и локализацию из коробки

  • Гибкость и расширяемость

Я засматривающийся на Java

Et si tu n’existais pas…

Цель была не просто в создании удобного маршрутизатора, а в сбалансированном решении, где, в том числе, документация является естественным продолжением кода, а не отдельным этапом работы.

Однажды устроился на работу, и мне досталась пачка «интересных» задач: нужно было вручную синхронизировать OpenAPI, оформленный в комментариях к коду, с самим кодом в десятке сервисах. Звучит как начало анекдота, но тогда мне было не до смеха…

Недавно вышла третья версия Sunrise Router, реализующая все вышеописанное. Это проверенное в продакшене решение, которое стабильно поддерживается и развивается все это время.

Демонстрационный контроллер

namespace App\Controller\Api;  use App\Contract\Service\PostServiceInterface; use App\Dictionary\MediaType; use App\Dto\Post\PostCreateRequest; use App\Entity\Post; use App\View\Post\PostView; use Sunrise\Bridge\Doctrine\Integration\Router\Annotation\RequestedEntity; use Sunrise\Http\Router\Annotation\Consumes; use Sunrise\Http\Router\Annotation\EncodableResponse; use Sunrise\Http\Router\Annotation\GetApiRoute; use Sunrise\Http\Router\Annotation\PostApiRoute; use Sunrise\Http\Router\Annotation\Produces; use Sunrise\Http\Router\Annotation\RequestBody;  final readonly class PostController {     public function __construct(         private PostServiceInterface $postService,     ) {     }      #[PostApiRoute('api.posts.create', '/api/posts')]     #[Consumes(MediaType::JSON)]     public function create(#[RequestBody] PostCreateRequest $createRequest): void     {         $this->postService->createPost($createRequest);     }      #[GetApiRoute('api.posts.read', '/api/posts/{id<\d+>}')]     #[Produces(MediaType::JSON)]     #[EncodableResponse]     public function read(#[RequestedEntity] Post $post): PostView     {         return PostView::create($post);     } }
Сгенерированный OpenAPI по нему

Сгенерированный OpenAPI по демонстрационному контроллеру.

Маршрутизатора недостаточно. Любой API-проект требует:

  • Гибкой конфигурации

  • DI

  • CLI

Так появился Awesome Skeleton, который закрывает эти задачи и предлагается в качестве готового базового решения, где за конфигурацию и DI отвечает PHP-DI, а за CLI – Symfony Сonsole.

Обзор некоторых возможностей

Следование PSR-стандартам

Хотя в третьей версии сохранена полная совместимость с PSR-15, следование этому стандарту в контексте контроллеров при разработке API не рекомендуется. Причина проста: система не сможет понять, что ожидается на входе и что будет на выходе, из-за чего разработчику придется смещать фокус с логики на документацию, что противоречит изначальной концепции.

В то же время, для промежуточного ПО, наоборот, рекомендуется придерживаться PSR-15, чтобы обеспечить максимальную совместимость и гибкость.

Иммутабельная архитектура

Переходя к практике, я много лет использую RoadRunner для PHP-проектов. Давайте подключим его к нашему проекту.

composer require spiral/roadrunner-cli spiral/roadrunner-http
php vendor/bin/rr get-binary

Определим команду-воркер, которая будет точкой входа:

declare(strict_types=1);  namespace App\Command;  use Override; use Psr\Http\Message\ServerRequestFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Message\UploadedFileFactoryInterface; use Spiral\RoadRunner\Http\PSR7Worker as HttpWorker; use Spiral\RoadRunner\Worker; use Sunrise\Http\Router\RouterInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface;  #[AsCommand('rr:work')] final class RoadRunnerWorker extends Command {     public function __construct(         private readonly RouterInterface $router,         private readonly ServerRequestFactoryInterface $requestFactory,         private readonly StreamFactoryInterface $streamFactory,         private readonly UploadedFileFactoryInterface $uploadsFactory,     ) {         parent::__construct();     }      #[Override]     protected function execute(InputInterface $input, OutputInterface $output): int     {         $worker = new HttpWorker(Worker::create(), $this->requestFactory, $this->streamFactory, $this->uploadsFactory);          while ($request = $worker->waitRequest()) {             $worker->respond($this->router->handle($request));         }          return self::SUCCESS;     } }

Добавим ее в конфигурацию config/definitions/app.php:

use App\Command\RoadRunnerWorker;  use function DI\autowire;  return [     'app.commands' => add([         autowire(RoadRunnerWorker::class),     ]), ];

Создадим .rr.yaml в корне проекта:

# https://github.com/roadrunner-server/roadrunner/blob/master/.rr.yaml  version: '3' server:   command: php bin/app rr:work http:   address: 127.0.0.1:8000   pool:     num_workers: ${ROADRUNNER_NUM_WORKERS}     max_jobs: ${ROADRUNNER_MAX_JOBS}

Добавим переменные в .env и .env.dist:

# How many worker processes will be started. # Zero (or nothing) means the number of logical CPUs.  ROADRUNNER_NUM_WORKERS=1 # Maximal count of worker executions. # Zero (or nothing) means no limit. ROADRUNNER_MAX_JOBS=1

Запуск:

./rr serve --dotenv .env

Если все работает и RoadRunner вас устраивает, можно удалить public/index.php, так как он больше не нужен.

Самодокументируемое API

В самом начале мы рассмотрели пример контроллера, который не заработает сразу — к нему мы вернемся позже. А сейчас давайте создадим контроллер для авторизации. Он не будет содержать логики и носит демонстрационный характер, хотя может послужить точкой старта.

Сначала опишем DTO, чтобы задать ожидаемую структуру данных от клиента:

declare(strict_types=1);  namespace App\Dto\Auth;  use SensitiveParameter;  final readonly class SignInRequest {     public function __construct(         #[SensitiveParameter]         public string $email,         #[SensitiveParameter]         public string $password,     ) {     } }

Теперь опишем сам контроллер:

declare(strict_types=1);  namespace App\Controller\Api;  use App\Dictionary\MediaType; use App\Dto\Auth\SignInRequest; use Sunrise\Http\Router\Annotation\Consumes; use Sunrise\Http\Router\Annotation\PostApiRoute; use Sunrise\Http\Router\Annotation\RequestBody;  final readonly class AuthController {     #[PostApiRoute('api.auth.signIn', '/api/auth/sign-in')]     #[Consumes(MediaType::JSON)]     public function signIn(         #[RequestBody]         SignInRequest $signInRequest,     ): void {     } }

Далее можно сгенерировать OpenAPI, открыть его в Swagger и протестировать метод:

php bin/app router:openapi:build-document

В результате по адресу http://localhost:8000/swagger.html мы должны увидеть что-то вроде этого:

Больше аннотаций и примеров можно найти в документации.

Обработка ошибок

По умолчанию ошибки отлавливаются и логируются. Клиент получает ошибку в формате JSON, адаптированном под его язык, в зависимости от типа ошибки. Если вы заглянете в схемы OpenAPI (Swagger), то увидите объекты-представления Error и Violation — именно они используются для отображения ошибок.

Если вам необходимо отдавать ошибки в другом формате, это легко настраивается через параметр router.error_handling_middleware.produced_media_types.

А если вы разрабатываете не только backend, но и frontend на основе скелетона, возможно, вам потребуется отображать ошибки в HTML. Это тоже легко реализовать: достаточно добавить промежуточное ПО перед ErrorHandlingMiddleware, проверить, запрашивает ли клиент HTML-ответ, и если да — вернуть HTML. В противном случае запрос просто передается следующему middleware.

Валидация DTO

Здесь все просто: чтобы DTO автоматически валидировались, требуется интеграция с Symfony Validator. Это делается очень легко — сначала установим его:

composer require symfony/validator

Далее интегрируем в наш проект. Для этого создадим файл config/definitions/validator.php и приведем его к следующему виду:

declare(strict_types=1);  use Psr\Container\ContainerInterface; use Symfony\Component\Validator\ContainerConstraintValidatorFactory; use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Component\Validator\ValidatorBuilder;  return [     ValidatorInterface::class => static fn(ContainerInterface $container): ValidatorInterface => (new ValidatorBuilder())         ->enableAttributeMapping()         ->setConstraintValidatorFactory(new ContainerConstraintValidatorFactory($container))         ->getValidator(), ];

Теперь, на примере нашего SignInController, доработаем SignInRequest, добавив валидацию:

declare(strict_types=1);  namespace App\Dto\Auth;  use SensitiveParameter; use Symfony\Component\Validator\Constraints as Assert;  final readonly class SignInRequest {     public function __construct(         #[Assert\NotBlank]         #[SensitiveParameter]         public string $email,         #[Assert\NotBlank]         #[SensitiveParameter]         public string $password,     ) {     } }

Интеграция с Doctrine

Интеграция с Doctrine дает ряд преимуществ, упрощающих работу с сущностями и повышающих удобство разработки.

Резолвинг сущности в свойстве DTO
Хотя смешивание слоев домена и приложения в одной точке может показаться спорным, на практике это весьма полезная возможность. Например, клиент отправляет запрос на создание товара, передавая categoryId. Вместо того чтобы просто принять этот идентификатор, система автоматически проверит существование категории, установит её в свойство DTO или вернет ошибку, если категория отсутствует. В результате контроллер получает не просто «сырые» данные от клиента, а уже частично обработанную и валидированную структуру.

Резолвинг сущности в параметре экшена
Иногда удобно сразу получать сущность в контроллере, чтобы не загружать её вручную и не обрабатывать ситуацию, когда объект не найден. Это компромисс между строгим разделением слоев и практичностью. Лично я нахожу такой подход удобным, но в любом случае это всего лишь опция, которую можно отключить — как и большинство возможностей в системе.

Промежуточное ПО для управления транзакциями
При разработке экшенов я придерживаюсь простого, но важного принципа: контроллер должен быть транзакционно атомарным, то есть выполняться как единая операция. Идеальный сценарий — когда разработчику не нужно думать о вызове flush(). Если в экшене вызываются разные сервисы, и каждый из них самостоятельно вызывает flush() (например, FooService сначала что-то делает и коммитит, затем BarService делает что-то своё и тоже коммитит), это приводит к потере согласованности данных. В худшем случае часть операции пройдет успешно, а часть — нет. Этот механизм автоматически оборачивает выполнение контроллера в транзакцию, устраняя подобные проблемы, однако всегда помните о распределенных транзакциях.

Гарантия уникальности сущности
Разработчики Symfony знакомы с валидатором UniqueEntity. Здесь реализован аналогичный механизм, который никак не связан с этим фреймворком и встроен в систему как отдельный «компонент».

Управление подключениями к базе данных
Классический сценарий: воркер потребляет сообщения из Kafka, но через какое-то время появляется ошибка «Lost Connection» – знакомо? В долгоработающих приложениях это критично, так как воркер — это, по сути, долгоживущий процесс. Для решения этой проблемы менеджер сущностей перед каждым использованием проверяет, не закрыто ли соединение. Если оно разорвано, происходит автоматическая перезагрузка соединения, что устраняет возможные сбои в работе.

В Awesome Skeleton всё это настраивается буквально в несколько шагов. Устанавливаем мост к Doctrine:

composer require sunrise-studio/doctrine-bridge

Обязательно добавьте PSR-18 совместимый пакет кеширования. Я рекомендую symfony/cache:

composer require symfony/cache

Далее интегрируем Doctrine в контейнер config/container.php, добавив перед определениями приложения следующий код:

$containerBuilder->addDefinitions(     __DIR__ . '/../vendor/sunrise-studio/doctrine-bridge/resources/definitions/doctrine.php',     __DIR__ . '/../vendor/sunrise-studio/doctrine-bridge/resources/definitions/integration/hydrator/type_converters/map_entity_type_converter.php',     __DIR__ . '/../vendor/sunrise-studio/doctrine-bridge/resources/definitions/integration/router/middlewares/request_termination_middleware.php',     __DIR__ . '/../vendor/sunrise-studio/doctrine-bridge/resources/definitions/integration/router/parameter_resolvers/requested_entity_parameter_resolver.php',     __DIR__ . '/../vendor/sunrise-studio/doctrine-bridge/resources/definitions/integration/validator/unique_entity_validator.php', );

Наконец, добавляем DSN в переменные окружения:

DATABASE_DSN=pdo-pgsql://user:password@localhost:5432/acme?charset=utf8

Google reCAPTCHA

Продолжая тему расширяемости Sunrise Router, давайте добавим защиту экшена входа, используя Google reCAPTCHA. Установим соответствующий пакет:

composer require sunrise/recaptcha

Также нам понадобятся пакеты, совместимые с PSR-17 и PSR-18:

Теперь интегрируем reCAPTCHA в проект. Для этого откроем config/container.php и перед основными определениями приложения добавим следующее:

$containerBuilder->addDefinition(     __DIR__ . '/../vendor/sunrise/recaptcha/resources/definitions/recaptcha_verification.php',     __DIR__ . '/../vendor/sunrise/recaptcha/resources/definitions/integration/router/middleware/recaptcha_challenge_middleware.php',     __DIR__ . '/../vendor/sunrise/recaptcha/resources/definitions/integration/validator/constraint/recaptcha_challenge_validator.php', );

После этого необходимо указать секретный ключ в переменных окружения:

RECAPTCHA_VERIFICATION_PRIVATE_KEY=secret

Вы можете выбрать один из двух способов валидации reCAPTCHA:

  1. Через HTTP-заголовок

  2. Через свойство в DTO

Конечно, есть и другие варианты. Например, можно использовать Constraint-аннотацию для валидации параметров в экшене.

Промежуточное ПО

declare(strict_types=1);  namespace App\Controller\Api;  use App\Dictionary\MediaType; use App\Dto\Auth\SignInRequest; use Sunrise\Http\Router\Annotation\Consumes; use Sunrise\Http\Router\Annotation\Middleware; use Sunrise\Http\Router\Annotation\PostApiRoute; use Sunrise\Http\Router\Annotation\RequestBody; use Sunrise\Recaptcha\Integration\Router\Middleware\RecaptchaChallengeMiddleware;  final readonly class AuthController {     #[PostApiRoute('api.auth.signIn', '/api/auth/sign-in')]     #[Consumes(MediaType::JSON)]     #[Middleware(RecaptchaChallengeMiddleware::class)]     public function signIn(         #[RequestBody]         SignInRequest $signInRequest,     ): void {     } }

По умолчанию ожидается заголовок X-Recaptcha-Token.

Свойства DTO

declare(strict_types=1);  namespace App\Dto\Auth;  use SensitiveParameter; use Sunrise\Recaptcha\Integration\Validator\Constraint\RecaptchaChallenge; use Symfony\Component\Validator\Constraints as Assert;  final readonly class SignInRequest {     public function __construct(         #[Assert\NotBlank]         #[SensitiveParameter]         public string $email,         #[Assert\NotBlank]         #[SensitiveParameter]         public string $password,         #[RecaptchaChallenge]         public string $recaptcha,     ) {     } }

Резюмируя все вышеизложенное

Мы рассмотрели ключевые возможности и примеры использования, затронув как концептуальные аспекты, так и практические примеры. В данный момент инструментарий активно развивается и документируется. Если вас заинтересовал проект — задавайте вопросы, буду рад обсуждению!


ссылка на оригинал статьи https://habr.com/ru/articles/891048/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *