Как легко и быстро развернуть API на фреймворке Symfony с уже встроенной валидацией и авторизацией по токену?

Можно воспользоваться бандлом для Symfony 6+

Посмотреть на packagist

Посмотреть на gitflick

Установка бандла

Как и любая другая библиотека ставится этот бандл с помощью композера:

composer require otezvikentiy/json-rpc-api

Далее проверяем, что бандл попал в инсталляцию в файле config/bundles.php файле:

<?php
// config/bundles.php
return [
    //...
    OV\JsonRPCAPIBundle\OVJsonRPCAPIBundle::class => ['all' => true],
];

После этого нам нужно создать файл config/routes/ov_json_rpc_api.yaml со следующим содержимым:

# config/routes/ov_json_rpc_api.yaml
ov_json_rpc_api:
   resource: '@OVJsonRPCAPIBundle/config/routes/routes.yaml'

И в файлике config/services.yaml в секцию services добавить раздел с той папкой, которая предполагается для реализации всех ваших дальнейших методов API. Например так:

# config/services.yaml
services:
    App\RPC\V1\:
        resource: '../src/RPC/V1/{*Method.php}'
        tags:
            - { name: ov.rpc.method, namespace: App\RPC\V1\, version: 1 }

На этом всё! Установка закончена - вы можете создавать методы своего API! 

Испытания и первые методы

Теперь вы можете создать ваш первый метод API. Давайте попробуем реализовать какой-нибудь тестовый простой метод. Для этого нам потребуется создать 3 файлика вот по такой вот схеме:

└── src
    └── RPC
        └── V1
            └── getProducts
                ├── GetProductsRequest.php
                └── GetProductsResponse.php
            └── GetProductsMethod.php
<?php

namespace App\RPC\V1\getProducts;

class GetProductsRequest
{
    private int $id;
    private string $title;

    /**
     * @param int $id
     */
    public function __construct(int $id)
    {
        $this->id = $id;
    }
    
    /**
     * @return int
     */
    public function getId(): int
    {
        return $this-&gt;id;
    }
    
    /**
     * @param int $id
     */
    public function setId(int $id): void
    {
        $this->id = $id;
    }
    
    /**
     * @return string
     */
    public function getTitle(): string
    {
        return $this-&gt;title;
    }
    
    /**
     * @param string $title
     */
    public function setTitle(string $title): void
    {
        $this-&gt;title = $title;
    }

}
<?php

namespace App\RPC\V1\getProducts;

class GetProductsResponse
{
    private bool $success;
    private string $title;

    /**
     * @param string $title
     * @param bool $success
     */
    public function __construct(string $title, bool $success = true)
    {
        $this-&gt;success = $success;
        $this-&gt;title = $title;
    }
    
    /**
     * @return string
     */
    public function getTitle(): string
    {
        return $this-&gt;title;
    }
    
    /**
     * @param string $title
     */
    public function setTitle(string $title): void
    {
        $this-&gt;title = $title;
    }
    
    /**
     * @return bool
     */
    public function isSuccess(): bool
    {
        return $this-&gt;success;
    }
    
    /**
     * @param bool $success
     */
    public function setSuccess(bool $success): void
    {
        $this-&gt;success = $success;
    }

}
<?php

namespace App\RPC\V1;

use OV\JsonRPCAPIBundle\Core\Annotation\JsonRPCAPI;
use App\RPC\V1\getProducts\GetProductsRequest;
use App\RPC\V1\getProducts\GetProductsResponse;

/**
 *
 * @JsonRPCAPI(methodName = "getProducts")
 */
#[JsonRPCAPI(methodName: 'getProducts')]
class GetProductsMethod
{
    /**
     * @param GetProductsRequest $request
     * @return GetProductsResponse
     */
    public function call(GetProductsRequest $request): GetProductsResponse
    {
        // здесь осуществляете всю логику вашего метода API
        $id = $request->getId();
        return new GetProductsResponse($request->getTitle().'OLOLOLOLO');
    }
}

Теперь вы можете выполнить curl-запрос например так:

curl --header "Content-Type: application/json" --request POST --data '{"jsonrpc": "2.0","method": "getProducts","params": {"title": "AZAZAZA"},"id": 1}' http://localhost/api/v1

И получите ответ:

{"jsonrpc":"2.0","result":{"title":"AZAZAZAOLOLOLOLO","success":true},"id":null}

Получается для реализации простейшего Json RPC API вам потребуется создать всего 3 класса.

Авторизация по токену

Авторизация по токену реализуется стандартно по документации symfony, но все же расскажу еще разок. 

Чтобы доступ в ваше API был по токену - вот пример реализации что для этого нужно сделать:

1) создаем сущность нашего токена в БД в файле src/Entity/ApiToken.php

<?php

namespace App\Entity;

use DateTime;
use DateTimeInterface;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class ApiToken
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private int $id;

    #[ORM\Column(type: 'string', length: 500, nullable: false)]
    private string $token;

    #[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: false)]
    private DateTimeInterface $expiresAt;

    #[ORM\ManyToOne(inversedBy: 'apiTokens')]
    #[ORM\JoinColumn(nullable: false)]
    private User $user;

    /**
     * @return int
     */
    public function getId(): int
    {
        return $this-&gt;id;
    }
    
    /**
     * @param int $id
     *
     * @return ApiToken
     */
    public function setId(int $id): ApiToken
    {
        $this-&gt;id = $id;
    
        return $this;
    }
    
    /**
     * @return string
     */
    public function getToken(): string
    {
        return $this-&gt;token;
    }
    
    /**
     * @param string $token
     *
     * @return ApiToken
     */
    public function setToken(string $token): ApiToken
    {
        $this-&gt;token = $token;
    
        return $this;
    }
    
    /**
     * @return DateTimeInterface
     */
    public function getExpiresAt(): DateTimeInterface
    {
        return $this-&gt;expiresAt;
    }
    
    /**
     * @param DateTimeInterface $expiresAt
     *
     * @return ApiToken
     */
    public function setExpiresAt(DateTimeInterface $expiresAt): ApiToken
    {
        $this-&gt;expiresAt = $expiresAt;
    
        return $this;
    }
    
    /**
     * @return User
     */
    public function getUser(): User
    {
        return $this-&gt;user;
    }
    
    /**
     * @param User $user
     *
     * @return ApiToken
     */
    public function setUser(User $user): ApiToken
    {
        $this-&gt;user = $user;
    
        return $this;
    }
    
    /**
     * @return bool
     */
    public function isValid(): bool
    {
        return (new DateTime())-&gt;getTimestamp() &gt; $this-&gt;expiresAt-&gt;getTimestamp();
    }

}

2) создаем кастомный аутентификатор в файле src/Security/ApiKeyAuthenticator.php

<?php

namespace App\Security;

use App\Entity\ApiToken;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
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
{
    /**
     * @param EntityManagerInterface $em
     */
    public function __construct(
        private readonly EntityManagerInterface $em
    ){
    }

    /**
     * @param Request $request
     *
     * @return bool
     */
    public function supports(Request $request): bool
    {
        return str_contains($request-&gt;getRequestUri(), '/api/v');
    }
    
    /**
     * @param Request $request
     *
     * @return Passport
     */
    public function authenticate(Request $request): Passport
    {
        $apiToken = $request-&gt;headers-&gt;get('X-AUTH-TOKEN');
        if (null === $apiToken) {
            throw new CustomUserMessageAuthenticationException('No API token provided');
        }
    
        $apiTokenEntity = $this-&gt;em-&gt;getRepository(ApiToken::class)-&gt;findOneBy(['token' =&gt; $apiToken]);
        if (is_null($apiTokenEntity)) {
            throw new CustomUserMessageAuthenticationException('No API token provided');
        }
    
        return new SelfValidatingPassport(new UserBadge(
            $apiTokenEntity-&gt;getUser()-&gt;getId(),
            function () use ($apiTokenEntity) {
                return $this-&gt;em-&gt;getRepository(User::class)-&gt;find($apiTokenEntity-&gt;getUser()-&gt;getId());
            }
        ));
    }
    
    /**
     * @param Request        $request
     * @param TokenInterface $token
     * @param string         $firewallName
     *
     * @return Response|null
     */
    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        return null;
    }
    
    /**
     * @param Request                 $request
     * @param AuthenticationException $exception
     *
     * @return Response|null
     */
    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        $data = [
            'message' =&gt; strtr($exception-&gt;getMessageKey(), $exception-&gt;getMessageData())
        ];
    
        return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
    }

}

3) добавляем новый firewall в файле config/security.yaml

security:
    # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
    password_hashers:
        Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
    # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
    providers:
        # used to reload user from session & other features (e.g. switch_user)
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email
    firewalls:
        api:
            pattern: ^/api
            provider: app_user_provider
            custom_authenticators:
                - App\Security\ApiKeyAuthenticator

4) создаем миграцию для токена, проводим миграцию, создаем какой-нибудь тестовый токен и можем пользоваться. ))) С токеном запрос curl будет выглядеть примерно так:

curl --header "X-AUTH-TOKEN: your-token-here" --header "Content-Type: application/json" --request POST --data '{"jsonrpc": "2.0","method": "getProducts","params": {"title": "AZAZAZA"},"id": 1}' http://localhost/api/v1

Оригинал статьи

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


  1. hudson
    11.08.2023 18:20
    +3

    Если не обращать внимание на отсутствующий тег "я пиарюсь", в начале статьи хотелось бы видеть какой-то мотивационный текст - зачем нам нужно установить именно этот бандл с 14 инсталляциями?

    Чем он отличается от существующих, для решения каких проблем создан, что делает лучше существующих решений, что еще не делает (но несомненно будет) и т.д.


    1. otezvikentiy Автор
      11.08.2023 18:20

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

      А какие есть другие решения я просто не нашел на самом деле. Для себя искал и в целом не нашёл... Поэтому и написал себе сам )))


      1. gmtd
        11.08.2023 18:20

        Есть и другие решения - свои голова и руки

        Получается для реализации простейшего Json RPC API вам потребуется создать всего 3 класса.

        Реализация конкретно этого кода на чистом PHP и JSON-RPC составляет вообще-то около 10 строк. Еще строк 50 - реализация роутинга методов JSON-RPC.

        Также советую для авторизации для передачи токена использовать параметры запроса JSON-RPC - то есть, не привязываться к транспортному протоколу (хедерам HTTP) вообще.

        Но за JSON-RPC всячески голосую - очень удобная вещь. На самом деле, JSON-RPC делает ненужным бэкенд фрейворк, за исключением библиотеки доступа к БД


  1. BoShurik
    11.08.2023 18:20
    +1

    И в файлике config/services.yaml в секцию services добавить раздел с той папкой, которая предполагается для реализации всех ваших дальнейших методов API.

    Лишний шаг, т.к. у вас все равно на эти методы навешиваются аттрибуты. В OVJsonRPCAPIBundle::build можно сделать что-то вроде

    $container->registerAttributeForAutoconfiguration(
        JsonRPCAPI::class,
        function (ChildDefinition $definition, JsonRPCAPI $attribute) {
            $tagAttributes = get_object_vars($attribute);
            $definition->addTag('ov.rpc.method', $tagAttributes);
        }
    );
    

    MethodSpecCollection в идеале заменить на ServiceLocator, чтобы не инициализировать все методы при каждом запросе

    P.S. Почему выбрали gitflic в качестве хранилища? У меня он composer.json как-то странно рендерит.


    1. otezvikentiy Автор
      11.08.2023 18:20
      -1

      Спасибо большое за рекомендации, попробую применить.

      По поводу MethodSpecCollection и инициализации при каждом запросе - мне вроде как казалось, что в прод окружении это всё кэшируется и как раз таки билд происходит только раз при прогреве кэша, а дальше все это каждый раз не прогоняется как раз таки. Разве нет?

      По поводу gitflic - да в целом несколько причин, поддержать отечественного производителя, хорошие ребята, знаком с создателями сервиса довольно тесно. ))) Поэтому и зашёл к ним. Раньше хостил код на bitbucket, но он начал хворать, поэтому перенес всё оттуда. )))


      1. BoShurik
        11.08.2023 18:20
        +1

        По поводу MethodSpecCollection и инициализации при каждом запросе

        Речь не про компиляцию, а рантайм. Условно в вашем случае результирующий код будет такой:

        $collection = new MethodSpecCollection();
        $collection->addMethodSpec('foo', $container->get(FooMethod::class));
        $collection->addMethodSpec('bar', $container->get(BarMethod::class));
        // И так 100500 раз
        

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

        В случае в локатором будет что-то вроде:

        $serviceLocator = new ServiceLocator();
        $serviceLocator->addMethodSpec('foo', fn () => $container->get(FooMethod::class));
        $serviceLocator->addMethodSpec('bar', fn () => $container->get(BarMethod::class));
        // И так 100500 раз
        

        т.о. они будут отрезолвлены в методе get


  1. FranCOder
    11.08.2023 18:20

    Как легко и быстро развернуть API на фреймворке Symfony с уже встроенной валидацией и авторизацией по токену?

    Использовать API Platform?


    1. otezvikentiy Автор
      11.08.2023 18:20

      Мне не зашло - слишком громоздко для меня. Мне не нужны круды и много чего не нужно оттуда. Соответственно зачем мне держать весь этот неиспользуемый код?


  1. zubrbonasus
    11.08.2023 18:20

    Не понял в чем кайф этого бандла, но вот запись формата $this-&gt;id = $id; возьму на заметку :).


    1. otezvikentiy Автор
      11.08.2023 18:20

      Спасибо, поправил. )))