0
0
Lập trình
Hưng Nguyễn Xuân 1
Hưng Nguyễn Xuân 1xuanhungptithcm

Xây Dựng REST API Hoàn Chỉnh Với Symfony 7

Đăng vào 7 tháng trước

• 10 phút đọc

Hướng Dẫn Xây Dựng REST API Hoàn Chỉnh Với Symfony 7

Giới Thiệu

Trong bài viết này, chúng ta sẽ tìm hiểu cách xây dựng một REST API hiện đại bằng Symfony 7. Chúng ta sẽ từ việc xác thực dữ liệu với DTOs, đến việc viết controller sạch và những thực tiễn tốt nhất để duy trì mã nguồn. Ví dụ trong bài viết này sẽ là một API về cocktail 🍸.

Bạn sẽ học cách:

  • ✅ Sử dụng DTOs để xác thực các yêu cầu đầu vào
  • ✅ Ánh xạ yêu cầu vào một Entity với ObjectMapper
  • ✅ Giữ cho các controller gọn gàng và dễ kiểm thử

Tạo Cocktail (Phương Thức POST)

Khi xây dựng các API hiện đại, việc sử dụng DTOs (Data Transfer Objects) và xác thực sẽ giúp mã nguồn của bạn sạch, an toàn và dễ bảo trì.

DTO Với Xác Thực

DTO này đảm bảo rằng bất kỳ yêu cầu nào gửi đến API của bạn đều tuân theo các quy tắc đúng (ví dụ: độ dài tên, URL hợp lệ, ít nhất một thành phần).

php Copy
<?php

namespace App\Dto;

use App\Entity\Cocktail;
use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\Validator\Constraints as Assert;

#[Map(target: Cocktail::class)]
final readonly class CreateCocktailRequest
{
    public function __construct(
        #[Assert\NotBlank]
        #[Assert\Length(min: 2, max: 255)]
        public string $name,

        #[Assert\NotBlank]
        #[Assert\Length(min: 10)]
        public string $description,

        #[Assert\NotBlank]
        public string $instructions,

        #[Assert\NotBlank]
        #[Assert\Count(min: 1)]
        public array $ingredients,

        #[Assert\Range(min: 1, max: 5)]
        public int $difficulty,

        public bool $isAlcoholic,

        #[Assert\Url]
        public ?string $imageUrl,
    ) {
    }
}

Controller

Nhờ vào thành phần ObjectMapper, việc ánh xạ giữa các DTO và entities trở nên dễ dàng.

php Copy
<?php

namespace App\Controller\Api;

use App\Dto\CreateCocktailRequest;
use App\Entity\Cocktail;
use App\Repository\CocktailRepository;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
use Symfony\Component\Routing\Attribute\Route;

class CreateCocktailController
{
    public function __construct(
        private readonly CocktailRepository $cocktailRepository,
        private readonly ObjectMapperInterface $objectMapper
    ) {
    }

    #[Route('/api/cocktails', name: 'api.cocktails.create', methods: ['POST'])]
    public function __invoke(#[MapRequestPayload] CreateCocktailRequest $request): Response
    {
        $cocktail = $this->objectMapper->map($request, Cocktail::class);

        $this->cocktailRepository->save($cocktail);

        return new Response(null, Response::HTTP_CREATED);
    }
}

Liệt Kê Tất Cả Các Cocktail (Phương Thức GET)

Sau khi tạo cocktail, chúng ta hãy xây dựng endpoint để liệt kê chúng với các bộ lọc.
Sử dụng Symfony 7, chúng ta có thể giữ mọi thứ sạch sẽ và an toàn với DTO, bộ lọc repository và ánh xạ truy vấn tự động.

Query DTO

DTO này làm cho các tham số truy vấn trở nên rõ ràng (tên, có rượu, độ khó, phân trang…).

php Copy
<?php

namespace App\Dto;

final readonly class ListCocktailsQuery
{
    public function __construct(
        public ?string $name = null,
        public ?bool $isAlcoholic = null,
        public ?int $difficulty = null,
        public int $page = 1,
        public int $itemsPerPage = 10,
    ) {
    }
}

Repository

Ở đây, repository xử lý việc lọc + phân trang một cách đơn giản và có thể tái sử dụng.

php Copy
    /**
     * @return Cocktail[]
     */
    public function findAllWithFilters(ListCocktailsQuery $query): array
    {
        $qb = $this->createQueryBuilder('cocktail');

        if ($query->name) {
            $qb
                ->andWhere('cocktail.name LIKE :name')
                ->setParameter('name', "%$query->name%");
        }

        if (null !== $query->isAlcoholic) {
            $qb
                ->andWhere('cocktail.isAlcoholic = :isAlcoholic')
                ->setParameter('isAlcoholic', $query->isAlcoholic);
        }

        if ($query->difficulty) {
            $qb
                ->andWhere('cocktail.difficulty = :difficulty')
                ->setParameter('difficulty', $query->difficulty);
        }

        $offset = ($query->page - 1) * $query->itemsPerPage;

        $qb
            ->setFirstResult($offset)
            ->setMaxResults($query->itemsPerPage);

        return $qb->getQuery()->getResult();
    }

Controller

Nhờ vào #[MapQueryString], Symfony 7 tự động ánh xạ các tham số truy vấn vào DTO 💡.

php Copy
<?php

namespace App\Controller\Api;

use App\Dto\ListCocktailsQuery;
use App\Repository\CocktailRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface;

class ListCocktailsController
{
    public function __construct(
        private readonly CocktailRepository $cocktailRepository,
        private readonly SerializerInterface $serializer,
    ) {
    }

    #[Route('/api/cocktails', name: 'api.cocktails.list', methods: ['GET'])]
    public function __invoke(#[MapQueryString] ListCocktailsQuery $filter): Response
    {
        $cocktails = $this->cocktailRepository->findAllWithFilters($filter);

        $data = $this->serializer->serialize($cocktails, 'json', [
            'groups' => ['cocktail:read']
        ]);

        return JsonResponse::fromJsonString($data);
    }
}

Hiển Thị Một Cocktail (GET)

Sau khi liệt kê cocktail, hãy thêm endpoint để lấy một cocktail đơn lẻ theo ID của nó.

Với Symfony 7, điều này trở nên cực kỳ sạch sẽ nhờ vào #[MapEntity]:

Controller

php Copy
<?php

namespace App\Controller\Api;

use App\Entity\Cocktail;
use App\Repository\CocktailRepository;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface;

class ShowCocktailController
{
    public function __construct(
        private readonly SerializerInterface $serializer,
    ) {
    }

    #[Route('/api/cocktails/{id}', name: 'api.cocktails.show', methods: ['GET'])]
    public function __invoke(#[MapEntity(expr: 'repository.find(id)', message: 'Not found')] Cocktail $cocktail): Response
    {
        $data = $this->serializer->serialize($cocktail, 'json', [
            'groups' => ['cocktail:read']
        ]);

        return JsonResponse::fromJsonString($data);
    }
}

Cập Nhật Cocktail

Thời gian để làm cho API của chúng ta có thể chỉnh sửa.
Với Symfony 7, chúng ta có thể chấp nhận các cập nhật một phần (PATCH) và thay thế hoàn toàn (PUT) bằng cách sử dụng DTO cập nhật dành riêng + ObjectMapper để giữ cho các controller gọn gàng và an toàn.

Update DTO

php Copy
<?php

namespace App\Dto;

use App\Entity\Cocktail;
use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\Validator\Constraints as Assert;

#[Map(target: Cocktail::class)]
final readonly class UpdateCocktailRequest
{
    public function __construct(
        #[Assert\Length(max: 255)]
        public ?string $name = null,

        #[Assert\Length(min: 10)]
        public ?string $description = null,

        #[Assert\NotBlank]
        public ?string $instructions = null,

        #[Assert\NotBlank]
        #[Assert\Count(min: 1)]
        public ?array $ingredients = null,

        #[Assert\Range(min: 1, max: 5)]
        public ?int $difficulty = null,

        public ?bool $isAlcoholic = null,

        #[Assert\Url]
        public ?string $imageUrl = null,
    ) {}
}

Controller

php Copy
<?php

namespace App\Controller\Api;

use App\Dto\UpdateCocktailRequest;
use App\Entity\Cocktail;
use App\Repository\CocktailRepository;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface;

class UpdateCocktailController
{
    public function __construct(
        private readonly CocktailRepository $cocktailRepository,
        private readonly ObjectMapperInterface $objectMapper,
        private readonly SerializerInterface $serializer
    ) {
    }

    #[Route('/api/cocktails/{id}', name: 'api.cocktails.update', methods: ['PUT', 'PATCH'])]
    public function __invoke(
        #[MapEntity(expr: 'repository.find(id)', message: 'Not found')] Cocktail $cocktail,
        #[MapRequestPayload] UpdateCocktailRequest $request
    ): JsonResponse {
        $updatedCocktail = $this->objectMapper->map($request, $cocktail);

        $this->cocktailRepository->save($updatedCocktail);

        $data = $this->serializer->serialize($updatedCocktail, 'json', [
            'groups' => ['cocktail:read']
        ]);

        return JsonResponse::fromJsonString($data);
    }
}

Xóa Cocktail (Phương Thức DELETE)

Phần cuối cùng của API CRUD của chúng ta: xóa một tài nguyên.
Với Symfony 7, nó vẫn giữ được sự sạch sẽ và tối giản nhờ vào #[MapEntity].

Controller

php Copy
<?php

namespace App\Controller\Api;

use App\Entity\Cocktail;
use App\Repository\CocktailRepository;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class DeleteCocktailController
{
    public function __construct(
        private readonly CocktailRepository $cocktailRepository
    ) {
    }

    #[Route('/api/cocktails/{id}', name: 'api.cocktails.delete', methods: ['DELETE'])]
    public function __invoke( #[MapEntity(expr: 'repository.find(id)', message: 'Not found')] Cocktail $cocktail): Response
    {
        $this->cocktailRepository->remove($cocktail);

        return new Response(null, Response::HTTP_NO_CONTENT);
    }
}

Xác Thực API Key

Sau khi CRUD đã sẵn sàng, bước tiếp theo là bảo mật API.
Dưới đây là cách triển khai một xác thực API key tùy chỉnh bằng cách sử dụng hệ thống Security của Symfony 7.

Xác Thực Tùy Chỉnh

php Copy
<?php

namespace App\Security;

use App\Entity\User;
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\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
{
    public function supports(Request $request): ?bool
    {
        return $request->headers->has('API-KEY');
    }

    public function authenticate(Request $request): Passport
    {
        $apiKey = $request->headers->get('API-KEY');
        if ($apiKey !== 'secret') {
            throw new AuthenticationException('Invalid API key');
        }

        return new SelfValidatingPassport(
            new UserBadge($apiKey, fn() => (new User())->setUsername('API-USER'))
        );
    }

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

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        $data = [
            'message' => strtr($exception->getMessageKey(), $exception->getMessageData()),
        ];

        return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
    }
}

Cấu Hình security.yaml

yaml Copy
    firewalls:
        main:
            custom_authenticators:
                - App\Security\ApiKeyAuthenticator
            lazy: true
            provider: app_user_provider
            stateless: true

Kết Luận

Vậy là chúng ta đã xây dựng xong một REST API CRUD hoàn chỉnh với Symfony 7 🚀

  • Tạo cocktail (POST)
  • Đọc cocktail (GET tất cả / GET theo id)
  • Cập nhật cocktail (PUT/PATCH)
  • Xóa cocktail (DELETE)

Trong suốt quá trình này, chúng ta đã sử dụng:

  • ✅ DTOs cho việc xác thực yêu cầu sạch sẽ
  • ✅ ObjectMapper cho việc ánh xạ giữa DTOs và entities
  • ✅ Serializer với các nhóm cho phản hồi JSON có cấu trúc
  • ✅ MapEntity / MapRequestPayload / MapQueryString cho các controller sạch hơn

Cách tiếp cận này giữ cho các controller tối thiểu, mã nguồn dễ bảo trì và APIs dễ dự đoán.

👉 Để tìm hiểu thêm, hãy xem video hướng dẫn đầy đủ với các bước bổ sung (xác thực, bộ lọc, triển khai trên Cloudways): Video Hướng Dẫn

Gợi ý câu hỏi phỏng vấn
Không có dữ liệu

Không có dữ liệu

Bài viết được đề xuất
Bài viết cùng tác giả

Bình luận

Chưa có bình luận nào

Chưa có bình luận nào