16

Rozdział 16 z 21

REST API bloga

Opublikowany

Udostępniamy blog przez REST API. Zbudujemy publiczne endpointy GET (lista i szczegóły artykułu, kategorie, tagi) zwracające JSON z DTO (nigdy encji Doctrine), z paginacją i automatyczną dokumentacją OpenAPI (Swagger UI pod /api/doc).

Czego się nauczysz

  • BlogApiController — publiczne endpointy GET
  • DTO (fromEntity) zamiast serializacji encji
  • Paginacja odpowiedzi API
  • Dokumentacja OpenAPI (NelmioApiDoc + swagger-php)

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:

  1. Nigdy nie serializujemy encji Doctrine — zawsze przez DTO (niżej).
  2. 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/doc i 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/articles zwraca JSON z artykułami, a /api/doc pokazuje klikalną dokumentację.

W Rozdziale 17 — ostatnim w Części IV — dodamy API admina (operacje zapisu) z uwierzytelnianiem tokenem i rate limitingiem.

Narzędzia / paczki

REST DTO NelmioApiDoc swagger-php

Spis treści