Rozdział 16: REST API bloga
Zaczynamy Część IV — API. Udostępnimy blog przez REST API: publiczne endpointy GET zwracające JSON, z DTO (nigdy encji Doctrine), paginacją i automatyczną dokumentacją OpenAPI (Swagger UI).
Stan wejściowy: encje i repozytoria bloga. Tu dokładamy osobną warstwę API — bez ruszania kontrolerów webowych z Części I.
Warstwa API i konwencje
W tym projekcie API to osobna warstwa w src/Api/, oddzielona od kontrolerów webowych:
src/Api/
├── Controller/ # BlogApiController — zwraca JSON
└── Dto/ # BlogArticleDto, BlogCategoryDto, BlogTagDto
Kontroler ma prefiks /api/blog, a każda akcja zwraca JsonResponse. Dwie żelazne zasady:
- Nigdy nie serializujemy encji Doctrine — zawsze przez DTO (niżej).
- Endpointy tego rozdziału są publiczne i tylko do odczytu (GET) — uwierzytelnianie i operacje zapisu dokładamy w Rozdziale 17.
// src/Api/Controller/BlogApiController.php
#[Route('/api/blog', name: 'api_blog_')]
#[OA\Tag(name: 'Blog')]
class BlogApiController extends AbstractController
{
// …
}
DTO zamiast encji
DTO (Data Transfer Object) to prosty obiekt opisujący kształt odpowiedzi. Statyczna metoda fromEntity()
mapuje encję na DTO — dzięki temu kształt API jest niezależny od modelu bazy (zmiana kolumny nie psuje
kontraktu API, nie wyciekają pola wewnętrzne):
// src/Api/Dto/BlogArticleDto.php
public static function fromEntity(BlogArticle $article): self
{
return new self(
id: $article->getId(),
title: $article->getTitle(),
slug: $article->getSlug(),
excerpt: $article->getExcerpt(),
content: $article->getContent(),
status: $article->getStatus(),
publishedAt: $article->getPublishedAt()?->format(\DateTimeInterface::ATOM),
viewsCount: $article->getViewsCount(),
category: null !== $article->getCategory() ? BlogCategoryDto::fromEntity($article->getCategory()) : null,
tags: array_map(BlogTagDto::fromEntity(...), $article->getTags()->toArray()),
authorName: $article->getAuthor()->getName(),
createdAt: $article->getCreatedAt()->format(\DateTimeInterface::ATOM),
);
}
- Daty w ISO 8601 (
DateTimeInterface::ATOM) — standardowy, jednoznaczny format w API. - Zagnieżdżone DTO — kategoria i tagi też przez własne
fromEntity(), więc odpowiedź jest w pełni kontrolowana. array_map(BlogTagDto::fromEntity(...))— pierwszej-klasowa składnia wywołań (PHP 8.1) mapuje kolekcję tagów na DTO.
Endpointy i paginacja
Lista artykułów przyjmuje page, limit oraz opcjonalne filtry category/tag (po slug-u) — i reużywa
findPublishedQuery() z Rozdziału 4:
#[Route('/articles', name: 'articles', methods: ['GET'])]
public function articles(Request $request, BlogArticleRepository $articles, BlogCategoryRepository $categories, BlogTagRepository $tags): JsonResponse
{
$page = max(1, $request->query->getInt('page', 1));
$limit = min(50, max(1, $request->query->getInt('limit', 10))); // twardy limit 50
$categoryId = null;
if ('' !== ($slug = $request->query->getString('category'))) {
$categoryId = $categories->findOneBy(['slug' => $slug])?->getId();
}
// (analogicznie tagId z ?tag=…)
$all = $articles->findPublishedQuery($categoryId, $tagId)->getResult();
$total = count($all);
$items = array_slice($all, ($page - 1) * $limit, $limit);
return $this->json([
'total' => $total,
'page' => $page,
'limit' => $limit,
'pages' => (int) ceil($total / $limit),
'items' => array_map(BlogArticleDto::fromEntity(...), $items),
]);
}
Endpoint szczegółów (/articles/{slug}) zwraca pojedynczy artykuł albo błąd, gdy nie istnieje / nie jest
opublikowany:
#[Route('/articles/{slug}', name: 'article_show', methods: ['GET'])]
public function articleShow(string $slug, BlogArticleRepository $articles, EntityManagerInterface $em): JsonResponse
{
$article = $articles->findOneBy(['slug' => $slug]);
if (null === $article || BlogArticle::STATUS_PUBLISHED !== $article->getStatus()) {
return $this->json(new ApiResponse('Artykuł nie istnieje lub nie jest opublikowany.', false), 404);
}
$article->incrementViews();
$em->flush();
return $this->json(BlogArticleDto::fromEntity($article));
}
Pozostałe endpointy — /categories i /tags — zwracają listy przez odpowiednie DTO. Metody API GET
inkrementują też licznik odsłon (jak strona webowa), więc statystyki są spójne.
Odpowiedź listy ma jednolity kształt:
total,page,limit,pages,items— klient łatwo zbuduje paginację po stronie frontu (np. SPA).
Dokumentacja OpenAPI
API dokumentujemy atrybutami OpenAPI (zircote/swagger-php), a NelmioApiDoc generuje z nich
interaktywny Swagger UI pod /api/doc:
#[Route('/articles', name: 'articles', methods: ['GET'])]
#[OA\Get(
summary: 'Lista opublikowanych artykułów',
parameters: [
new OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'limit', in: 'query', schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'category', in: 'query', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'Lista artykułów'),
],
)]
public function articles(/* … */): JsonResponse
- Atrybuty przy akcjach — dokumentacja żyje obok kodu, nie w osobnym pliku YAML, więc nie rozjeżdża się z implementacją.
#[OA\Tag(name: 'Blog')]na klasie grupuje endpointy w Swaggerze.- Wchodzisz na
http://localhost:8090/api/doci masz klikalną dokumentację — z możliwością wysłania zapytań („Try it out”).
Dlaczego NelmioApiDoc, a nie API Platform? Trzymamy się jawnych, ręcznie pisanych kontrolerów i DTO — mamy pełną kontrolę nad kształtem odpowiedzi, a Nelmio jedynie dokumentuje to, co napisaliśmy (nie generuje endpointów za nas).
Podsumowanie
Blog ma publiczne REST API:
- ✅ osobna warstwa
src/Api/(kontroler + DTO), prefiks/api/blog, wyłącznie GET, - ✅ DTO
fromEntity()zamiast serializacji encji — kształt API niezależny od bazy, daty w ISO 8601, - ✅ endpointy: lista artykułów (paginacja + filtry
category/tag), szczegóły artykułu (404 dla nieopublikowanych), kategorie, tagi, - ✅ paginacja w jednolitym kształcie (
total/page/limit/pages/items), - ✅ OpenAPI przez atrybuty
#[OA\...]+ Swagger UI pod/api/doc(NelmioApiDoc, nie API Platform), - ✅ działający stan:
GET /api/blog/articleszwraca JSON z artykułami, a/api/docpokazuje klikalną dokumentację.
W Rozdziale 17 — ostatnim w Części IV — dodamy API admina (operacje zapisu) z uwierzytelnianiem tokenem i rate limitingiem.