API platform это полнофункциональный REST API, который вы получите за считанные минуты.Вот неполный список фич:

- Генерация CRUD

- Поддержка GraphQL

- Машиночитаемая документация API в форматах Hydra и Swagger/Open API, гененрится из метаданных PHPDoc, Serializer, Validator и Doctrine ORM / MongoDB ODM

- Хорошая удобочитаемая документация, созданная с использованием пользовательского интерфейса Swagger (включая песочницу) и / или ReDoc

- Пагинация

- Куча фильтров

- Проверка с использованием компонента Symfony Validator (с поддержкой групп)

- Расширенные правила аутентификации и авторизации

- Расширенная сериализация благодаря компоненту Symfony Serializer (поддержка групп, встраивание отношений, максимальная глубина...)

- Поддержка JWT и OAuth- Файлы и \DateTime, сериализация и десериализация

- Все полностью настраивается благодаря мощной системе событий и сильному ООП.


Сразу отмечу, что в Symfony можно создавать API через контроллер

php bin/console make:controller --no-template SuperApiController

и так же настроить валидацию и доступы через атрибуты

Код
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

class SuperApiController extends AbstractController
{
    #[Route('/super/api', name: 'app_super_api')]
    #[IsGranted("ROLE_ADMIN")]
    public function index(): JsonResponse
    {
        return $this->json([
            'message' => 'Welcome to your new controller!',
            'path' => 'src/Controller/SuperApiController.php',
        ]);
    }
}

Но если можно не писать код, а использовать уже проверенный, почему бы не пойти такой дорогой?

После изучения этого материала ты узнаешь:

  • Как с помощью API platform создать CRUD и получить документацию swagger.

  • Добавим операцию регистрации пользователя и аутентификацию через токен.

  • Добавим доступы к операциям и позволим менять пароль через API только себе.

Устанавливаем API platform

composer require api

Создадим сущность пользователя

php bin/console make:user

И теперь, давай добавим просто атрибут [ApiResource] у этой сущности

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
 
#[ApiResource] //<-- Добавил только это
#[ORM\Entity(repositoryClass: UserRepository::class)]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
...

Вот и всё готово! Мы создали полноценное REST Api.

Заходи по ссылке http://localhost/api

И видим, что у нас появились методы для CRUD. По сути, мы написали одну строчку кода, добавив один атрибут, и уже имеем такой мощный функционал.

Отлично, движемся дальше.

Делаем метод регистарции пользователя

Для этого нам необходимо добавить поле токен к сущности пользователя

php bin/console make:entity User

добавим поле token

В самой сущности, добавим поле plainPassword, которой добавим атрибут

Сущность User
use Symfony\Component\Serializer\Annotation\SerializedName;
//....

class User implements UserInterface, PasswordAuthenticatedUserInterface
{
//...

#[SerializedName('password')]
private $plainPassword;


  /**
     * @return mixed
     */
    public function getPlainPassword()
    {
        return $this->plainPassword;
    }

    /**
     * @param   mixed  $plainPassword
     */
    public function setPlainPassword($plainPassword): void
    {
        $this->plainPassword = $plainPassword;
    }

  /**
     * @see UserInterface
     */
    public function eraseCredentials()
    {
        // If you store any temporary, sensitive data on the user, clear it here
         $this->plainPassword = null; // <--- Раскоментируем эту строчку 
    }
}

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

Смотри документацию про Процессоры состояний

После чего мы сможем использовать этот процессор в операциях для создания пользователя и изменения пароля.

Поехали

php bin/console make:state-processor UserPasswordHasherProcessor

Регистрируем его в конфиге services.yaml

#services.yaml
services:
  
  App\State\UserPasswordHasherProcessor:
    bind:
      '@api_platform.doctrine.orm.state.persist_processor'

Сам процессор будет выглядеть так.

UserPasswordHasherProcessor
<?php

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\User;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

class UserPasswordHasherProcessor implements ProcessorInterface
{
    public function __construct(private readonly ProcessorInterface $processor, private readonly UserPasswordHasherInterface $passwordHasher)
    {
    }


    /**
     * @param   User      $data
     * @param   Operation  $operation
     * @param   array      $uriVariables
     * @param   array      $context
     *
     * @return mixed
     * @throws \Exception
     */
    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
    {
        if (!$data->getPlainPassword()) {
            return $this->processor->process($data, $operation, $uriVariables, $context);
        }

        $hashedPassword = $this->passwordHasher->hashPassword(
            $data,
            $data->getPlainPassword()
        );
        $data->setPassword($hashedPassword);
        $data->eraseCredentials();
        // Если это операция по созданию нового пользователя, то генерим токе и назначаем роль по умолчанию 
        if ($operation instanceof Post){
            $data->setToken(bin2hex(random_bytes(60)));
            $data->setRoles($data->getRoles());
        }

        return $this->processor->process($data, $operation, $uriVariables, $context);
    }
}

Настраиваем операции

Пришло время, связать наш ресурс API с логикой, описанной в процессоре. Для этого добавим атрибут operations к сущности User и пропишем все необходимые операции в виде методов API.

Обрати внимание, я указал созданный нами процессор UserPasswordHasherProcessor в операциях Post() и Patch(). А так же, в операции пост добавил параметр uriTemplate со значением /registration, что изменит uri у этого метода с /api/users на /api/registration.

use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Get;

...

#[ApiResource(
operations: [
    new GetCollection(),
    new Post(processor: UserPasswordHasherProcessor::class, 
            uriTemplate: '/registration'),
    new Get(),
    new Patch(processor: UserPasswordHasherProcessor::class),
    new Delete(),
    ]
)]

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{

Отлично, давай посмотрим на то, как сейчас выглядит swagger. И видим, то что раньше у нас были поля в разделе Example value, а сейчас пусто. 

Все нормально! 

Так как я переопределил, каждый метод в параметре operations, API Platform не понимает, какие поля ему нужно использовать для каждого метода как входные параметры, и какие поля будут отображены после успешной работы метода. Пришло время настроить сериализацию и нормализацию данных.

Группы сериализации и нормализации

Смотри документацию про Сериализацию

Для этого в атрибуте ApiResource в сущности User опишем параметры normalizationContext для чтения данных из сущности, и denormalizationContext для записи данных в сущность.

use Symfony\Component\Serializer\Annotation\Groups;
...

#[ApiResource(
        normalizationContext: ['groups' => ['user:read']],
        denormalizationContext: ['groups' => ['user:create', 'user:update']],
        operations: [

А дальше, назначим эти группы для полей в сущности User. Так, чтобы для создания пользователя мы запрашивали только логин и пароль, а возвращали токен.

Код
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[Groups(['user:create', 'user:update'])]
    #[ORM\Column(length: 180, unique: true)]
    private ?string $email = null;

    #[ORM\Column]
    private array $roles = [];

    /**
     * @var string The hashed password
     */
    #[ORM\Column]
    private ?string $password = null;

    #[ORM\Column(length: 255)]
    #[Groups(['user:read'])]
    private ?string $token = null;


    #[Groups(['user:create', 'user:update'])]
    #[SerializedName('password')]
    private $plainPassword;

Посмотрим, как теперь выглядит в Swagger Example value в методе /api/registration. Обновим страничку с описанием и видим что, все так как и запланировали.

{
  "email": "string",
  "password": "string"
}

И в ответе мы видим, что будет возращен токен

{
  "@context": "string",
  "@id": "string",
  "@type": "string",
  "token": "string"
}

Ты не поверишь, но метод регистрации уже работает! Можно уже сейчас нажать кнопку execute и все сработает! Опа, ошибка вывалилась!

Ну конечно! А ты настроил соединение с БД и выполнил миграцию данных?

БД и миграции

Пришло время настроить подключение и заполнить БД.

#.env
DATABASE_URL="mysql://root:root@mysql:3306/app?serverVersion=8&charset=utf8mb4"

Создадим миграцию

php bin/console make:migration

И выполним её

php bin/console doctrine:migrations:migrate

Регистрируем пользователя через API

Заполняем данные в Swagger Example value в методе /api/registration и нажимаем execute

Это же amazing какой-то!

Кстати, метод Patch тоже работает. И в нем так же можно изменить два поля

{
  "email": "string",
  "password": "string"
}

И в ответ вернется значение поля token. Только токен не будет генерироваться вновь, так как в UserPasswordHasherProcessor, есть такое условие:

    ...
    if ($operation instanceof Post){ // <- только для метода Post 
        $data->setToken(bin2hex(random_bytes(60)));
        $data->setRoles($data->getRoles());
    }

Авторизация

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

Начнем с настройки API Platform. Добавим такой конфиг config/packages/api_platform.yaml

#config/packages/api_platform.yaml
api_platform:
  # The title of the API.
  title: 'Edu API'

  # The description of the API.
  description: 'Edu API description'

  # The version of the API.
  version: '0.0.1'

  # Set this to false if you want Webby to disappear.
  show_webby: false

  mapping:
    paths: ['%kernel.project_dir%/src/Entity']
  patch_formats:
    json: ['application/merge-patch+json']
  swagger:
    versions: [3]
    api_keys:
      Bearer:
        name: AUTH-TOKEN
        type: header

И теперь нажав на кнопку Authorize, появится форма, с полем AUTH-TOKEN куда можно вставить токен нашего пользователя. Значение которого можно вытащить из таблицы user.

Но пока, авторизацию не реализована у нас в API, и хоть swagger радостно сообщит, что авторизовался, и будет честно слать токен в заголовке, API его не воспримет.

Давай реализуем необходимый аутентификатор, и все настроим.

Выполним команду и выберем вариант 0 Empty authenticator и дадим название ApiTokenAuthenticator

php bin/console make:auth

 What style of authentication do you want? [Empty authenticator]:
  [0] Empty authenticator
  [1] Login form authenticator
 > ApiTokenAuthenticator

Теперь, реализуем в нем все необходимые методы:

ApiKeyAuthenticator
namespace App\Security;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;

class ApiKeyAuthenticator extends AbstractAuthenticator
{

    protected const HEADER_AUTH_TOKEN = 'AUTH-TOKEN';

    /**
     * @inheritDoc
     */
    public function supports(Request $request): ?bool
    {
        return $request->headers->has(self::HEADER_AUTH_TOKEN);
    }

    /**
     * @inheritDoc
     */
    public function authenticate(Request $request): Passport
    {
        $apiToken = $request->headers->get(self::HEADER_AUTH_TOKEN);

        if (null === $apiToken) {
            throw new CustomUserMessageAuthenticationException('Auth token not found (header: "{{ header }}")', [
                '{{ header }}' => self::HEADER_AUTH_TOKEN,
            ]);
        }

        return new SelfValidatingPassport(new UserBadge($apiToken));
    }

    /**
     * @inheritDoc
     */
    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        return null;
    }

    /**
     * @inheritDoc
     */
    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        throw $exception;
    }
}

Настроим UserProvider на работу с полем token и настроим firewall

#config/packages/security.yaml

security:
    #....
    providers:
        
        app_user_provider:
            entity:
                class: App\Entity\User
                property: token # <-- тут устанавливаем поле token 
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            lazy: true
            stateless: true  # <-- добавляем stateless: true так как у нас не будут использоваться сессии  
            provider: app_user_provider
            custom_authenticator: App\Security\ApiKeyAuthenticator  # <-- регистрируем ApiKeyAuthenticator

   #...

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

Доступы и роли

Определим роли и разрешения

Давай представим, а как бы хотелось пользоваться текущим API.

  1. Метод POST /api/registration должен быть доступен всем, чтобы пользователи свободно регистрировались.

  2. Должен быть метод POST /api/login тоже должен быть доступен всем, и в ответ давать свежий токен для авторизации

  3. Методы GET /api/users и /api/users/{id} доступно только залогиненым пользователям

  4. Методы PATCH /api/users/{id} можно менять пароль только себе, доступно только залогиненым пользователям

  5. Методы DELETE /api/users/{id} можно удалить только себя, доступно только залогиненым пользователям

Первый пункт, уже реализован.

Для пункта 2, давай сделаем процессор для метода login и добавим его в операции.

php bin/console make:state-processor UserLoginProcessor

Так же добавим метод findOneByLogin в UserRepository для нахождения пользователя по email


 public function findOneByLogin($value): ?User
    {
        return $this->createQueryBuilder('u')
            ->andWhere('u.email = :val')
            ->setParameter('val', $value)
            ->getQuery()
            ->getOneOrNullResult();
    }

Теперь добавим логику для процессора.

UserLoginProcessor
namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

class UserLoginProcessor implements ProcessorInterface
{
    public function __construct(private UserRepository $repository, private UserPasswordHasherInterface $userPasswordEncoder)
    {

    }

    /**
     * @param   User      $data
     * @param   Operation  $operation
     * @param   array      $uriVariables
     * @param   array      $context
     *
     * @return User
     * @throws \Exception
     */
    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
    {
        $user = $this->repository->findOneByLogin($data->getEmail());
        if ($user instanceof  User) {

            if (!$this->userPasswordEncoder->isPasswordValid($user,  $data->getPlainPassword())) {
                throw new AccessDeniedHttpException();
            }

            $user->setToken(bin2hex(random_bytes(60)));
            $this->repository->save($user, true);
            return  $user;
        }

        throw new NotFoundHttpException();
    }
}

Если служба автозапуска и автоконфигурации включена (они включены по умолчанию), то все готово!

# api/config/services.yaml
services:
    # default configuration for services in this file
    _defaults:
        autowire: true      # Automatically injects dependencies in your services.
        autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.

В противном случае, если вы используете пользовательскую конфигурацию внедрения зависимостей, вам необходимо зарегистрировать соответствующую службу и добавить тег api_platform.state_processor.

# api/config/services.yaml
services:
    # ...
    App\State\UserLoginProcessor: ~
    # Uncomment only if autoconfiguration is disabled
    #tags: [ 'api_platform.state_processor' ]

Осталось создать новый метод для логина и настроить все доступы, как описано в пунктах 3-5

Настроим доступы

Для удовлетворения требований пункта 2, переходим к сущности User и добавим операцию для метода POST /api/login

Сущность User
use App\State\UserLoginProcessor;

...

#[ApiResource(
    normalizationContext: ['groups' => ['user:read']],
    denormalizationContext: ['groups' => ['user:create']],
    operations: [
        new GetCollection(),
        new Post(processor: UserPasswordHasherProcessor::class, uriTemplate: '/registration'),
        new Post(processor: UserLoginProcessor::class,
            uriTemplate: '/login'
        ),
        new Get(),
        new Patch(),
        new Delete(),
    ]
)]

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

Теперь для пункта 3, наших требований, добавим параметр security для операций Get() и GetCollection()

Сущность User
use App\State\UserLoginProcessor;

...

#[ApiResource(
    normalizationContext: ['groups' => ['user:read']],
    denormalizationContext: ['groups' => ['user:create']],
    operations: [
        new GetCollection(security: "is_granted('ROLE_USER')"),
        new Post(processor: UserPasswordHasherProcessor::class, uriTemplate: '/registration'),
        new Post(processor: UserLoginProcessor::class,
            uriTemplate: '/login'
        ),
        new Get(security: "is_granted('ROLE_USER')"),
        new Patch(),
        new Delete(),
    ]
)]

Теперь, если вызвать эти операции через swagger, без установленного токена, получим ошибку 403 Доступ запрещен

И теперь, самое интересное - пункты 4 и 5.

Сущность User
use App\State\UserLoginProcessor;

...

#[ApiResource(
    normalizationContext: ['groups' => ['user:read']],
    denormalizationContext: ['groups' => ['user:create']],
    operations: [
        new GetCollection(security: "is_granted('ROLE_USER')"),
        new Post(processor: UserPasswordHasherProcessor::class, uriTemplate: '/registration'),
        new Post(processor: UserLoginProcessor::class,
            uriTemplate: '/login'
        ),
        new Get(security: "is_granted('ROLE_USER')"),
        new Patch(security: "object == user",
            securityMessage: 'Пароль можно менять только себе'),
        new Delete(security: "object == user",
            securityMessage: 'Удалять можно только себя'),
    ]
)]

Доступными переменными являются:

user: текущий объект, вошедший в систему 

object: текущий класс ресурсов во время денормализации, текущий ресурс во время нормализации или коллекция ресурсов 

previous_object: (только после securityPostDenormalize) клон объекта, до внесения изменений - это значение равно null для операций создания нового объекта 

request (только на уровне ресурсов): текущий запрос

Проверки контроля доступа в атрибуте безопасности всегда выполняются перед этапом денормализации. Это означает, что для запросов PUT или PATCH объект содержит не значение, отправленное пользователем, а значения, хранящиеся в данный момент на уровне сохраняемости (persistence layer).

И ещё один момент - для операции Patch() сейчас отображаются поля

{
  "email": "string",
  "password": "string"
}

А хотелось бы настроить поля так, чтобы менять можно было только пароль.

И в результат, выдавать не token, а например email. Для таких целей, можно настроить группы на уровне операции и добавить их на соответствующие поля в сущности.

В итоге сущность User выглядит так
use App\State\UserLoginProcessor;

...

#[ApiResource(
    normalizationContext: ['groups' => ['user:read']],
    denormalizationContext: ['groups' => ['user:create']],
    operations: [
        new GetCollection(security: "is_granted('ROLE_USER')"),
        new Post(processor: UserPasswordHasherProcessor::class, uriTemplate: '/registration'),
        new Post(processor: UserLoginProcessor::class,
            uriTemplate: '/login'
        ),
        new Get(security: "is_granted('ROLE_USER')"),
        new Patch(security: "object == user",
            securityMessage: 'Пароль можно менять только себе',
            normalizationContext: ['groups' => ['user:passwordRead']],
            denormalizationContext: ['groups' => ['user:passwordUpdate']],
            ),
        new Delete(security: "object == user",
            securityMessage: 'Удалять можно только себя'),
    ]
)]
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[Groups(['user:create', 'user:passwordRead'])]
    #[ORM\Column(length: 180, unique: true)]
    private ?string $email = null;

    #[ORM\Column]
    private array $roles = [];

    /**
     * @var string The hashed password
     */
    #[ORM\Column]
    private ?string $password = null;

    #[ORM\Column(length: 255)]
    #[Groups(['user:read'])]
    private ?string $token = null;


    #[Groups(['user:create', 'user:passwordUpdate'])]
    #[SerializedName('password')]
    private $plainPassword;

Вот так, у нас получился гибкий функционал. И, обрати внимание как мало кода было написано! В следующих материалах, расскажу про другие фичи этой библиотеки.

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

Заключение

Вы можете найти код из этого материала на GitHub.

Перевод документации на русский тут

Буду рад вашим добрым советам, замечаниям и здоровой критики.

Отличного дня!

Комментарии (9)


  1. k0rinf
    26.12.2022 15:34

    Вы просто переписали часть документации апи платформы в статью на хабр?


    1. kxxb Автор
      26.12.2022 16:57

      Я просто изучил документацию, просто перевел часть статей документации на русский язык(wip), просто сделал туториал, и просто решил поделится им с друзьями.

      А потом, подумал, а может этот туториал станет полезен не только мне и моим друзьям?
      И просто поделился им с русскоговорящим сообществом Хабра.

      В надежде, что кому-то это принесет пользу и упросит жизнь :)


  1. michael_v89
    27.12.2022 11:27
    +1

    По сути, мы написали одну строчку кода, добавив один атрибут, и уже имеем такой мощный функционал.

    И бесполезный, подходит только для админки, и то только для самой простой.


    В самой сущности, добавим поле plainPassword, которой добавим атрибут

    Ух ты, прям в сущности хранить пароль в открытом виде? Да еще и в атрибуте, который сущности не принадлежит и используется только в одном сценарии.
    Сущность начинает превращаться в God-object, в котором есть всё.


    public function eraseCredentials()
    // If you store any temporary, sensitive data on the user, clear it here

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


    $data->setRoles($data->getRoles());

    И роли по умолчанию для конкретного сценария. Данные по умолчанию для других сценариев тоже будем добавлять в сущность?


    Сущность User
    securityMessage: 'Пароль можно менять только себе'

    Ага, и строки для представления сюда же добавим.


    Далее нам необходимо реализовать процессор состояний

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


    if (!$data->getPlainPassword())

    Бизнес-сценарий определяем не явно, а через хаки с предположениями (если поле пустое, значит менять пароль не хотим).


    if ($operation instanceof Post)

    Добавили в этот же класс реализацию другого сценария.


    назначим эти группы для полей в сущности User

    Имитируем входные данные для бизнес-сценариев помощью групп.


    Кстати, метод Patch тоже работает. И в нем так же можно изменить два поля

    Что если надо изменить имя пользователя (поле name)? Будем проверять в процессоре, какие поля пришли?


    $user->setToken(bin2hex(random_bytes(60)));
    $this->repository->save($user, true);
    ...
    throw new NotFoundHttpException();

    Работа с HTTP в бизнес-логике.


    security: "is_granted('ROLE_USER')"
    Доступными переменными являются

    Программирование на другом языке с глобальными переменными через нетипизированные строки.




    Вот как это выглядит без использования API Platform.


    60 строк кода
    class User { ... }
    
    class UserController {
      function registerAction(Request $request) {
        $validationResult = $this->validationService->validate($request, RegistrationForm::class);
        if ($validationResult->hasErrors()) {
          return $this->validationErrorResponse($validationResult->getErrors());
        }
    
        $form = $validationResult->getDto();
        $token = $this->userService->register($form);
    
        return $this->successResponse(['token' => $token]);
      }
    
      function loginAction(Request $request) {
        $validationResult = $this->validationService->validate($request, LoginForm::class);
        if ($validationResult->hasErrors()) {
          return $this->validationErrorResponse($validationResult->getErrors());
        }
    
        $form = $validationResult->getDto();
        $token = $this->userService->login($form);
    
        return $this->successResponse(['token' => $token]);
      }
    }
    
    class UserService {
      function register(RegistrationForm $form): string {
        $user = new User();
    
        $user->setEmail($form->getEmail());
        $passwordHash = $this->passwordHasher->hashPassword($form->getPassword());
        $user->setPasswordHash($passwordHash);
    
        $token = $this->generateToken();
        $user->setToken($token);
    
        $this->entityManager->save($user);
    
        return $token;
      }
    
      function login(LoginForm $form): string {
        $user = $this->userRepository->findOneByLogin($form->email);
        if ($user === null) {
          // HTTP response should be returned on validation step
          throw new RuntimeException('User not found');
        }
    
        $token = $this->generateToken();
        $user->setToken($token);
    
        return $token;
      }
    
      private function generateToken(): string {
        return bin2hex(random_bytes(60));
      }
    }


    1. kxxb Автор
      27.12.2022 12:37
      +1

      Благодарю за ревью!

      В туториале, я рассказываю как максимально быстро подступиться к изучению API-platform. Это не "серебряная пуля" для решения всех задач (в начале статьи, я подсветил, что можно делать API и на подходе с контроллерами).

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

      По поводу Вашего замечания про God-object, только Вам решать как и куда раскладывать логику в проекте :)

      В данном примере, я показал подход, как обработку каждого метода(POST, PATCH, GET) можно увести в процессор или провайдер (используя принцип CQRS). А дальше, естественно, реализовывать бизнес логику в сервисах или других компонентах системы.

      Так же, если Вас не устраивает такой подход, API-platfom вполне поддерживает подход с контроллерами.

      То что, произошла "концентрация" атрибутов в сущности пользователя, для описании поведения API, не вижу в этом ничего криминального, для формата туториала. И в сущности пароли не храниться в открытом виде, поле plainPassword необходимо, только для чтения пароля чтобы сразу закэшировать его.

      По поводу высказывания "Программирование на другом языке" - если вы про атрибуты, то это все тот же php.

      В этой статье, я не стал грузить читателя всеми фичами библиотеки, так как пришлось быть делать очень длинный материал. В следующих материалах, планирую рассказать как применять валидаторы, voter, загрузку и отдачу медиа.


      1. michael_v89
        27.12.2022 14:20

        я рассказываю как максимально быстро подступиться к изучению API-platform

        Ну а я даю совет, что не надо ее изучать)


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

        Ну я вот поместил в 60 строк кода оба ваши процессора. При этом не надо читать документацию, не используются исключения, используется типизация (а не mixed $data) и не нужны проверки instanceof. И мы даже не дошли до сложной валидации с полями из связанных сущностей, типа указывать у товара только активную категорию. И не рассмотрели проблему появления N+1 в коде вида public function __invoke(Book $book), куда API Platform будет сама передавать Book, загруженный по id без связей.


        По поводу Вашего замечания про God-object, только Вам решать как и куда раскладывать логику в проекте

        Нет, это решать еще и API Platform. У нее есть требования где какие аннотации писать, куда что положить и как это должно называться.


        я показал подход, как обработку каждого метода(POST, PATCH, GET) можно увести в процессор или провайдер (используя принцип CQRS).

        А я хочу показать, что нескольких методов HTTP недостаточно для выражения всех возможных действий с сущностью, поэтому такие попытки приводят к плохо поддерживаемому коду. Например, в какой метод HTTP поместить активацию пользователя при переходе по ссылке из письма? Это GET-запрос, но GET-обработчик уже занят, поэтому придется делать отдельный endpoint /activation. То есть в результате все равно получится RPC, а не REST.


        У вас нет разделения на Command и Query. Это должны быть выделенные структуры данных с теми полями, которые вы задаете группами.


        А дальше, естественно, реализовывать бизнес логику в сервисах или других компонентах системы

        То есть процессоры и провайдеры не содержат бизнес-логику, и нужны исключительно из-за использования API Platform, они фактически выполняют роль контроллеров. Где ж тут меньше кода.


        Так же, если Вас не устраивает такой подход, API-platfom вполне поддерживает подход с контроллерами.

        Зачем же она тогда нужна?) Контроллеры можно писать и без нее.


        не вижу в этом ничего криминального, для формата туториала

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


        необходимо только для чтения пароля

        Вот я и говорю, всякие хаки для работы API Platform. А в моем коде этого не нужно, ни plainPassword, ни eraseCredentials.


        если вы про атрибуты, то это все тот же php

        Я про код внутри строковых литералов — "is_granted('ROLE_USER')", "object == user".


        я не стал грузить читателя всеми фичами библиотеки, так как пришлось быть делать очень длинный материал

        А с обычным кодом всё уместилось бы в одну статью.


        1. kxxb Автор
          28.12.2022 13:50

          Спасибо за совет!

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

          Сам грешен, вел себя так же. Так как на изучение нового, уходит время и не всегда это новое окажется годным.

          Со своей стороны, вижу свою задачу, рассказывать про инструменты такие как API platform, так как верю, что кому-то это сможет сэкономить много времени и ресурса.


          1. michael_v89
            28.12.2022 16:39

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

            Так я как раз и объясняю, что в случае с API Platform это не так. Она покрывает лишь небольшой набор задач, которые есть в рамках учебных примеров с простым CRUD без логики, а для всего остального нужно писать гораздо больше кода.


            так как верю, что кому-то это сможет сэкономить много времени и ресурса

            Ну а я уже проверил, она не экономит время на реальных сложных задачах. На любое нестандартное действие надо будет лазить в документацию и исходный код библиотеки, проверять в отладчике что и как она делает. Я вот сходу нашел в вашем коде десяток примеров, которые считаются костылями и плохо поддерживаемым кодом. А главное, стремиться к REST бессмысленно, привязкой к 4 действиям с сущностью нельзя покрыть все бизнес-сценарии. Иначе это будет или простой CRUD без логики, или God-object, где в одном процессоре определяется нужное действие по набору входных полей и их значениям.


            Если хотите, можем проверить. Добавим такую функциональность:
            — Действие activate для активации из email
            — Действие deactivate если пользователь хочет деактивировать свой аккаунт (не метод DELETE, DELETE будет полностью удалять)
            — Фильтр списка пользователей по id/name/email (одно входное поле, SQL-запрос id = 'text' OR name LIKE '%text%' OR email LIKE '%text%')
            — Фильтр по наличию статей has_articles (добавим таблицу статей, API для нее не нужно)


            Сделаем готовое законченное приложение (не для формата туториала, а нормально) и сравним код.


    1. TrogWarZ
      29.12.2022 11:44
      +1

      60 строк кода, ага. А как насчёт кода внутри RegistrationForm? А вот это $validationResult->getDto()? `successResponse()`? И это только для всего двух роутов регистрации/логина. Дорисуйте остальную сову?

      ---

      Теперь представим что у вас 30 сущностей и для всех из них нужен типичный CRUD с разделением доступа по 3-4 ролям.

      С подходом от APiPlatform я добавлю 30 аннотаций с security параметром и всё (1-4 строки для каждой сущности). И может накину два кастомных контроллера для очень специфических роутов.

      Вы же сделаете 30 сервисов и 30*4 контроллеров (ну или 30 контроллеров с 4 методами в каждом по вкусу), в котором будет примерно один и тот же код? Да ещё и самописный, а не оттестированный тысячами пользователей?

      Теперь добавим фильтрацию по любым параметрам, например, которая должна работать везде – сколько кода займёт это в вашем подходе? Явно не по одной строке для каждой сущности (в ApiPlatform именно так).

      ---

      Я это к тому что для каждой задачи свой инструмент. И если в ваших проектах используется 5-6 сущностей со сложной логикой в каждом роуте – ApiPlatform ничего не упростит. Но если у вас большой проект с кучей сущностей и связей между ними и нужен разнообразный CRUD, то оно сэкономит очень много времени разработки и тестирования.


      1. michael_v89
        29.12.2022 12:27

        А как насчёт кода внутри RegistrationForm?

        Так и автор в статье не весь код привел.


        Теперь представим что у вас 30 сущностей и для всех из них нужен типичный CRUD

        Я уже представил и написал про это, типичный CRUD без логики это бессмысленно. Отдельный сервис с API делают для того, чтобы сконцентрировать там некоторую бизнес-логику, какие-то знания о предметной области, чтобы не помещать их в другие системы. А если там простой CRUD, значит вся бизнес-логика находится в клиенте этого API, то есть мы не достигли этой цели. А если не простой, значит там God-object в обработчике PATCH с кучей проверок вида if ($srcArticleState->is_published === false && $newArticleState->is_published === true) $this->publishArticle(); else $this->hideArticle();.


        Вы же сделаете 30 сервисов и 30*4 контроллеров (ну или 30 контроллеров с 4 методами в каждом по вкусу), в котором будет примерно один и тот же код?

        У меня не будет 4 метода CRUD, у меня будут методы по числу бизнес-сценариев. Для пользователя например это register, login, activate, saveProfile и т.д.
        В сервисах не будет один и тот же код. В контроллерах да, он похож, но если хочется, можно это вынести в какую-нибудь абстракцию. Это все равно будет проще и гибче, чем API Platform.
        Проблема API Platform не в том, что это абстракция, а в том, что она заточена на REST и CRUD-действия, что для большинства реальных приложений не подходит.


        Теперь добавим фильтрацию по любым параметрам, например, которая должна работать везде

        Вот как раз для фильтрации в реальных приложениях мой подход более удобен. Набор полей в фильтрах обычно не соответствует набору полей сущности. В сущности есть поле created_at, а в фильтре 2 поля "от" и "до" (я знаю, что в API Platform есть специальный фильтр для этого). В фильтре галочка "has_images", а в сущности такого поля нет, нужен специальный подзапрос. В фильтре есть поле "search_text", и оно должно искать по нескольким текстовым полям сущности (название товара, guid, артикул).
        Вот кстати фильтрация по любым полям обычно нужна когда бизнес-логика находится на клиенте.


        сколько кода займёт это в вашем подходе?

        Я ж выше предложил написать и сравнить. Пишите, сравним.