Тест на вакансию

Создание REST API приложения на Symfony с авторизацией по токену

19 августа 2025 г.
74

Установка Symfony

symfony new symfony_api_app --webapp
cd symfony_api_app
Где --webapp, указывает, что нужно установить все основные пакеты, необходимые для веб-разработки.

Установка необходимых пакетов для API и JWT аутентификации

composer require symfony/security-bundle
composer require symfony/maker-bundle --dev
composer require lexik/jwt-authentication-bundle
composer require doctrine/orm doctrine/annotations
composer require symfony/serializer symfony/validator

Где:
  • symfony/security-bundle: Основа для безопасности в Symfony.
  • symfony/maker-bundle: Помогает генерировать boilerplate код (только для разработки).
  • lexik/jwt-authentication-bundle: Реализует аутентификацию на основе JWT (JSON Web Token).
  • doctrine/orm doctrine/annotations: Для работы с базой данных и сущностями.
  • symfony/serializer symfony/validator: Для сериализации данных в JSON и валидации входных данных.

Настройка базы данных

Установим Doctrine Migrations:
composer require doctrine/doctrine-migrations-bundle

Отредактируем файл .env в корне вашего проекта и настроим параметры подключения к базе данных (MySQL):

DATABASE_URL="mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=5.7"
Создадим базу данных (если она еще не существует):
php bin/console doctrine:database:create

Настройка JWT ключей

mkdir config/jwt
openssl genpkey -out config/jwt/private.pem -aes256 -algorithm RSA -pkeyopt rsa_keygen_bits:4096
openssl pkey -in config/jwt/private.pem -out config/jwt/public.pem -pubout
PEM pass phrase (парольная фраза) берётся из файла .env (JWT_PASSPHRASE).

Создание User

Используйте maker-bundle для создания сущности User:
php bin/console make:entity User
Укажим, какие поля нужны:
  • email (string, length 180, unique)
  • password (string)
  • roles (json)
Отредактируем файл (src/Entity/User.php):
<?php

namespace App\Entity;

use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_EMAIL', fields: ['email'])]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;
    #[ORM\Column(length: 180)]
    private ?string $email = null;
    /**
     * @var list<string> The user roles
     */
    #[ORM\Column]
    private array $roles = [];
    /**
     * @var string The hashed password
     */
    #[ORM\Column]
    private ?string $password = null;
    public function getId(): ?int
    {
        return $this->id;
    }
    public function getEmail(): ?string
    {
        return $this->email;
    }
    public function setEmail(string $email): static
    {
        $this->email = $email;
        return $this;
    }
    /**
     * A visual identifier that represents this user.
     *
     * @see UserInterface
     */
    public function getUserIdentifier(): string
    {
        return (string) $this->email;
    }
    /**
     * @see UserInterface
     */
    public function getRoles(): array
    {
        $roles = $this->roles;
        // guarantee every user at least has ROLE_USER
        $roles[] = 'ROLE_USER';
        return array_unique($roles);
    }
    /**
     * @param list<string> $roles
     */
    public function setRoles(array $roles): static
    {
        $this->roles = $roles;
        return $this;
    }
    /**
     * @see PasswordAuthenticatedUserInterface
     */
    public function getPassword(): ?string
    {
        return $this->password;
    }
    public function setPassword(string $password): static
    {
        $this->password = $password;
        return $this;
    }
    /**
     * Ensure the session doesn't contain actual password hashes by CRC32C-hashing them, as supported since Symfony 7.3.
     */
    public function __serialize(): array
    {
        $data = (array) $this;
        $data["\0".self::class."\0password"] = hash('crc32c', $this->password);
        return $data;
    }
    #[\Deprecated]
    public function eraseCredentials(): void
    {
        // @deprecated, to be removed when upgrading to Symfony 8
    }
}
Отредактируем файл (src/Repository/UserRepository.php):
<?php

namespace App\Repository;

use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;

class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, User::class);
    }
    public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
    {
        if (!$user instanceof User) {
            throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class));
        }
        $user->setPassword($newHashedPassword);
        $this->getEntityManager()->persist($user);
        $this->getEntityManager()->flush();
    }
}
Создание миграции и обновление базы данных:
php bin/console make:migration
php bin/console doctrine:migrations:migrate
Создадим команду консоли, чтобы упростить создание пользователей с хэшированными паролями:
php bin/console make:command CreateUserCommand
Отредактируем файл (src/Command/CreateUserCommand.php):
<?php

namespace App\Command;

use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

#[AsCommand(
    name: 'app:create-user',
    description: 'Creates a new user account',
)]
class CreateUserCommand extends Command
{
    public function __construct(
        private EntityManagerInterface $entityManager,
        private UserPasswordHasherInterface $passwordHasher
    ) {
        parent::__construct();
    }
    protected function configure(): void
    {
        $this
            ->addArgument('email', InputArgument::REQUIRED, 'User email')
            ->addArgument('password', InputArgument::REQUIRED, 'User password');
    }
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $email = $input->getArgument('email');
        $plainPassword = $input->getArgument('password');
        // Проверяем, существует ли пользователь
        $existingUser = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $email]);
       
        if ($existingUser) {
            $output->writeln('User with this email already exists!');
            return Command::FAILURE;
        }
        // Создаем нового пользователя
        $user = new User();
        $user->setEmail($email);
        $user->setRoles(['ROLE_USER']);
        // Хешируем пароль
        $hashedPassword = $this->passwordHasher->hashPassword($user, $plainPassword);
        $user->setPassword($hashedPassword);
        // Сохраняем пользователя
        $this->entityManager->persist($user);
        $this->entityManager->flush();
        $output->writeln('User created successfully!');
        $output->writeln('Email: ' . $email);
        return Command::SUCCESS;
    }
}
Внесём нашу команду (config/services.yaml):
parameters:
services:
    _defaults:
        autowire: true
        autoconfigure: true
    App\:
        resource: '../src/'
   
    App\Command\CreateUserCommand:
        tags: [console.command]
Создадим нового пользователя:
php bin/console app:create-user test@test.ru 123456

Создание контроллера аутентификации

php bin/console make:controller AuthController
Отредактируем файл (src/Controller/AuthController.php):
<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

class AuthController extends AbstractController
{
    #[Route('/api/login', name: 'api_login', methods: ['POST'])]
    public function login(): JsonResponse
    {
        return new JsonResponse([
            'message' => 'Login should be handled by JSON login',
            'status' => 'error'
        ], 200);
    }
}
Внесём изменения в файл security.yaml (config/packages/security.yaml):
security:
    password_hashers:
        App\Entity\User:
            algorithm: auto
    providers:
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email
    firewalls:
        login:
            pattern: ^/api/login
            stateless: true
            json_login:
                check_path: /api/login
                username_path: email
                password_path: password
                success_handler: lexik_jwt_authentication.handler.authentication_success
                failure_handler: lexik_jwt_authentication.handler.authentication_failure
        api:
            pattern: ^/api
            stateless: true
            jwt: ~
    access_control:
        - { path: ^/api/login, roles: PUBLIC_ACCESS }
        - { path: ^/api,       roles: IS_AUTHENTICATED_FULLY }

Создание Article

php bin/console make:entity Article
Укажем, какие поля нужны:
  • title (string, length 255)
  • description (text)
Создадим контроллер:
php bin/console make:crud Article
Или так:
php bin/console make:controller Article
Отредактируем файл (src/Controller/ArticleController.php):
<?php

namespace App\Controller;

use App\Entity\Article;
use App\Repository\ArticleRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

#[Route('/api/articles')]
class ArticleController extends AbstractController
{
    private $serializer;
    public function __construct(SerializerInterface $serializer)
    {
        $this->serializer = $serializer;
    }

    #[Route('', name: 'article_list', methods: ['GET'])]
    public function index(ArticleRepository $articleRepository): JsonResponse
    {
        $articles = $articleRepository->findAll();
       
        $data = $this->serializer->serialize($articles, 'json');
        return new JsonResponse($data, 200, [], true);
    }

    #[Route('/{id}', name: 'article_show', methods: ['GET'])]
    public function show(int $id, EntityManagerInterface $entityManager): JsonResponse
    {
        $article = $entityManager->getRepository(Article::class)->find($id);
        if (!$article) {
            return $this->json([
                'message' => 'Article not found'
            ], Response::HTTP_NOT_FOUND);
        }
        $data = $this->serializer->serialize($article, 'json');
        return new JsonResponse($data, 200, [], true);
    }

    #[Route('', name: 'article_create', methods: ['POST'])]
    public function create(Request $request, EntityManagerInterface $entityManager): JsonResponse
    {
        $data = json_decode($request->getContent(), true);
        $article = new Article();
        $article->setTitle($data['title']);
        $article->setDescription($data['description'] ?? '');
        $entityManager->persist($article);
        $entityManager->flush();
        $responseData = $this->serializer->serialize($article, 'json');
        return new JsonResponse($responseData, 201, [], true);
    }

    #[Route('/{id}', name: 'article_update', methods: ['PUT'])]
    public function update(Request $request, int $id, EntityManagerInterface $entityManager): JsonResponse
    {
        $article = $entityManager->getRepository(Article::class)->find($id);
        if (!$article) {
            return $this->json([
                'message' => 'Article not found'
            ], Response::HTTP_NOT_FOUND);
        }
        $data = json_decode($request->getContent(), true);
        if (isset($data['title'])) {
            $article->setTitle($data['title']);
        }
        if (isset($data['description'])) {
            $article->setDescription($data['description']);
        }
        $entityManager->flush();
        $responseData = $this->serializer->serialize($article, 'json');
        return new JsonResponse($responseData, 200, [], true);
    }

    #[Route('/{id}', name: 'article_delete', methods: ['DELETE'])]
    public function delete(int $id, EntityManagerInterface $entityManager): JsonResponse
    {
        $article = $entityManager->getRepository(Article::class)->find($id);
       
        if (!$article) {
            return $this->json([
                'message' => 'Article not found'
            ], Response::HTTP_NOT_FOUND);
        }
        $entityManager->remove($article);
        $entityManager->flush();
        return $this->json([
            'message' => 'Article deleted successfully'
        ], Response::HTTP_OK);
    }

    #[Route('/{id}', name: 'article_patch', methods: ['PATCH'])]
    public function patch(Request $request, int $id, EntityManagerInterface $entityManager): JsonResponse
    {
        $article = $entityManager->getRepository(Article::class)->find($id);
        if (!$article) {
            return $this->json([
                'message' => 'Article not found'
            ], Response::HTTP_NOT_FOUND);
        }
       
        $data = json_decode($request->getContent(), true);
        if (isset($data['title'])) {
            $article->setTitle($data['title']);
        }
        if (isset($data['description'])) {
            $article->setDescription($data['description']);
        }
        $entityManager->flush();
        $responseData = $this->serializer->serialize($article, 'json');
        return new JsonResponse($responseData, 200, [], true);
    }
}
Создание миграции и обновление базы данных:
php bin/console make:migration
php bin/console doctrine:migrations:migrate

Тестирование API

Запускаем наш API:
symfony server:start
Сервер запуститься по адресу http://127.0.0.1:8000.

Если сервер запускали до этого, то сперва:
symfony server:stop
Для тестирования API можно использовать:
  • Postman
  • curl
Получение токена:
curl -X POST http://localhost:8000/api/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "test@test.ru",
    "password": "123456"
  }'
Получение всех статей:
curl -X GET http://localhost:8000/api/articles \
  -H "Authorization: Bearer <your_token>"
Создание новой статьи:
curl -X POST http://localhost:8000/api/articles \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <your_token>" \
  -d '{
    "title": "Новая статья",
    "description": "Описание новой статьи"
  }'
Получение конкретной статьи:
curl -X GET http://localhost:8000/api/articles/1 \
  -H "Authorization: Bearer <your_token>"
Обновление статьи:
curl -X PUT http://localhost:8000/api/articles/1 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <your_token>" \
  -d '{
    "title": "Обновленная статья",
    "description": "Обновленное описание статьи"
  }'
Частичное обновление статьи:
curl -X PATCH http://localhost:8000/api/articles/1 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <your_token>" \
  -d '{"title": "Только заголовок изменен"}'
Удаление статьи:
curl -X DELETE http://localhost:8000/api/articles/1 \
  -H "Authorization: Bearer <your_token>"
Наш API для работы со статьями на Symfony готов и протестирован!
Поделиться: