Symfony 7: API Platform과 베스트 프랙티스

Symfony 7과 API Platform 4로 전문적인 REST API를 구축하는 완벽 가이드입니다. State Provider, Processor, 유효성 검사, 직렬화를 실전 예제와 함께 설명합니다.

Symfony 7 API Platform 베스트 프랙티스 완벽 가이드

API Platform 4는 Symfony 7과 함께 REST 및 GraphQL API 개발 방식을 근본적으로 혁신합니다. 새로운 버전은 명확한 관심사 분리, 단순화된 State Provider 및 Processor, Symfony Object Mapper의 네이티브 통합이라는 재정립된 철학을 제시합니다. 전문적인 API 구축이 이보다 직관적이었던 적은 없었습니다.

API Platform 4.2의 새로운 기능

버전 4.2에서는 최대 +32% RPS 성능 향상을 제공하는 JSON Streamer, 재설계된 필터 시스템, 코어를 건드리지 않고 오퍼레이션을 커스터마이즈할 수 있는 Mutator가 도입되었습니다. Symfony 7 및 8 지원이 네이티브로 제공됩니다.

설치 및 초기 설정

API Platform은 Symfony Flex를 통해 몇 가지 명령어로 설치할 수 있습니다. 기본 설정은 대부분의 사용 사례를 커버하면서도 완전한 커스터마이즈가 가능합니다.

bash
# terminal
# Create a new Symfony project with API Platform
composer create-project symfony/skeleton my-api
cd my-api

# Install API Platform with Doctrine ORM
composer require api

# Verify installation
php bin/console debug:router | grep api

Symfony Flex는 라우트, OpenAPI 문서, /api에서 접근 가능한 Swagger UI 인터페이스를 자동으로 설정합니다.

yaml
# config/packages/api_platform.yaml
api_platform:
    title: 'My API'
    version: '1.0.0'
    # Supported response formats
    formats:
        jsonld: ['application/ld+json']
        json: ['application/json']
    # OpenAPI documentation
    swagger:
        versions: [3]
    # Default pagination
    defaults:
        pagination_items_per_page: 30
        pagination_maximum_items_per_page: 100

이 설정은 직렬화 형식, 전역 페이지네이션, API 문서 메타데이터를 정의합니다.

간단한 API 리소스 생성

#[ApiResource] 어트리뷰트는 Doctrine 엔티티를 REST 리소스로 노출시킵니다. API Platform은 CRUD 엔드포인트, OpenAPI 문서, 기본 유효성 검사를 자동으로 생성합니다.

php
<?php
// src/Entity/Book.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\Patch;
use ApiPlatform\Metadata\Delete;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity]
#[ApiResource(
    // Explicit definition of available operations
    operations: [
        new GetCollection(),
        new Post(),
        new Get(),
        new Put(),
        new Patch(),
        new Delete(),
    ],
    // Default ordering for collections
    order: ['publishedAt' => 'DESC'],
    // Pagination configuration for this resource
    paginationItemsPerPage: 20
)]
class Book
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Assert\NotBlank(message: 'Title is required')]
    #[Assert\Length(min: 2, max: 255)]
    private ?string $title = null;

    #[ORM\Column(type: 'text')]
    #[Assert\NotBlank]
    private ?string $description = null;

    #[ORM\Column(length: 13, unique: true)]
    #[Assert\Isbn]
    private ?string $isbn = null;

    #[ORM\Column]
    private ?\DateTimeImmutable $publishedAt = null;

    // Getters and setters...
    public function getId(): ?int
    {
        return $this->id;
    }

    public function getTitle(): ?string
    {
        return $this->title;
    }

    public function setTitle(string $title): static
    {
        $this->title = $title;
        return $this;
    }

    public function getDescription(): ?string
    {
        return $this->description;
    }

    public function setDescription(string $description): static
    {
        $this->description = $description;
        return $this;
    }

    public function getIsbn(): ?string
    {
        return $this->isbn;
    }

    public function setIsbn(string $isbn): static
    {
        $this->isbn = $isbn;
        return $this;
    }

    public function getPublishedAt(): ?\DateTimeImmutable
    {
        return $this->publishedAt;
    }

    public function setPublishedAt(\DateTimeImmutable $publishedAt): static
    {
        $this->publishedAt = $publishedAt;
        return $this;
    }
}

이 엔티티는 GET /api/books, POST /api/books, GET /api/books/{id}, PUT /api/books/{id}, PATCH /api/books/{id}, DELETE /api/books/{id} 여섯 개의 엔드포인트를 생성합니다.

UUID v7 권장

API Platform은 식별자로 UUID v7을 네이티브 지원합니다. 이 방식은 보안(예측 불가능한 식별자)과 성능(생성 날짜 기준 자연 정렬)을 모두 향상시킵니다.

노출 데이터 제어를 위한 직렬화 그룹

직렬화 그룹은 읽기(정규화)와 쓰기(역정규화) 시 어떤 프로퍼티를 노출할지 정확하게 정의합니다. 이 분리는 API 보안과 유연성을 위해 필수적입니다.

php
<?php
// src/Entity/User.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\Patch;
use ApiPlatform\Metadata\Delete;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use App\State\UserPasswordHasher;

#[ORM\Entity]
#[ORM\Table(name: '`user`')]
#[UniqueEntity('email')]
#[ApiResource(
    operations: [
        new GetCollection(),
        // Custom processor for password hashing
        new Post(processor: UserPasswordHasher::class,
                 validationContext: ['groups' => ['Default', 'user:create']]),
        new Get(),
        new Put(processor: UserPasswordHasher::class),
        new Patch(processor: UserPasswordHasher::class),
        new Delete(),
    ],
    // Properties exposed when reading
    normalizationContext: ['groups' => ['user:read']],
    // Properties accepted when writing
    denormalizationContext: ['groups' => ['user:create', 'user:update']],
)]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['user:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 180, unique: true)]
    #[Assert\NotBlank]
    #[Assert\Email]
    #[Groups(['user:read', 'user:create', 'user:update'])]
    private ?string $email = null;

    #[ORM\Column]
    private ?string $password = null;

    // Never exposed when reading, only when writing
    #[Assert\NotBlank(groups: ['user:create'])]
    #[Groups(['user:create', 'user:update'])]
    private ?string $plainPassword = null;

    #[ORM\Column(length: 100)]
    #[Groups(['user:read', 'user:create', 'user:update'])]
    private ?string $fullName = null;

    #[ORM\Column(type: 'json')]
    #[Groups(['user:read'])]
    private array $roles = [];

    #[ORM\Column]
    #[Groups(['user:read'])]
    private ?\DateTimeImmutable $createdAt = null;

    public function __construct()
    {
        $this->createdAt = new \DateTimeImmutable();
    }

    // UserInterface implementation
    public function getUserIdentifier(): string
    {
        return (string) $this->email;
    }

    public function getRoles(): array
    {
        $roles = $this->roles;
        $roles[] = 'ROLE_USER';
        return array_unique($roles);
    }

    public function getPassword(): string
    {
        return $this->password;
    }

    public function eraseCredentials(): void
    {
        $this->plainPassword = null;
    }

    // Getters and setters...
    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;
    }

    public function setPassword(string $password): static
    {
        $this->password = $password;
        return $this;
    }

    public function getPlainPassword(): ?string
    {
        return $this->plainPassword;
    }

    public function setPlainPassword(?string $plainPassword): static
    {
        $this->plainPassword = $plainPassword;
        return $this;
    }

    public function getFullName(): ?string
    {
        return $this->fullName;
    }

    public function setFullName(string $fullName): static
    {
        $this->fullName = $fullName;
        return $this;
    }

    public function setRoles(array $roles): static
    {
        $this->roles = $roles;
        return $this;
    }

    public function getCreatedAt(): ?\DateTimeImmutable
    {
        return $this->createdAt;
    }
}

이 설정을 통해 plainPassword는 응답에 절대 노출되지 않지만, 생성 또는 업데이트 시 전송할 수 있습니다.

Symfony 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

비즈니스 로직을 위한 State Processor

State Processor는 퍼시스턴스 오퍼레이션을 가로채 비즈니스 로직을 추가합니다. API Platform 4는 어트리뷰트 기반 의존성 주입을 통해 Processor 생성을 단순화합니다.

php
<?php
// src/State/UserPasswordHasher.php

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\User;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

/**
 * Processor that hashes password before persistence
 * @implements ProcessorInterface<User, User>
 */
final class UserPasswordHasher implements ProcessorInterface
{
    public function __construct(
        // Injection of standard Doctrine processor
        #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
        private ProcessorInterface $persistProcessor,
        private UserPasswordHasherInterface $passwordHasher,
    ) {
    }

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): User
    {
        // Hash password if provided
        if ($data->getPlainPassword()) {
            $hashedPassword = $this->passwordHasher->hashPassword(
                $data,
                $data->getPlainPassword()
            );
            $data->setPassword($hashedPassword);
            $data->eraseCredentials();
        }

        // Delegate persistence to standard processor
        return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
    }
}

이 컴포지션 패턴은 표준 퍼시스턴스 동작을 유지하면서 이메일 전송, 이벤트 발행, 로깅 등 모든 로직을 추가할 수 있게 해줍니다.

조건부 로직이 있는 Processor

Processor는 오퍼레이션 유형에 따라 동작을 조정할 수 있습니다.

php
<?php
// src/State/BookProcessor.php

namespace App\State;

use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Book;
use App\Service\NotificationService;
use App\Service\SearchIndexer;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

/**
 * @implements ProcessorInterface<Book, Book|void>
 */
final class BookProcessor implements ProcessorInterface
{
    public function __construct(
        #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
        private ProcessorInterface $persistProcessor,
        #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
        private ProcessorInterface $removeProcessor,
        private NotificationService $notifications,
        private SearchIndexer $searchIndexer,
    ) {
    }

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
    {
        // Deletion: use remove processor
        if ($operation instanceof DeleteOperationInterface) {
            $this->searchIndexer->remove($data);
            return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
        }

        // Creation: set publication date
        if ($operation instanceof Post) {
            $data->setPublishedAt(new \DateTimeImmutable());
        }

        // Standard persistence
        $result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);

        // Post-processing: indexing and notification
        $this->searchIndexer->index($result);

        if ($operation instanceof Post) {
            $this->notifications->notifyNewBook($result);
        }

        return $result;
    }
}

커스텀 데이터 소스를 위한 State Provider

State Provider는 외부 API, 캐시, 파일, 복잡한 비즈니스 로직 등 어떤 소스에서든 데이터를 가져올 수 있습니다.

php
<?php
// src/State/PopularBooksProvider.php

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Book;
use App\Repository\BookRepository;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

/**
 * Provider that returns popular books with caching
 * @implements ProviderInterface<Book>
 */
final class PopularBooksProvider implements ProviderInterface
{
    public function __construct(
        private BookRepository $bookRepository,
        private CacheInterface $cache,
    ) {
    }

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
    {
        // 5-minute cache for popular books
        return $this->cache->get('popular_books', function (ItemInterface $item) {
            $item->expiresAfter(300);

            return $this->bookRepository->findPopular(limit: 10);
        });
    }
}

이 Provider는 전용 오퍼레이션에서 사용됩니다.

php
<?php
// src/Entity/Book.php (excerpt)

use App\State\PopularBooksProvider;

#[ApiResource(
    operations: [
        // ... other operations
        new GetCollection(
            uriTemplate: '/books/popular',
            provider: PopularBooksProvider::class,
            paginationEnabled: false,
        ),
    ],
)]
class Book
{
    // ...
}

동적 그룹을 활용한 고급 유효성 검사

API Platform은 Symfony의 Validator 컴포넌트를 네이티브로 통합합니다. 유효성 검사 그룹은 오퍼레이션이나 컨텍스트에 따라 달라질 수 있습니다.

php
<?php
// src/Entity/Article.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 Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity]
#[ApiResource(
    operations: [
        new GetCollection(),
        new Get(),
        // Strict validation on creation
        new Post(validationContext: ['groups' => ['Default', 'article:create']]),
        // More lenient validation for updates
        new Put(validationContext: ['groups' => ['Default', 'article:update']]),
    ],
)]
class Article
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Assert\NotBlank]
    #[Assert\Length(min: 10, max: 255)]
    private ?string $title = null;

    #[ORM\Column(type: 'text')]
    #[Assert\NotBlank]
    // Minimum 500 characters on creation
    #[Assert\Length(min: 500, groups: ['article:create'])]
    // Minimum 100 characters for updates
    #[Assert\Length(min: 100, groups: ['article:update'])]
    private ?string $content = null;

    #[ORM\Column(length: 50)]
    #[Assert\NotBlank(groups: ['article:create'])]
    #[Assert\Choice(choices: ['draft', 'published', 'archived'])]
    private ?string $status = 'draft';

    #[ORM\Column(nullable: true)]
    // Required only if status is "published"
    #[Assert\NotBlank(groups: ['article:publish'])]
    private ?\DateTimeImmutable $publishedAt = null;

    // Getters and setters...
}

서비스를 이용한 동적 유효성 검사

복잡한 유효성 검사 규칙의 경우, 커스텀 그룹 생성기가 완전한 유연성을 제공합니다.

php
<?php
// src/Validator/ArticleGroupsGenerator.php

namespace App\Validator;

use ApiPlatform\Symfony\Validator\ValidationGroupsGeneratorInterface;
use App\Entity\Article;
use Symfony\Bundle\SecurityBundle\Security;

final class ArticleGroupsGenerator implements ValidationGroupsGeneratorInterface
{
    public function __construct(
        private Security $security,
    ) {
    }

    public function __invoke(object $object): array
    {
        assert($object instanceof Article);

        $groups = ['Default'];

        // Admins have fewer restrictions
        if ($this->security->isGranted('ROLE_ADMIN')) {
            $groups[] = 'admin';
            return $groups;
        }

        // Additional validation if publishing
        if ($object->getStatus() === 'published') {
            $groups[] = 'article:publish';
        }

        return $groups;
    }
}
유효성 검사 성능

복잡한 유효성 검사는 성능에 영향을 줄 수 있습니다. 대량 가져오기의 경우 특정 유효성 검사를 일시적으로 비활성화하거나 비동기 제약 조건을 사용하는 것을 고려하십시오.

유연한 쿼리를 위한 필터

API Platform 4.2는 명확한 관심사 분리를 통해 필터 시스템을 완전히 재설계했습니다. 필터는 API 클라이언트가 데이터를 검색하고 정렬할 수 있도록 합니다.

php
<?php
// src/Entity/Product.php

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ApiResource]
// Text search on name and description
#[ApiFilter(SearchFilter::class, properties: [
    'name' => 'partial',       // LIKE %value%
    'description' => 'partial',
    'category.name' => 'exact', // Search on relation
    'sku' => 'exact',          // Exact match
])]
// Range filtering
#[ApiFilter(RangeFilter::class, properties: ['price', 'stock'])]
// Date filtering
#[ApiFilter(DateFilter::class, properties: ['createdAt', 'updatedAt'])]
// Boolean filtering
#[ApiFilter(BooleanFilter::class, properties: ['isActive', 'isFeatured'])]
// Customizable sorting
#[ApiFilter(OrderFilter::class, properties: [
    'name',
    'price',
    'createdAt',
], arguments: ['orderParameterName' => 'sort'])]
class Product
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $name = null;

    #[ORM\Column(type: 'text', nullable: true)]
    private ?string $description = null;

    #[ORM\Column(length: 50, unique: true)]
    private ?string $sku = null;

    #[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
    private ?string $price = null;

    #[ORM\Column]
    private ?int $stock = null;

    #[ORM\Column]
    private ?bool $isActive = true;

    #[ORM\Column]
    private ?bool $isFeatured = false;

    #[ORM\ManyToOne(targetEntity: Category::class)]
    private ?Category $category = null;

    #[ORM\Column]
    private ?\DateTimeImmutable $createdAt = null;

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $updatedAt = null;

    // Getters and setters...
}

이 필터들은 OpenAPI 문서를 자동으로 생성하며 다음과 같은 쿼리를 가능하게 합니다.

text
GET /api/products?name=phone&price[gte]=100&price[lte]=500&isActive=true&sort[price]=asc

관계 및 서브리소스

API Platform은 직렬화 옵션과 서브리소스를 통해 엔티티 관계를 우아하게 처리합니다.

php
<?php
// src/Entity/Author.php

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;

#[ORM\Entity]
#[ApiResource(
    normalizationContext: ['groups' => ['author:read']],
)]
class Author
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['author:read', 'book:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Groups(['author:read', 'book:read'])]
    private ?string $name = null;

    #[ORM\Column(type: 'text', nullable: true)]
    #[Groups(['author:read'])]
    private ?string $biography = null;

    #[ORM\OneToMany(mappedBy: 'author', targetEntity: Book::class)]
    #[Groups(['author:read'])]
    private Collection $books;

    public function __construct()
    {
        $this->books = new ArrayCollection();
    }

    // Getters and setters...
}
php
<?php
// src/Entity/Book.php (with relation)

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;

#[ORM\Entity]
#[ApiResource(
    normalizationContext: ['groups' => ['book:read']],
)]
// Subresource: GET /api/authors/{authorId}/books
#[ApiResource(
    uriTemplate: '/authors/{authorId}/books',
    operations: [new GetCollection()],
    uriVariables: [
        'authorId' => new Link(
            fromProperty: 'books',
            fromClass: Author::class
        ),
    ],
    normalizationContext: ['groups' => ['book:read']],
)]
class Book
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['book:read', 'author:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Groups(['book:read', 'author:read'])]
    private ?string $title = null;

    #[ORM\ManyToOne(targetEntity: Author::class, inversedBy: 'books')]
    #[ORM\JoinColumn(nullable: false)]
    #[Groups(['book:read'])]
    private ?Author $author = null;

    // Getters and setters...
}

보안 및 접근 제어

API Platform은 Symfony의 보안 시스템과 완벽하게 통합됩니다. Voter와 보안 표현식이 리소스 접근을 제어합니다.

php
<?php
// src/Entity/Order.php

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Delete;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ApiResource(
    operations: [
        // List: authenticated users see their orders
        new GetCollection(
            security: "is_granted('ROLE_USER')",
            // Automatic filter by connected user
            provider: UserOrdersProvider::class,
        ),
        // Creation: any authenticated user
        new Post(
            security: "is_granted('ROLE_USER')",
            processor: CreateOrderProcessor::class,
        ),
        // Read: owner or admin
        new Get(
            security: "is_granted('ROLE_ADMIN') or object.getCustomer() == user",
            securityMessage: "Access denied to this order.",
        ),
        // Modification: admin only
        new Patch(
            security: "is_granted('ROLE_ADMIN')",
        ),
        // Deletion: admin only
        new Delete(
            security: "is_granted('ROLE_ADMIN')",
        ),
    ],
)]
class Order
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: User::class)]
    #[ORM\JoinColumn(nullable: false)]
    private ?User $customer = null;

    #[ORM\Column(length: 50)]
    private ?string $status = 'pending';

    #[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
    private ?string $total = null;

    #[ORM\Column]
    private ?\DateTimeImmutable $createdAt = null;

    // Getters...
    public function getCustomer(): ?User
    {
        return $this->customer;
    }
}

object.getCustomer() == user 표현식은 세밀한 검사를 위해 현재 엔티티와 로그인한 사용자 모두에 접근할 수 있게 합니다.

자동화된 API 테스팅

API Platform은 엔드포인트를 쉽게 테스트할 수 있는 PHPUnit 트레이트를 제공합니다.

php
<?php
// tests/Api/BookTest.php

namespace App\Tests\Api;

use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Entity\Book;
use App\Factory\BookFactory;
use App\Factory\UserFactory;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;

class BookTest extends ApiTestCase
{
    use ResetDatabase;
    use Factories;

    public function testGetCollection(): void
    {
        // Create test data
        BookFactory::createMany(30);

        // GET request on collection
        $response = static::createClient()->request('GET', '/api/books');

        // Assertions
        $this->assertResponseIsSuccessful();
        $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
        $this->assertJsonContains([
            '@context' => '/api/contexts/Book',
            '@type' => 'Collection',
            'totalItems' => 30,
        ]);
        // Verify pagination (20 items per page)
        $this->assertCount(20, $response->toArray()['member']);
    }

    public function testCreateBook(): void
    {
        $user = UserFactory::createOne(['roles' => ['ROLE_ADMIN']]);

        static::createClient()->request('POST', '/api/books', [
            'auth_bearer' => $this->getToken($user),
            'json' => [
                'title' => 'Clean Code',
                'description' => 'A Handbook of Agile Software Craftsmanship',
                'isbn' => '9780132350884',
            ],
        ]);

        $this->assertResponseStatusCodeSame(201);
        $this->assertJsonContains([
            '@type' => 'Book',
            'title' => 'Clean Code',
        ]);
    }

    public function testCreateBookValidationFails(): void
    {
        $user = UserFactory::createOne(['roles' => ['ROLE_ADMIN']]);

        static::createClient()->request('POST', '/api/books', [
            'auth_bearer' => $this->getToken($user),
            'json' => [
                'title' => '', // Empty title = error
                'isbn' => 'invalid-isbn',
            ],
        ]);

        $this->assertResponseStatusCodeSame(422);
        $this->assertJsonContains([
            '@type' => 'ConstraintViolationList',
            'violations' => [
                ['propertyPath' => 'title', 'message' => 'Title is required'],
            ],
        ]);
    }

    private function getToken(object $user): string
    {
        // Implementation depends on your authentication system
        return 'test_token';
    }
}

결론

Symfony 7과 함께하는 API Platform 4는 PHP에서 전문적인 REST API를 만드는 최신 기술을 대표합니다. State Provider(읽기)와 State Processor(쓰기)의 명확한 분리, 직렬화 그룹 및 유효성 검사 시스템의 조합은 견고하고 유지보수 가능한 API 구축을 가능하게 합니다.

고품질 API를 위한 체크리스트

  • ✅ 읽기와 쓰기에 별도의 직렬화 그룹을 사용합니다
  • ✅ 비즈니스 로직(비밀번호 해싱, 알림)에 State Processor를 구현합니다
  • ✅ 검색 및 정렬을 위한 필터를 설정합니다
  • ✅ 그룹을 사용하여 오퍼레이션별 유효성 검사를 적용합니다
  • ✅ 보안 표현식으로 엔드포인트를 보호합니다
  • ✅ 각 엔드포인트에 대한 기능 테스트를 작성합니다
  • ✅ OpenAPI 메타데이터를 통해 API를 문서화합니다

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

API Platform 4의 철학은 상속보다 컴포지션을, 관례보다 설정을 지향합니다. 그 결과: 확장 가능하고 테스트 가능하며 REST/JSON-LD 표준을 준수하는 API가 첫날부터 프로덕션 준비 완료 상태로 만들어집니다.

태그

#symfony
#api platform
#php
#rest api
#api development

공유

관련 기사