Symfony 7 กับ API Platform: แนวทางปฏิบัติที่ดีที่สุดสำหรับ REST API
คู่มือเชิงลึกสำหรับการสร้าง REST API ด้วย Symfony 7 และ API Platform 4 ครอบคลุม Resource configuration, State Processor, Serialization Groups, Security และการปรับแต่ง performance ด้วยตัวอย่างโค้ดที่ใช้งานได้จริง

API Platform 4 ที่ทำงานบน Symfony 7 ได้เปลี่ยนวิธีที่ทีมพัฒนา PHP สร้าง REST API ไปอย่างสิ้นเชิง แทนที่จะเขียน controller, serializer และ documentation ด้วยมือ API Platform จะสร้างทุกอย่างจาก PHP attribute เพียงไม่กี่บรรทัด บทความนี้วิเคราะห์แนวทางปฏิบัติที่ใช้ได้จริงในการสร้าง API ระดับ production ด้วย Symfony 7.2 และ API Platform 4.1
การสัมภาษณ์งาน Symfony ที่เน้น API Platform มักทดสอบความเข้าใจเรื่อง State Processor แทน Event Subscriber ที่ใช้ใน API Platform 2.x, การจัดการ Serialization Groups อย่างถูกต้อง และการออกแบบ security ด้วย Voters ผู้สมัครที่เข้าใจสถาปัตยกรรม Resource-centric ของ API Platform จะโดดเด่นกว่าผู้ที่ใช้เพียง annotation พื้นฐาน
การตั้งค่า ApiResource: จุดเริ่มต้นที่ถูกต้อง
การกำหนด #[ApiResource] attribute คือหัวใจของ API Platform ปัญหาที่พบบ่อยคือการตั้งค่า operation ทั้งหมดโดยไม่จำกัดขอบเขต ซึ่งทำให้ API มี endpoint ที่ไม่จำเป็นและเพิ่มพื้นที่ attack surface
<?php
// src/Entity/Product.php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Patch;
use App\State\ProductStateProcessor;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
#[ApiResource(
operations: [
new GetCollection(
normalizationContext: ['groups' => ['product:list']]
),
new Get(
normalizationContext: ['groups' => ['product:read']]
),
new Post(
denormalizationContext: ['groups' => ['product:write']],
processor: ProductStateProcessor::class
),
new Patch(
denormalizationContext: ['groups' => ['product:update']],
security: "is_granted('PRODUCT_EDIT', object)"
),
new Delete(
security: "is_granted('PRODUCT_DELETE', object)"
),
]
)]
class Product
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['product:list', 'product:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 255)]
#[Groups(['product:list', 'product:read', 'product:write', 'product:update'])]
private string $name = '';
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
#[Assert\Positive]
#[Groups(['product:list', 'product:read', 'product:write', 'product:update'])]
private string $price = '0.00';
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['product:read', 'product:write'])]
private ?string $description = null;
// Getters and setters...
}การแยก normalization context ระหว่าง product:list และ product:read เป็นแนวทางสำคัญ collection endpoint ควรส่งข้อมูลที่น้อยกว่า single item endpoint เสมอเพื่อลด payload และ query complexity
Serialization Groups: การควบคุม Payload อย่างละเอียด
การออกแบบ Serialization Groups ที่ไม่ดีเป็นต้นตอของปัญหา performance ใน API Platform หลายโปรเจค ปัญหาที่พบบ่อยคือการ expose ข้อมูลที่ไม่จำเป็นหรือทำให้เกิด circular reference
<?php
// src/Entity/Order.php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\MaxDepth;
#[ORM\Entity]
#[ApiResource(
operations: [
new GetCollection(
normalizationContext: [
'groups' => ['order:list'],
'enable_max_depth' => true
]
),
new Get(
normalizationContext: [
'groups' => ['order:read'],
'enable_max_depth' => true
]
),
]
)]
class Order
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['order:list', 'order:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Customer::class)]
#[Groups(['order:list', 'order:read'])]
#[MaxDepth(1)]
private ?Customer $customer = null;
#[ORM\OneToMany(targetEntity: OrderItem::class, mappedBy: 'order', cascade: ['persist'])]
#[Groups(['order:read'])]
#[MaxDepth(1)]
private Collection $items;
#[ORM\Column(type: 'decimal', precision: 12, scale: 2)]
#[Groups(['order:list', 'order:read'])]
private string $totalAmount = '0.00';
public function __construct()
{
$this->items = new ArrayCollection();
}
// Getters and setters...
}#[MaxDepth(1)] ป้องกัน circular reference ที่เกิดจากความสัมพันธ์แบบ bidirectional การใช้ enable_max_depth ใน context ร่วมกับ #[MaxDepth] attribute เป็นวิธีที่ถูกต้องใน API Platform 4
API Platform 4 ได้ยกเลิก DataPersister และ DataProvider ที่ใช้ใน 2.x แทนที่ด้วย StateProcessor และ StateProvider สถาปัตยกรรมใหม่นี้แยก concern ชัดเจนกว่าและรองรับ async processing ได้ดีกว่า ผู้สมัครที่ยังอ้างถึง DataPersister ในการสัมภาษณ์แสดงให้เห็นว่าไม่ได้ติดตาม ecosystem ปัจจุบัน
State Processor: การแทนที่ DataPersister
State Processor คือจุดที่ business logic ทั้งหมดควรอยู่ใน API Platform 4 การใส่ logic ใน Entity หรือ Controller ถือเป็น anti-pattern ที่ผู้สัมภาษณ์ระดับ senior จะตรวจจับได้ทันที
<?php
// src/State/ProductStateProcessor.php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Product;
use App\Service\InventoryService;
use App\Service\SlugGeneratorService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
class ProductStateProcessor implements ProcessorInterface
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly InventoryService $inventoryService,
private readonly SlugGeneratorService $slugGenerator,
private readonly LoggerInterface $logger
) {}
public function process(
mixed $data,
Operation $operation,
array $uriVariables = [],
array $context = []
): Product {
/** @var Product $product */
$product = $data;
// Generate slug from name
if ($product->getSlug() === null) {
$product->setSlug(
$this->slugGenerator->generate($product->getName())
);
}
// Initialize inventory record
if ($product->getId() === null) {
$this->inventoryService->initializeForProduct($product);
}
$this->entityManager->persist($product);
$this->entityManager->flush();
$this->logger->info('Product processed', [
'id' => $product->getId(),
'name' => $product->getName(),
]);
return $product;
}
}การ inject EntityManagerInterface โดยตรงใน State Processor เป็นวิธีที่ถูกต้อง API Platform 4 ได้ออกแบบให้ State Processor รับผิดชอบการ persist ข้อมูลเองแทนที่จะให้ framework จัดการโดยอัตโนมัติเหมือนใน 2.x
State Provider: การ Customize Data Fetching
เมื่อต้องการ data ที่ไม่ได้มาจาก Doctrine โดยตรง หรือต้องการ transform data ก่อนส่ง State Provider คือคำตอบที่ถูกต้อง
<?php
// src/State/ProductCollectionProvider.php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Product;
use App\Repository\ProductRepository;
use Symfony\Bundle\SecurityBundle\Security;
class ProductCollectionProvider implements ProviderInterface
{
public function __construct(
private readonly ProductRepository $productRepository,
private readonly Security $security
) {}
public function provide(
Operation $operation,
array $uriVariables = [],
array $context = []
): iterable {
$user = $this->security->getUser();
// Different query logic based on user role
if ($this->security->isGranted('ROLE_ADMIN')) {
return $this->productRepository->findAll();
}
if ($this->security->isGranted('ROLE_VENDOR')) {
return $this->productRepository->findByVendor($user);
}
// Public users see only published products
return $this->productRepository->findPublished();
}
}ตัวอย่างนี้แสดงให้เห็น separation of concerns ที่ชัดเจน: logic สำหรับการกำหนดว่าจะ fetch ข้อมูลอะไรอยู่ใน Provider ส่วน logic สำหรับการบันทึกข้อมูลอยู่ใน Processor
Security ด้วย Voters: ระดับ Object-Level
การใช้ security attribute ใน operation เพียงตรวจสอบ role ทั่วไปนั้นไม่เพียงพอสำหรับ API ระดับ production ควรใช้ Voter เพื่อตรวจสอบสิทธิ์ระดับ object
<?php
// src/Security/Voter/ProductVoter.php
namespace App\Security\Voter;
use App\Entity\Product;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ProductVoter extends Voter
{
public const EDIT = 'PRODUCT_EDIT';
public const DELETE = 'PRODUCT_DELETE';
public const VIEW = 'PRODUCT_VIEW';
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::EDIT, self::DELETE, self::VIEW])
&& $subject instanceof Product;
}
protected function voteOnAttribute(
string $attribute,
mixed $subject,
TokenInterface $token
): bool {
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
/** @var Product $product */
$product = $subject;
return match ($attribute) {
self::VIEW => $this->canView($product, $user),
self::EDIT => $this->canEdit($product, $user),
self::DELETE => $this->canDelete($product, $user),
default => false,
};
}
private function canView(Product $product, User $user): bool
{
// Published products are visible to all authenticated users
if ($product->isPublished()) {
return true;
}
// Unpublished products only visible to owner or admin
return $product->getOwner() === $user
|| in_array('ROLE_ADMIN', $user->getRoles());
}
private function canEdit(Product $product, User $user): bool
{
return $product->getOwner() === $user
|| in_array('ROLE_ADMIN', $user->getRoles());
}
private function canDelete(Product $product, User $user): bool
{
return in_array('ROLE_ADMIN', $user->getRoles());
}
}การใช้ security="is_granted('ROLE_USER')" เพียงอย่างเดียวใน operation ไม่ได้ป้องกัน user คนหนึ่งจากการแก้ไข resource ของ user อีกคน ต้องใช้ Voter ที่ตรวจสอบ ownership ระดับ object เสมอ นี่คือช่องโหว่ที่พบบ่อยใน API ที่สร้างด้วย API Platform โดยผู้ที่ไม่มีประสบการณ์เพียงพอ
JWT Authentication: การตั้งค่ากับ LexikJWTAuthenticationBundle
การรวม JWT กับ API Platform ต้องการการตั้งค่าที่ถูกต้องทั้งใน security.yaml และ api_platform.yaml เพื่อให้ OpenAPI documentation แสดง authentication scheme ได้ถูกต้อง
# config/packages/api_platform.yaml
api_platform:
title: 'Product API'
version: '1.0.0'
formats:
jsonld: ['application/ld+json']
json: ['application/json']
html: ['text/html']
swagger:
api_keys:
JWT:
name: Authorization
type: header
defaults:
stateless: true
cache_headers:
vary: ['Content-Type', 'Authorization', 'Origin']
extra_properties:
standard_put: true
rfc_7807_compliant_errors: true# config/packages/security.yaml
security:
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
login:
pattern: ^/api/auth/login
stateless: true
json_login:
check_path: /api/auth/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/auth/login, roles: PUBLIC_ACCESS }
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }Custom Filter: การค้นหาที่ยืดหยุ่น
API Platform มี filter ในตัวหลายประเภท แต่สำหรับ business logic ที่ซับซ้อน จำเป็นต้องสร้าง custom filter ตัวอย่างนี้แสดงการสร้าง filter สำหรับค้นหาสินค้าตามช่วงราคา
<?php
// src/Filter/PriceRangeFilter.php
namespace App\Filter;
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
final class PriceRangeFilter extends AbstractFilter
{
protected function filterProperty(
string $property,
mixed $value,
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
Operation $operation = null,
array $context = []
): void {
if ($property !== 'price_range') {
return;
}
if (!isset($value['min']) && !isset($value['max'])) {
return;
}
$alias = $queryBuilder->getRootAliases()[0];
if (isset($value['min'])) {
$minParam = $queryNameGenerator->generateParameterName('price_min');
$queryBuilder
->andWhere(sprintf('%s.price >= :%s', $alias, $minParam))
->setParameter($minParam, $value['min']);
}
if (isset($value['max'])) {
$maxParam = $queryNameGenerator->generateParameterName('price_max');
$queryBuilder
->andWhere(sprintf('%s.price <= :%s', $alias, $maxParam))
->setParameter($maxParam, $value['max']);
}
}
public function getDescription(string $resourceClass): array
{
return [
'price_range[min]' => [
'property' => 'price',
'type' => 'float',
'required' => false,
'description' => 'Minimum price filter',
],
'price_range[max]' => [
'property' => 'price',
'type' => 'float',
'required' => false,
'description' => 'Maximum price filter',
],
];
}
}การนำ filter ไปใช้กับ Entity ทำได้ด้วย attribute:
<?php
// src/Entity/Product.php — เพิ่ม filter attribute
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use App\Filter\PriceRangeFilter;
#[ApiResource(/* ... */)]
#[ApiFilter(SearchFilter::class, properties: [
'name' => 'partial',
'category.name' => 'exact'
])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'price', 'createdAt'])]
#[ApiFilter(PriceRangeFilter::class)]
class Product
{
// ...
}หลังจาก register filter แล้ว API จะรองรับ query string ในรูปแบบ: /api/products?price_range[min]=10&price_range[max]=100&order[price]=asc API Platform จะสร้าง OpenAPI documentation สำหรับ filter เหล่านี้โดยอัตโนมัติผ่าน getDescription() method
Pagination และ Performance Optimization
การ configure pagination อย่างถูกต้องใน API Platform มีผลอย่างมากต่อ performance โดยเฉพาะเมื่อ collection มีข้อมูลจำนวนมาก
<?php
// src/Entity/Product.php — Pagination configuration
#[ApiResource(
operations: [
new GetCollection(
normalizationContext: ['groups' => ['product:list']],
paginationEnabled: true,
paginationItemsPerPage: 20,
paginationMaximumItemsPerPage: 100,
paginationClientItemsPerPage: true,
),
]
)]
class Product
{
// ...
}สำหรับ collection ขนาดใหญ่ ควรใช้ cursor-based pagination แทน page-based pagination:
<?php
// src/Repository/ProductRepository.php
namespace App\Repository;
use App\Entity\Product;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ProductRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Product::class);
}
public function findPublished(): array
{
return $this->createQueryBuilder('p')
->where('p.published = :published')
->setParameter('published', true)
->orderBy('p.createdAt', 'DESC')
->getQuery()
->getResult();
}
public function findByVendor(mixed $vendor): array
{
return $this->createQueryBuilder('p')
->where('p.owner = :vendor')
->setParameter('vendor', $vendor)
->orderBy('p.createdAt', 'DESC')
->getQuery()
->getResult();
}
/**
* Cursor-based pagination for large datasets.
* @return Product[]
*/
public function findAfterCursor(int $cursor, int $limit = 20): array
{
return $this->createQueryBuilder('p')
->where('p.id > :cursor')
->setParameter('cursor', $cursor)
->orderBy('p.id', 'ASC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
}HTTP Caching: การเพิ่ม Performance ด้วย Cache Headers
API Platform 4 มีระบบ HTTP caching ที่ทรงพลังผ่าน cache headers และรองรับ Varnish หรือ Symfony's built-in HTTP cache
<?php
// src/Entity/Product.php — Cache configuration
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
#[ApiResource(
operations: [
new GetCollection(
cacheHeaders: [
'max_age' => 60,
'shared_max_age' => 120,
'vary' => ['Authorization', 'Accept-Language'],
]
),
new Get(
cacheHeaders: [
'max_age' => 300,
'shared_max_age' => 600,
]
),
]
)]
class Product
{
// ...
}สำหรับ resource ที่ต้องการ cache invalidation อัตโนมัติ API Platform รองรับ PurgeHttpCacheListener ที่ทำงานร่วมกับ Varnish:
<?php
// src/Entity/Product.php — เพิ่ม cache invalidation tag
use ApiPlatform\Metadata\ApiResource;
use Symfony\Component\HttpFoundation\Response;
#[ApiResource(
extraProperties: [
'cache_headers' => [
'vary' => ['Content-Type', 'Authorization'],
]
]
)]
class Product
{
// ...
}API Platform 4 มี built-in support สำหรับ Mercure protocol ซึ่งเปิดใช้งาน real-time update ผ่าน Server-Sent Events การเพิ่ม #[ApiResource(mercure: true)] จะทำให้ API ส่ง event ไปยัง Mercure hub โดยอัตโนมัติเมื่อ resource ถูกสร้าง แก้ไข หรือลบ เหมาะสำหรับแอปพลิเคชันที่ต้องการ live update
Validation: การใช้ Symfony Constraints กับ Groups
การแยก validation group ตาม operation ทำให้ควบคุม validation logic ได้ละเอียดกว่าการใช้ constraint เดียวกันทุก operation
<?php
// src/Entity/User.php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Patch;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
#[ApiResource(
operations: [
new Post(
denormalizationContext: ['groups' => ['user:create']],
validationContext: ['groups' => ['Default', 'user:create']]
),
new Patch(
denormalizationContext: ['groups' => ['user:update']],
validationContext: ['groups' => ['Default', 'user:update']],
security: "is_granted('USER_EDIT', object)"
),
]
)]
class User
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
#[Assert\NotBlank]
#[Assert\Email]
#[Groups(['user:create', 'user:read'])]
private string $email = '';
#[ORM\Column]
#[Assert\NotBlank(groups: ['user:create'])]
#[Assert\Length(min: 8, groups: ['user:create', 'user:update'])]
#[Groups(['user:create', 'user:update'])]
private string $plainPassword = '';
#[ORM\Column(length: 100)]
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 100)]
#[Groups(['user:create', 'user:update', 'user:read'])]
private string $displayName = '';
// Getters and setters...
}Error Handling: RFC 7807 Problem Details
API Platform 4 รองรับ RFC 7807 (Problem Details for HTTP APIs) โดยค่าเริ่มต้น การ configure rfc_7807_compliant_errors: true ทำให้ error response มีรูปแบบมาตรฐานที่ client สามารถ parse ได้อย่างสม่ำเสมอ
<?php
// src/Exception/InsufficientStockException.php
namespace App\Exception;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
class InsufficientStockException extends UnprocessableEntityHttpException
{
public function __construct(
private readonly string $productName,
private readonly int $requested,
private readonly int $available
) {
parent::__construct(
sprintf(
'Insufficient stock for product "%s": requested %d, available %d',
$productName,
$requested,
$available
)
);
}
public function getProductName(): string
{
return $this->productName;
}
}<?php
// src/State/OrderStateProcessor.php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Order;
use App\Exception\InsufficientStockException;
use App\Service\StockService;
use Doctrine\ORM\EntityManagerInterface;
class OrderStateProcessor implements ProcessorInterface
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly StockService $stockService
) {}
public function process(
mixed $data,
Operation $operation,
array $uriVariables = [],
array $context = []
): Order {
/** @var Order $order */
$order = $data;
foreach ($order->getItems() as $item) {
$available = $this->stockService->getAvailableStock($item->getProduct());
if ($item->getQuantity() > $available) {
throw new InsufficientStockException(
$item->getProduct()->getName(),
$item->getQuantity(),
$available
);
}
}
$this->entityManager->persist($order);
$this->entityManager->flush();
return $order;
}
}เมื่อ InsufficientStockException ถูก throw API Platform จะแปลงเป็น response ในรูปแบบ RFC 7807 โดยอัตโนมัติ:
{
"@context": "/api/contexts/Error",
"@type": "hydra:Error",
"hydra:title": "An error occurred",
"hydra:description": "Insufficient stock for product \"Widget Pro\": requested 10, available 3",
"status": 422,
"title": "Unprocessable Content",
"detail": "Insufficient stock for product \"Widget Pro\": requested 10, available 3"
}Testing: Functional Tests กับ ApiTestCase
การทดสอบ API Platform ที่มีประสิทธิภาพใช้ ApiTestCase ซึ่ง extend จาก WebTestCase ของ Symfony พร้อมกับ assertion methods ที่เฉพาะเจาะจงสำหรับ API
<?php
// tests/Functional/ProductApiTest.php
namespace App\Tests\Functional;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Entity\Product;
use App\Factory\ProductFactory;
use App\Factory\UserFactory;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
class ProductApiTest extends ApiTestCase
{
use ResetDatabase;
use Factories;
public function testGetProductCollection(): void
{
ProductFactory::createMany(5, ['published' => true]);
ProductFactory::createMany(3, ['published' => false]);
$response = static::createClient()->request('GET', '/api/products');
$this->assertResponseIsSuccessful();
$this->assertResponseHeaderSame(
'content-type',
'application/ld+json; charset=utf-8'
);
$this->assertJsonContains([
'@context' => '/api/contexts/Product',
'@type' => 'hydra:Collection',
'hydra:totalItems' => 5,
]);
}
public function testCreateProductRequiresAuthentication(): void
{
static::createClient()->request('POST', '/api/products', [
'json' => [
'name' => 'New Product',
'price' => '29.99',
],
]);
$this->assertResponseStatusCodeSame(401);
}
public function testCreateProductAsAuthenticatedUser(): void
{
$user = UserFactory::createOne(['roles' => ['ROLE_VENDOR']]);
$token = $this->generateJwtToken($user->object());
$response = static::createClient()->request('POST', '/api/products', [
'auth_bearer' => $token,
'json' => [
'name' => 'Premium Widget',
'price' => '49.99',
'description' => 'A high-quality widget',
],
]);
$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains([
'name' => 'Premium Widget',
'price' => '49.99',
]);
}
private function generateJwtToken(mixed $user): string
{
$jwtManager = static::getContainer()->get('lexik_jwt_authentication.jwt_manager');
return $jwtManager->create($user);
}
}การใช้ zenstruck/foundry ร่วมกับ ResetDatabase trait ทำให้แต่ละ test เริ่มต้นด้วยฐานข้อมูลที่สะอาดเสมอ Factory pattern ของ Foundry ทำให้สร้าง test data ได้ง่ายและชัดเจน ซึ่งดีกว่าการใช้ fixtures แบบ static ที่ต้องดูแลรักษาแยกต่างหาก
สรุป: แนวทางปฏิบัติที่ดีที่สุด
การพัฒนา API ด้วย Symfony 7 และ API Platform 4 ที่มีคุณภาพสูงต้องยึดหลักสำคัญดังนี้: ใช้ State Processor แทน DataPersister, ออกแบบ Serialization Groups แยกตามความต้องการของแต่ละ operation, ใช้ Voter สำหรับ security ระดับ object, กำหนด validation groups ให้ตรงกับ operation และเขียน functional test ด้วย ApiTestCase เสมอ
สถาปัตยกรรม Resource-centric ของ API Platform บังคับให้ developer คิดถึง API design ก่อนที่จะเขียน implementation ซึ่งส่งผลให้ได้ API ที่สอดคล้องกับมาตรฐาน REST และ Hydra/JSON-LD ตั้งแต่ต้น การทำความเข้าใจ architectural decision เหล่านี้ไม่ใช่แค่การสอบผ่านการสัมภาษณ์ แต่คือการสร้าง API ที่ maintainable ในระยะยาว
แท็ก
แชร์
