API Platform – część 1/2

Wstęp

Chciałbym zaprezentować jak szybko i łatwo zbudować API z użyciem Symfony + API Platform. Biblioteka ta dostarcza w pełni działający CRUD, stronicowanie, walidację, HATEOAS, dokumentacje. Wspiera formaty XML, JSON i JSON-LD. Dzięki korzystaniu ze znaczników schama.org wspiera SEO. Ponadto umożliwia korzystanie z GraphQL. 

Najłatwiej zaprezentować działanie API Platform na przykładzie prostej aplikacji. Nasza aplikacja roboczo nazywa się “Flashcards” i służy do nauki słówek. Przy tworzeniu tej aplikacji krok po kroku przeprowadzę Was przez przez następujące komponenty API Platform:

  1. Tworzenie prostego CRUD
  2. Grupy serializacyjne
  3. Walidacja
  4. Włączanie/Wyłączanie Operacji
  5. Własne operacje
  6. Zagnieżdżone endpointy
  7. Wyszukiwanie 

Opis aplikacji

Aplikacja powinna pozwalać użytkownikowi na posiadaniu wielu przedmiotów, których chciałby się uczyć. Każdy przedmiot powinien być podzielony na lekcje. Do lekcji można dodać wiele fiszek, które powinny zawierać pytanie oraz odpowiedź. Użytkownik powinien mieć dostęp tylko do zasobów, które sam stworzył. Użytkownik powinien mieć możliwość wyszukiwania fiszek po pytaniach i odpowiedziach.

Instalacja

Najłatwiejszym sposobem rozpoczęcia pracy z API Platform jest pobranie specjalnie przygotowanej paczki z https://github.com/api-platform/api-platform/releases/latest. Zawiera ona wszystko co konieczne aby stworzyć pierwszy projekt.

Paczka zawiera konfigurację dockera. Po rozpakowaniu jej i uruchomieniu za pomocą polecenia docker-compose up -d uruchomione zostanie całe środowisko developerskie zawierające następujące elementy: 

  • php - kontener z php 7.2 oraz composer, 
  • db - kontener z bazą danych PostgreSQL (port 5432),
  • client - kontener z częścią frontową aplikacji, domyślnie zawiera stronę powitalną. (port 80),
  • admin - kontener zawierający panel administracyjny, jest on tworzony w sposób automatyczny dzięki auto odkrywaniu API (port 81),
  • API - kontener zawierający API wraz z jego dokumentacją,
  • cache-proxy - kontener z proxy (Varnish) do API (port 8081),
  • h2-proxy - http/2 i https dla wszystkich aplikacji (porty 443 - client,  444 - admin, 8443 - API, 8444 - cache-proxy), powinien być on wykorzystywany tylko w celach deweloperskich. 

Gdy nie potrzebujemy całego środowiska dostarczonego w paczce możemy skorzystać z drugiego sposobu instalacji z wykorzystaniem Symfony Flex oraz Composer-a. Po szczegóły przeprowadzić ten proces odsyłam do dokumentacji https://api-platform.com/docs/distribution#using-symfony-flex-and-composer-advanced-users

Model  

Zaczniemy od stworzenia klas, które będą reprezentowały poszczególne elementy aplikacji. Następnie te klasy zmapujemy na bazę danych z wykorzystaniem adnotacji dostarczonych przez Doctrine ORM. 

Zacznijmy od modelu użytkownika. Będzie on zawierał podstawowe informacje o użytkowniku: name, e-mail. Na późniejszym etapie dodamy uwierzytelnianie więc warto już teraz zadbać aby model implementował UserInterface oraz zawierał niezbędne pola. 

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 */
class Flashcard
{
    /**
     * @ORM\Column(name="id", type="string", length=36)
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="CUSTOM")
     * @ORM\CustomIdGenerator(class="App\Util\Doctrine\UuidIdGenerator")
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=255)
     */
    public $question;

    /**
     * @ORM\Column(type="string", length=255)
     */
    public $answer;

    /**
     * @ORM\ManyToOne(targetEntity="Lesson", inversedBy="flashcards")
     */
    public $lesson;

    public function __construct($id = null)
    {
        $this->id = $id;
    }
  
    public function getId(): ?string
    {
        return $this->id;
    }
}
https://gist.github.com/piotrbrzezina/484121812136ea7a209aa297481a0c55.js

Następnie przygotowujemy model dla przedmiotów. Zgodnie ze specyfikacją model powinien zawierać informacje kto jest właścicielem przedmiotu, jego nazwę oraz listę dodanych lekcji.

Następnie tworzymy model lekcji. Powinien on zawierać informacje o tym do jakiego przedmioty należy lekcja, jaki jest jej temat oraz informacje o dołączonych fiszkach. 

Ostatnim elementem układanki jest model fiszki. Powinien on zawierać pytanie, odpowiedź oraz informacje do jakiej lekcji należy fiszka.

https://gist.github.com/piotrbrzezina/484121812136ea7a209aa297481a0c55

Modele są już gotowe. Następnym krokiem, który trzeba wykonać to aktualizacja 

schematu bazy danych. 

bin/console doctrine:schema:update --force

CRUD

Aby stworzyć endpointy musimy poinformować bibliotekę API Platform, które modele powinny być brane pod uwagę jako elementy API. Można to zrobić za pomocą: xml, yaml lub adnotacji. Zdecydowałem się na ostatni sposób. Do klas User, Subject, Lesson, Flashcard dodajemy adnotację  @ApiResource()

https://gist.github.com/piotrbrzezina/55a73c12a7bea2af9b81cd860cb81053

Podstawowa wersja API jest już gotowa. Możemy wykonywać podstawowe operacje na modelach: dodawać, czytać, modyfikować i usuwać. API Platform dodatkowo automatycznie wygenerował dokumentację. 

Grupy serializacyjne (Serialization groups)

Endpointy, które zostały wygenerowane nie są jeszcze dostosowane do naszych potrzeb. Wymagają doprecyzowania, które pola będą wprowadzane, a które prezentowane. API Platform podczas normalizacji i denormalizacji korzysta z symfony/serializer dzięki czemu można skorzystać z atrybutu Group. Dzięki grupom można zmieniać formę przedstawienia obiektów. W tym przypadku do ustawienia grup serializacyjnych będziemy wykorzystywać adnotację @Group ale można także skorzystać z formatów yaml lub xml. Więcej szczegółów na temat samego procesu serializacji można znaleźć w dokumentacji tego komponentu: https://symfony.com/doc/current/components/serializer.html#attributes-groups.

Przy tworzenie nowego przedmiotu jedyne pole które, użytkownik powinien wprowadzać to pole name. Przy pobieraniu danych o przedmiocie powinny być zwracane informacje o  identyfikatorze oraz wcześniej wprowadzonej nazwie. Aby to osiągnąć do pól  id i name dodajemy grupę subjectList, która będzie wykorzystywana przy pobieraniu informacji o obiekcie. Do pola  name dodajemy grupę subjectCreate, dzięki której będzie możliwe określenie, że to pole powinno zostać wprowadzone przy tworzeniu nowego przedmiotu.

https://gist.github.com/piotrbrzezina/ceffca06d59ce9a75b0360c21850653f

Następnie należy skonfigurować API Platform przez ustawienie właściwości normalizationContext oraz denormalizationContext  na odpowiednie 

wartości. Właściwość normalizationContext odpowiada za poinformowanie, jakie grupy powinny być brane pod uwagę podczas zmieniania obiektu w array. Za proces odwrotny odpowiedzialna jest właściwość denormalizationContext. 

https://gist.github.com/piotrbrzezina/26f80f17bae3932bd6da2328b81155be

Analogicznie postępujemy w przypadku pozostałych modeli https://gist.github.com/piotrbrzezina/0a5fbf9505706409614639b7801a1a97.


Dzięki temu że dokumentacja jest tworzona automatycznie pozostaje ona zawsze aktualna. Po dodaniu adnotacji @Group dokumentacja zaktualizowała się automatycznie i zawiera tylko informacje o polach, które są wykorzystywane.

W dalszej części artykułu przedstawię bardziej zaawansowane przypadki użycia grup serializacyjnych. Zachęcam do zapoznania się z dokumentacją tego elementu na stronie https://api-platform.com/docs/core/serialization/#the-serialization-process

Walidacja   

Aplikacja zaczyna przybierać odpowiednią formę. Niestety jest ona bardzo niestabilna. Wystarczy podać nieprawidłowe wartości przy tworzeniu nowych wpisów, a aplikacja zamiast poinformować, że przesyłane dane są niepoprawne zwróci błąd. API Platform wychodzi na przeciw temu problemowi i pozwala skorzystać z komponentu symfony/validator. Aby z niego skorzystać wystarczy dopisać odpowiednie ograniczenia (Constraints) do właściwości obiektów. Można to zrobić za pomocą yaml, xml lub php. Podobnie jak we wcześniejszych przypadkach, ja skorzystałem jak z adnotacji. Po więcej szczegółów na temat samego validaora odsyłam do dokumentacji:  https://symfony.com/doc/current/validation.html

Dodajemy do modeli ograniczenia, aby nie dało się wprowadzić tam danych, które mogłyby spowodować awarię aplikacji. 

https://gist.github.com/piotrbrzezina/2a14e345d93c4704c65a9370989751a6

Po dodaniu  odpowiednich ograniczeń aplikacja przestanie wyrzucać błędy, a zacznie zwracać ustandaryzowane informacje o tym, które pola zawierają błędy. 

Ponadto dokumentacja została wzbogacona o informacje które pola są wymagane. API Platform pozwala obsłużyć bardziej skomplikowane przypadki. Na przykład pozwala ustawić różne grupy serializacyjne w zależności od tego, jaką akcje wykonujemy, pozwala też ustawiać grupy serializacyjne w sposób dynamiczny. Po szczegóły odsyłam do dokumentacji: https://api-platform.com/docs/core/validation.

Endpointy:

Konfiguracja dostępnych akcji

Zdarzają się sytuacje, w których automatycznie wygenerowane endopinty są nam niepotrzebne albo musimy dodać specjalny endpoint. API Platform pozwala w łatwy sposób zarządzać dostępnymi endpointami. 

Domyślnie dla każdego resource’a tworzone jest 5 podstawowych endpointów pogrupowanych w dwa rodzaje operacji. Pierwsza grupa dotyczy operacji na kolekcji obiektów lub tworzeniu nowego elementu. Drugi rodzaj operacji zawsze odnosi się do konkretnego elementu (wymagane jest podanie o jaki element chodzi).

https://gist.github.com/piotrbrzezina/2e77ae48d5132e2aa89107a76b253a82

W naszej aplikacji użytkownik nie może usuwać wcześniej dodanych lekcji i przedmiotów. Dlatego w tych dwóch przypadkach należy dokładnie opisać jakie akcje są dostępne. Pozostawiamy wszystkie oprócz operacji delete.

Na razie ograniczyliśmy się do usuwania nadmiarowych akcji. Teraz przyszła pora na własną akcję. Nasze API powinno pozwalać użytkownikowi na zmianę hasła. Chcemy stworzyć następujący url:  /users/{id}/change-password. W tej akcji użytkownik powinien podać tylko swoje nowe hasło. Aby to osiągnąć nie musimy pisać nowej akcji w kontrolerze. Wystarczy stworzyć nową operację w API Platform oraz poinformować, że dla tej konkretnej operacji grupa denormalizacyjna powinna mieć taką wartość aby jedynym polem, które można w niej ustawić było nowe hasło. Trzeba też zadbać o to, aby nowe hasło było odpowiednio siln. Można to osiągnąć z wykorzystaniem walidacji:

https://gist.github.com/piotrbrzezina/7b06f9f39c0af93b80b223b8d4c6dd60

Dodaliśmy nową operację changePassword. Ustawiliśmy dla niej nową grupę denormalizacyjną oraz grupę walidacyjną userChangePassword. Aby zapewnić, że pozostałe walidacje nadal działają należy we właściwości validationGroups ustawić domyślną grupę walidacyjną, która w tym przypadku ma nazwę userCreate. Pozostało  zmodyfikować właściwości w klasie User tak aby posiadały odpowiednie grupy validacyjne i serializacyjne.

Nowy endpoint już działa. Dokumentacja API została automatycznie zaktualizowana.

Zagnieżdżone linki  

API powinno umożliwiać użytkownikowi pobieranie lekcji należących do konkretnego przedmiotu oraz fiszek należących do konkretnej lekcji. API Platform pozwala  na szybkie przygotowanie endpointów, które zwrócą potrzebne dane. Wystarczy oznaczyć relacje do lekcji oraz fiszek za pomocą parametru ApiSubresource

https://gist.github.com/piotrbrzezina/a5ca678486bb06177a11f0e8f61c2e61

Dzięki tym modyfikacjom powstały dwa nowe endpointy. Ponownie dokumentacja API została zmodyfikowana automatycznie. 

Po więcej szczegółów jak zarządzać operacjami w API Platform zapraszam do dokumentacji samej biblioteki https://api-platform.com/docs/core/operations

Wyszukiwanie

Wyszukiwanie danych jest bardzo częstym wymaganiem biznesowym. Nasza aplikacja powinna umożliwiać wyszukiwanie fiszek po polu question i answer. API Platform przygotowało zbiór filtrów, z których można skorzystać. 

Do dyspozycji mamy następujące filtry: 

  • Search Filter - umożliwia wyszukiwanie po stringach (dokładne, częściowe, rozpoczynające się od określonej frazy, kończące się na określonej frazie, zawierające konkretne słowa),
  • Date Filter - umożliwia wyszukiwanie po dacie (przed datą, po dacie),
  • Boolean Filter - umożliwia wyszukiwanie po polach typu boolean,
  • Numeric Filter - umożliwia wyszukiwanie po wartości numerycznej pola,
  • Range Filter - umożliwia wyszukiwanie po zakresach liczbowych (większe, mniejsze, pomiędzy),
  • Exists Filter - umożliwia wyszukiwanie, czy dane pole posiada ustawioną określoną wartość (czy nie jest null-em),
  • Order Filter - umożliwia sortowanie listy wyników po podanym polu.

Oprócz wbudowanych filtrów istnieje możliwość dodawania własnych. Fiszki mają mieć możliwość  wyszukiwania po tekście. Do tego celu idealnie użyjemy filtra Search Filter (SearchFilter::class). Aby to wyszukiwanie nie było dokładne użyjemy parametru partial, co oznacza że będziemy wyszukiwać jakiegokolwiek tekstu zawierającego daną frazę (LIKE %wyszukiwanaFraza%). Wiemy już jakiego filtra chcemy użyć ale jak o tym poinformować API Platform. Jak zwykle możemy to zrobić  na kilka sposób xml, yml, adnotacje. Jak wcześniej skorzystamy z adnotacji. Do modelu Flashcard dodajemy nową adnotację @ApiFilter 

https://gist.github.com/piotrbrzezina/ec0f04b09420af6773822fa874f9d269.

Po jej dodaniu otrzymujemy możliwość wyszukiwania po polach question oraz answer, a dokumentacja została zaktualizowana o tą informację. 

Szczegółowy opis jak zaimplementować pozostały filtry jest dostępny na stronie https://api-platform.com/docs/core/filters.

Podsumowanie 

Jak na razie udało się ogarnąć sprawy związane z podstawową funkcjonalnością API możemy już tworzyć nowe fiszki, lekcje, przedmioty i nowych użytkowników. Endpointy zwracają tylko potrzebne dane. Dzięki walidacji aplikacja ma zapewnioną odpowiednią stabilność. Wciąż jednak brakuje pewnych elementów, które są wymagane. O pozostałych elementach opowiem w drugiej części artykułu. 

Aplikacja, która była tworzona podczas pisania tego artykuł dostępna jest tutaj: https://gitlab.com/flashcards_uszanowanko/api_article/commits/part1