Как легко и быстро развернуть API на фреймворке Symfony с уже встроенной валидацией и авторизацией по токену?
Можно воспользоваться бандлом для Symfony 6+
Установка бандла
Как и любая другая библиотека ставится этот бандл с помощью композера:
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->id;
}
/**
* @param int $id
*/
public function setId(int $id): void
{
$this->id = $id;
}
/**
* @return string
*/
public function getTitle(): string
{
return $this->title;
}
/**
* @param string $title
*/
public function setTitle(string $title): void
{
$this->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->success = $success;
$this->title = $title;
}
/**
* @return string
*/
public function getTitle(): string
{
return $this->title;
}
/**
* @param string $title
*/
public function setTitle(string $title): void
{
$this->title = $title;
}
/**
* @return bool
*/
public function isSuccess(): bool
{
return $this->success;
}
/**
* @param bool $success
*/
public function setSuccess(bool $success): void
{
$this->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->id;
}
/**
* @param int $id
*
* @return ApiToken
*/
public function setId(int $id): ApiToken
{
$this->id = $id;
return $this;
}
/**
* @return string
*/
public function getToken(): string
{
return $this->token;
}
/**
* @param string $token
*
* @return ApiToken
*/
public function setToken(string $token): ApiToken
{
$this->token = $token;
return $this;
}
/**
* @return DateTimeInterface
*/
public function getExpiresAt(): DateTimeInterface
{
return $this->expiresAt;
}
/**
* @param DateTimeInterface $expiresAt
*
* @return ApiToken
*/
public function setExpiresAt(DateTimeInterface $expiresAt): ApiToken
{
$this->expiresAt = $expiresAt;
return $this;
}
/**
* @return User
*/
public function getUser(): User
{
return $this->user;
}
/**
* @param User $user
*
* @return ApiToken
*/
public function setUser(User $user): ApiToken
{
$this->user = $user;
return $this;
}
/**
* @return bool
*/
public function isValid(): bool
{
return (new DateTime())->getTimestamp() > $this->expiresAt->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->getRequestUri(), '/api/v');
}
/**
* @param Request $request
*
* @return Passport
*/
public function authenticate(Request $request): Passport
{
$apiToken = $request->headers->get('X-AUTH-TOKEN');
if (null === $apiToken) {
throw new CustomUserMessageAuthenticationException('No API token provided');
}
$apiTokenEntity = $this->em->getRepository(ApiToken::class)->findOneBy(['token' => $apiToken]);
if (is_null($apiTokenEntity)) {
throw new CustomUserMessageAuthenticationException('No API token provided');
}
return new SelfValidatingPassport(new UserBadge(
$apiTokenEntity->getUser()->getId(),
function () use ($apiTokenEntity) {
return $this->em->getRepository(User::class)->find($apiTokenEntity->getUser()->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' => strtr($exception->getMessageKey(), $exception->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)
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
как-то странно рендерит.otezvikentiy Автор
11.08.2023 18:20-1Спасибо большое за рекомендации, попробую применить.
По поводу MethodSpecCollection и инициализации при каждом запросе - мне вроде как казалось, что в прод окружении это всё кэшируется и как раз таки билд происходит только раз при прогреве кэша, а дальше все это каждый раз не прогоняется как раз таки. Разве нет?
По поводу gitflic - да в целом несколько причин, поддержать отечественного производителя, хорошие ребята, знаком с создателями сервиса довольно тесно. ))) Поэтому и зашёл к ним. Раньше хостил код на bitbucket, но он начал хворать, поэтому перенес всё оттуда. )))
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
FranCOder
11.08.2023 18:20Как легко и быстро развернуть API на фреймворке Symfony с уже встроенной валидацией и авторизацией по токену?
Использовать API Platform?
otezvikentiy Автор
11.08.2023 18:20Мне не зашло - слишком громоздко для меня. Мне не нужны круды и много чего не нужно оттуда. Соответственно зачем мне держать весь этот неиспользуемый код?
zubrbonasus
11.08.2023 18:20Не понял в чем кайф этого бандла, но вот запись формата
$this->id = $id;
возьму на заметку :).
hudson
Если не обращать внимание на отсутствующий тег "я пиарюсь", в начале статьи хотелось бы видеть какой-то мотивационный текст - зачем нам нужно установить именно этот бандл с 14 инсталляциями?
Чем он отличается от существующих, для решения каких проблем создан, что делает лучше существующих решений, что еще не делает (но несомненно будет) и т.д.
otezvikentiy Автор
А чего, пиариться плохо что ли? ))) Для меня в целом это небольшое достижение, что появился собственный бандл, которым можно поделиться с другими разработчиками, потому что до этого не было такой ни возможности ни темы для библиотеки. Поэтому... Да, немного похвастался XD
А какие есть другие решения я просто не нашел на самом деле. Для себя искал и в целом не нашёл... Поэтому и написал себе сам )))
gmtd
Есть и другие решения - свои голова и руки
Реализация конкретно этого кода на чистом PHP и JSON-RPC составляет вообще-то около 10 строк. Еще строк 50 - реализация роутинга методов JSON-RPC.
Также советую для авторизации для передачи токена использовать параметры запроса JSON-RPC - то есть, не привязываться к транспортному протоколу (хедерам HTTP) вообще.
Но за JSON-RPC всячески голосую - очень удобная вещь. На самом деле, JSON-RPC делает ненужным бэкенд фрейворк, за исключением библиотеки доступа к БД