# API Platform & RESTful API Development ## Installation & Configuration ### Installing API Platform ```bash composer require api # Or manually composer require symfony/serializer-pack composer require symfony/validator composer require symfony/property-access composer require symfony/property-info composer require doctrine/annotations composer require api-platform/core ``` ### Basic Configuration ```yaml # config/packages/api_platform.yaml api_platform: title: 'My API' version: '1.0.0' description: 'API documentation' # Swagger/OpenAPI configuration swagger: versions: [3] api_keys: apiKey: name: Authorization type: header # Collection configuration collection: pagination: enabled: true items_per_page: 30 maximum_items_per_page: 100 client_items_per_page: true client_enabled: true # Format configuration formats: jsonld: ['application/ld+json'] json: ['application/json'] xml: ['application/xml', 'text/xml'] csv: ['text/csv'] ``` ## Creating API Resources ### Basic Resource ```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; #[ORM\Entity] #[ApiResource( description: 'Product resource', operations: [ new GetCollection( uriTemplate: '/products', normalizationContext: ['groups' => ['product:list']] ), new Get( uriTemplate: '/products/{id}', normalizationContext: ['groups' => ['product:read']] ), new Post( uriTemplate: '/products', denormalizationContext: ['groups' => ['product:write']], security: "is_granted('ROLE_ADMIN')" ), new Put( uriTemplate: '/products/{id}', denormalizationContext: ['groups' => ['product:write']], security: "is_granted('ROLE_ADMIN') or object.owner == user" ), new Patch( uriTemplate: '/products/{id}', denormalizationContext: ['groups' => ['product:write']], security: "is_granted('ROLE_ADMIN') or object.owner == user" ), new Delete( uriTemplate: '/products/{id}', security: "is_granted('ROLE_ADMIN')" ) ], order: ['createdAt' => 'DESC'], paginationEnabled: true, paginationItemsPerPage: 20 )] class Product { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['product:read', 'product:list'])] private ?int $id = null; #[ORM\Column(length: 255)] #[Groups(['product:read', 'product:list', 'product:write'])] #[Assert\NotBlank] #[Assert\Length(min: 3, max: 255)] private ?string $name = null; #[ORM\Column(type: 'text')] #[Groups(['product:read', 'product:write'])] private ?string $description = null; #[ORM\Column(type: 'decimal', precision: 10, scale: 2)] #[Groups(['product:read', 'product:list', 'product:write'])] #[Assert\NotNull] #[Assert\Positive] private ?string $price = null; #[ORM\Column] #[Groups(['product:read'])] private ?\DateTimeImmutable $createdAt = null; #[ORM\ManyToOne(targetEntity: User::class)] #[Groups(['product:read'])] public ?User $owner = null; } ``` ### Custom Operations ```php namespace App\Controller; use App\Entity\Product; use App\Service\ProductService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpKernel\Attribute\AsController; #[AsController] class ProductPublishController extends AbstractController { public function __construct( private ProductService $productService ) {} public function __invoke(Product $data): Product { $this->productService->publish($data); return $data; } } // In Product entity #[ApiResource( operations: [ // ... other operations new Post( uriTemplate: '/products/{id}/publish', controller: ProductPublishController::class, openapiContext: [ 'summary' => 'Publish a product', 'description' => 'Changes the product status to published', 'responses' => [ '200' => [ 'description' => 'Product published', ], ], ], read: false, name: 'product_publish' ) ] )] ``` ## Filters & Search ### Built-in Filters ```php use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; use ApiPlatform\Doctrine\Orm\Filter\RangeFilter; use ApiPlatform\Doctrine\Orm\Filter\DateFilter; use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; #[ApiResource] #[ApiFilter(SearchFilter::class, properties: [ 'name' => 'partial', 'description' => 'partial', 'category.name' => 'exact', 'sku' => 'exact' ])] #[ApiFilter(RangeFilter::class, properties: ['price', 'stock'])] #[ApiFilter(DateFilter::class, properties: ['createdAt'])] #[ApiFilter(BooleanFilter::class, properties: ['active', 'featured'])] #[ApiFilter(OrderFilter::class, properties: [ 'name' => 'ASC', 'price' => 'DESC', 'createdAt' => 'DESC' ])] class Product { // Entity properties } ``` ### Custom Filters ```php namespace App\Filter; use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; class CustomSearchFilter extends AbstractFilter { protected function filterProperty( string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = [] ): void { if ($property !== 'search') { return; } $alias = $queryBuilder->getRootAliases()[0]; $queryBuilder ->andWhere(sprintf('%s.name LIKE :search OR %s.description LIKE :search', $alias, $alias)) ->setParameter('search', '%' . $value . '%'); } public function getDescription(string $resourceClass): array { return [ 'search' => [ 'property' => null, 'type' => 'string', 'required' => false, 'description' => 'Search in name and description', ], ]; } } ``` ## Serialization & Validation ### Serialization Groups ```php #[ApiResource( normalizationContext: ['groups' => ['read']], denormalizationContext: ['groups' => ['write']], operations: [ new GetCollection( normalizationContext: ['groups' => ['collection']] ), new Get( normalizationContext: ['groups' => ['item', 'read']] ) ] )] class Order { #[Groups(['read', 'collection', 'item'])] private ?int $id = null; #[Groups(['read', 'collection', 'write'])] private ?string $reference = null; #[Groups(['item', 'write'])] private ?string $customerEmail = null; #[Groups(['item'])] private ?array $items = []; #[Groups(['read'])] #[SerializedName('total_amount')] private ?float $totalAmount = null; } ``` ### Custom Normalizer ```php namespace App\Serializer; use App\Entity\Product; use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; class ProductNormalizer implements ContextAwareNormalizerInterface { public function __construct( private ObjectNormalizer $normalizer ) {} public function normalize($object, string $format = null, array $context = []): array { $data = $this->normalizer->normalize($object, $format, $context); // Add computed fields $data['formatted_price'] = number_format($object->getPrice(), 2, '.', ',') . ' €'; $data['availability'] = $object->getStock() > 0 ? 'in_stock' : 'out_of_stock'; // Add related data conditionally if (in_array('product:detailed', $context['groups'] ?? [])) { $data['reviews_count'] = count($object->getReviews()); $data['average_rating'] = $this->calculateAverageRating($object); } return $data; } public function supportsNormalization($data, string $format = null, array $context = []): bool { return $data instanceof Product; } private function calculateAverageRating(Product $product): ?float { $reviews = $product->getReviews(); if (count($reviews) === 0) { return null; } $total = array_reduce($reviews->toArray(), function ($sum, $review) { return $sum + $review->getRating(); }, 0); return round($total / count($reviews), 1); } } ``` ### Validation ```php use Symfony\Component\Validator\Constraints as Assert; #[ApiResource] class Product { #[Assert\NotBlank(groups: ['create'])] #[Assert\Length( min: 3, max: 255, groups: ['create', 'update'] )] private ?string $name = null; #[Assert\Positive] #[Assert\LessThan(10000)] private ?float $price = null; #[Assert\Email] #[Assert\NotBlank(groups: ['create'])] private ?string $contactEmail = null; #[Assert\Valid] private ?Category $category = null; #[Assert\Callback] public function validate(ExecutionContextInterface $context): void { if ($this->startDate && $this->endDate && $this->startDate > $this->endDate) { $context->buildViolation('Start date must be before end date') ->atPath('startDate') ->addViolation(); } } } ``` ## Authentication & Security ### JWT Authentication ```php // config/packages/security.yaml security: firewalls: api: pattern: ^/api stateless: true jwt: ~ access_control: - { path: ^/api/login, roles: PUBLIC_ACCESS } - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } // Login endpoint namespace App\Controller; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Security\Http\Attribute\CurrentUser; class SecurityController extends AbstractController { #[Route('/api/login', name: 'api_login', methods: ['POST'])] public function login(#[CurrentUser] ?User $user): JsonResponse { if (null === $user) { return $this->json(['message' => 'Invalid credentials'], 401); } $token = $this->jwtManager->create($user); return $this->json([ 'user' => $user->getUserIdentifier(), 'token' => $token, ]); } } ``` ### Resource Security ```php #[ApiResource( security: "is_granted('ROLE_USER')", operations: [ new Get( security: "is_granted('VIEW', object)" ), new Put( security: "is_granted('EDIT', object)", securityMessage: "Only the owner can edit this resource" ), new Delete( security: "is_granted('ROLE_ADMIN')", securityPostDenormalize: "is_granted('DELETE', object)" ) ] )] class Document { // Properties } ``` ## Pagination & Performance ### Custom Pagination ```php namespace App\Pagination; use ApiPlatform\State\Pagination\PaginatorInterface; class CustomPaginator implements PaginatorInterface { public function __construct( private iterable $items, private float $currentPage, private float $itemsPerPage, private float $totalItems ) {} public function count(): int { return $this->totalItems; } public function getLastPage(): float { return ceil($this->totalItems / $this->itemsPerPage); } public function getTotalItems(): float { return $this->totalItems; } public function getCurrentPage(): float { return $this->currentPage; } public function getItemsPerPage(): float { return $this->itemsPerPage; } public function getIterator(): \Traversable { return new \ArrayIterator($this->items); } } ``` ### Data Provider with Optimization ```php namespace App\DataProvider; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProviderInterface; use Doctrine\ORM\EntityManagerInterface; class ProductCollectionDataProvider implements ProviderInterface { public function __construct( private EntityManagerInterface $entityManager ) {} public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { $queryBuilder = $this->entityManager ->getRepository(Product::class) ->createQueryBuilder('p') ->leftJoin('p.category', 'c') ->addSelect('c') ->leftJoin('p.images', 'i') ->addSelect('i'); // Apply filters if (isset($context['filters']['category'])) { $queryBuilder ->andWhere('c.id = :category') ->setParameter('category', $context['filters']['category']); } // Apply pagination $page = $context['filters']['page'] ?? 1; $itemsPerPage = $context['filters']['itemsPerPage'] ?? 30; $queryBuilder ->setFirstResult(($page - 1) * $itemsPerPage) ->setMaxResults($itemsPerPage); return $queryBuilder->getQuery()->getResult(); } } ``` ## Subresources ```php #[ApiResource] class User { #[ORM\OneToMany(targetEntity: Order::class, mappedBy: 'customer')] #[ApiSubresource] private Collection $orders; } // Access: GET /api/users/{id}/orders // Custom subresource #[ApiResource( uriTemplate: '/users/{id}/orders.{_format}', uriVariables: [ 'id' => new Link(fromClass: User::class, identifiers: ['id']) ], operations: [new GetCollection()] )] class Order { // Properties } ``` ## GraphQL Support ```php // Install GraphQL // composer require webonyx/graphql-php #[ApiResource( graphQlOperations: [ new Query(), new QueryCollection( paginationType: 'page' ), new Mutation( name: 'create' ), new Mutation( name: 'update' ), new DeleteMutation( name: 'delete' ) ] )] class Product { // Properties } // Custom GraphQL resolver #[ApiResource] class Order { #[GraphQL\Field] public function totalWithTax(): float { return $this->total * 1.2; } #[GraphQL\Query] public static function ordersByStatus(string $status): array { // Custom query logic return []; } } ``` ## Error Handling ### Custom Exception ```php namespace App\Exception; use ApiPlatform\Symfony\Validator\Exception\ValidationException; use Symfony\Component\Validator\ConstraintViolationList; class CustomApiException extends \Exception { public function __construct( private string $detail, private int $statusCode = 400, private ?array $additionalData = null ) { parent::__construct($detail, $statusCode); } public function getStatusCode(): int { return $this->statusCode; } public function getDetail(): string { return $this->detail; } public function getAdditionalData(): ?array { return $this->additionalData; } } // Exception normalizer class CustomExceptionNormalizer implements NormalizerInterface { public function normalize($exception, string $format = null, array $context = []): array { $data = [ 'type' => 'https://tools.ietf.org/html/rfc7231#section-6.5.1', 'title' => 'An error occurred', 'detail' => $exception->getDetail(), 'status' => $exception->getStatusCode(), ]; if ($additionalData = $exception->getAdditionalData()) { $data['additionalData'] = $additionalData; } return $data; } public function supportsNormalization($data, string $format = null): bool { return $data instanceof CustomApiException; } } ``` ## Testing API ```php namespace App\Tests\Api; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use App\Entity\Product; use Hautelook\AliceBundle\PhpUnit\RefreshDatabaseTrait; class ProductApiTest extends ApiTestCase { use RefreshDatabaseTrait; public function testGetCollection(): void { $response = static::createClient()->request('GET', '/api/products'); $this->assertResponseIsSuccessful(); $this->assertJsonContains([ '@context' => '/api/contexts/Product', '@id' => '/api/products', '@type' => 'hydra:Collection', ]); } public function testCreateProduct(): void { $client = static::createClient(); $client->request('POST', '/api/products', [ 'headers' => ['Content-Type' => 'application/ld+json'], 'json' => [ 'name' => 'Test Product', 'price' => 99.99, 'description' => 'Test description', ], ]); $this->assertResponseStatusCodeSame(201); $this->assertJsonContains([ '@type' => 'Product', 'name' => 'Test Product', ]); } } ``` ## OpenAPI/Swagger Customization ```php #[ApiResource( openapiContext: [ 'tags' => ['Products'], 'summary' => 'Product management', 'description' => 'Endpoints for managing products', 'parameters' => [ [ 'name' => 'X-API-VERSION', 'in' => 'header', 'required' => false, 'schema' => [ 'type' => 'string', 'default' => '1.0', ], ], ], ] )] class Product { // Properties } ```