Vai al contenuto

API Platform e Symfony: come creare delle API RESTful con operazioni CRUD

API Platform è framework full-stack potente ma facile da usare dedicato a progetti basati su API. La piattaforma aiuta gli sviluppatori ad accelerare notevolmente lo sviluppo, costruendo API complesse e ad alte prestazioni, basate su hypermedia.  La progettazione di un’API REST avviene in pochi minuti rispettando gli standard attuali ( JSON-LD con Hydra, OpenAPI, ecc.) e mantenendo una grande flessibilità ( risorse in formato JSON, XML o CSV ….). Oltre a creare l’API REST, la piattaforma genera automaticamente un’interfaccia utente (basata su Swagger) che consente a chiunque di visualizzare e interagire con le risorse API facilitando l’implementazione lato back-end e lato client.

Swagger UI è l’interfaccia che viene generata in base alle specifiche OpenAPI. Ricordiamo che la specifica OpenAPI (OAS), precedentemente nota come specifica Swagger, è lo standard internazionale per la definizione di interfacce RESTful. Questo è un file JSON o YAML che contiene tutti i percorsi API, una descrizione di ciò che ciascuno fa, i parametri da compilare e le risposte previste. In sintesi, OAS fondamentalmente cerca di descrivere la nostra API.

In questo articolo vedremo come utilizzare API Platform con Symfony 5 per creare delle funzioni CRUD in maniera veloce e soprattutto scrivendo poche righe di codice. Rimarremo concentrati sulla componente API, anche se la distribuzione ufficiale di API Platform offre un framework molto più completo che comprende un’interfaccia Admin (React-Admin), un’interfaccia client (Next.js, Vue.js, React …) nonchè potenti strumenti di sviluppo e distribuzione (Docker e Kubernetes), un sistema di cache (Varnish) e aggiornamenti push dei dati (Mercury).

Creazione del progetto Symfony

La prima cosa da fare è creare un progetto Symfony: seguendo la guida ufficiale dovrebbe essere abbastanza semplice. Dopo l’installazione, avviando il server di sviluppo, se riusciamo a vedere la classica schermata di benvenuto di Symfony, possiamo procedere ad installare il resto.

composer require api

A questo punto se apriamo l’URL http://127.0.0.1:8000/api con un qualsiasi browser ci troveremo di fronte questa schermata:

Ci accorgiamo che è vuota! Ma niente paura, non c’è nulla che non va. Platform API lavora con le entità di Symfony, che ancora dobbiamo creare.

Prima di farlo, possiamo aprire il file api_platform.yaml che si trova in config/packages e inserire tre righe di codice per descrivere meglio la nostra API, in questo modo:

api_platform:
    title: 'Symfony API Platform REST API' 
    description: 'Esempio di API Symfony con API PLATFORM' 
    version: '1.0.0'
    mapping:
        paths: ['%kernel.project_dir%/src/Entity']
    patch_formats:
        json: ['application/merge-patch+json']
    swagger:
        versions: [3]

All’interno del suddetto file, potremo anche configurare il punto di ingresso della nostra API (che di default è /api).

Se aggiorneremo l’URL http://127.0.0.1:8000/api, il risultato sarà questo:

Creazione dell’entità su Symfony

Per esporre (pubblicare) le nostre API dobbiamo quindi creare prima una o più entità, da riga di comando, con il classico comando:

php bin/console make:entity

Inseriamo il nome dell’entità (io ho scelto Movie) e successivamente le proprietà che compongono la struttura (titolo, genere di tipo string; anno, durata di tipo integer; data_creazione e data_modifica di tipo datetime not null). Da notare che subito dopo aver confermato il nome dell’entità, appare questo messaggio che ci dice se “etichettare” l’entità come una risorsa API Platform. Ovviamente dobbiamo rispondere yes.

Mark this class as an API Platform resource (expose a CRUD API for it) (yes/no) [no]:
> yes

Una volta terminata la procedura, se apriamo il file src/Entity/Movie.php, ci accorgeremo che è stata inserita l’annotazione @ApiResource() prima della definizione di class. Questo significa che l’entità è una risorsa della nostra API e di conseguenza, quest’ultima viene automaticamente aggiunta alla documentazione dell’API. Infatti, se aggiorniamo l’URL http://127.0.0.1:8000/api ci ritroveremo di fronte a una schermata leggermente modificata:

API Platform genera per noi tutte le operazioni per l’entità Movie senza nemmeno scrivere una singola riga di codice. Cioè possiamo creare un film, modificarlo, eliminarlo e leggere l’intero elenco dei film presenti nel database.

A titolo informativo, per tutte le classi contrassegnate con la notation @ApiResource saranno disponibili 5 nuovi percorsi per le 5 operazioni e anteporranno a tutti gli URL /api. Per modificare gli URL dell’API, dobbiamo sostituire semplicemente il prefisso nel file config.

Le operazioni di API Platform

Api Platform si basa sul concetto di operazioni, un’operazione è un collegamento tra una risorsa, un percorso e il suo controller associato. Esistono due tipi di operazioni:

  • CollectionOperations: operazioni sulle collections che agiscono su un insieme di risorse.
  • ItemOperations: operazioni sugli items che agiscono su un singolo item.

Ecco le operazioni nel dettaglio:

Operazioni sulle collections
Metodo HTTP Descrizione
GET Recupera l’elenco (impaginato) di items
POST Crea un nuovo item
Operazioni sugli items
Metodo HTTP Descrizione
GET Recupera un item
PUT Sostituisci un item
PATCH Applica una modifica parziale a un item
DELETE Rimuovere un item

Tutte queste operazioni sono abilitate di default, ma è possibile disabilitarne alcune a seconda delle proprie esigenze. Le operazioni possono essere configurate utilizzando le annotation, XML o YAML. Proviamo a seguire il seguente esempio, provando a disabilitare alcune operazioni per l’entità Movie:

<?php

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiResource;
use App\Repository\MovieRepository;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ApiResource( 
 *          collectionOperations={"get"}, 
 *          itemOperations={"get", "put", "delete"} 
 * )
 * @ORM\Entity(repositoryClass=MovieRepository::class)
 */
class Movie

Aggiornando la pagina delle API, in questo caso, il metodo Patch di itemOperations e Post of collectionOperations non saranno più accessibili.

Serializzazione e deserializzazione

L’intero processo di trasformazione del nostro oggetto Movie in JSON e di nuovo JSON in un oggetto Movie, viene eseguito dal componente Serializer di Symfony.

La trasformazione da un oggetto a JSON è chiamato serializzazione mentre da JSON a un oggetto è chiamato deserializzazione. Il processo di serializzazione/deserializzazione passa attraverso un processo chiamato normalizzazione: nel caso della serializzazione l’oggetto viene prima trasformato (normalizzato) in un array e poi codificato in formato JSON. Viceversa, nel caso della deserializzazione, la stringa JSON viene decodificata in un array e poi trasformata (denormalizzata) in un oggetto.

API Platform usa gli attributi normalizationContext e denormalizationContext nell’annotation @ApiResource, per definire i cosiddetti gruppi. Le proprietà dell’entità vengono quindi aggiunte o meno a tale e tale gruppo grazie all’annotazione @Groups:

In tal modo :

  • Le proprietà che saranno nel gruppo normalizationContext saranno accessibili in modalità di lettura (GET).
  • Le proprietà che saranno nel gruppo denormalizationContext saranno accessibili in modalità di scrittura (POST, PUT, PATCH).
  • Le proprietà che saranno nei 2 gruppi saranno accessibili in modalità lettura e scrittura.
  • Le proprietà che non hanno gruppi non verranno esposte.

Proviamo queste opzioni sulla nostra entità Movie:

<?php

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiResource;
use App\Repository\MovieRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;


/**
 * @ApiResource( 
 *          normalizationContext={"groups"={"movie:read"}}, 
 *          denormalizationContext={"groups"={"movie:write"}}
 * )
 * @ORM\Entity(repositoryClass=MovieRepository::class)
 */
class Movie
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @Groups({"movie:read"})
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @Groups({"movie:read", "movie:write"})
     * @ORM\Column(type="string", length=255, nullable=true)
     */
    private $titolo;

    /**
     * @Groups({"movie:read", "movie:write"})
     * @ORM\Column(type="string", length=255, nullable=true)
     */
    private $genere;

    /**
     * @Groups({"movie:read", "movie:write"})
     * @ORM\Column(type="integer", nullable=true)
     */
    private $anno;

    /**
     * @Groups({"movie:read", "movie:write"})
     * @ORM\Column(type="integer", nullable=true)
     */
    private $durata;

    /**
     * @Groups({"movie:read"})
     * @ORM\Column(type="datetime")
     */
    private $data_creazione;

    /**
     * @Groups({"movie:read"})
     * @ORM\Column(type="datetime")
     */
    private $data_modifica; 

    (... metodi set/get)

}

Facciamo attenzione alle variabili $data_creazione e $data_modifica: il @Groups corrispondente è stato impostato solo su movie:read. Adesso, ricarichiamo la pagina delle api e diamo un’occhiata alla sezione Schemas, vedremo che questi sono stati sostanzialmente modificati con i nuovi gruppi.

Inoltre in base alle proprietà read/write impostate in annotation, noteremo che nell’endpoin api/movies, utilizzato per salvare un nuovo movie, sarà necessario inserire 4 attributi anzichè tutti e 6 (cioè mancano proprio gli attributi data_creazione e data_modifica).

Aggiungere la persistenza dei dati

Siamo quasi pronti per testare la nostra API. Ma se proviamo ad inserire dei dati, dove andrebbero a finire? Nel nulla cosmico? Allora prima di effettuare il test finale, dobbiamo creare il nostro database che memorizzerà in modo persistente i dati che inseriamo e recuperare i dati esistenti.

La prima cosa da fare è configurare correttamente la stringa di connessione al database presente sul file .env nella root del progetto specificando username, password e nome del database, nonchè il tipo di database utilizzato (noi per comodità utilizzeremo MySql). Solo in questo modo riusciremo a comunicare correttamente con il database. Poi lanciamo da riga di comando:

php bin/console doctrine:database:create

E iniziamo ad eseguire le migrazioni delle entità:

php bin/console make:migration 
php bin/console doctrine:migrations:migrate

Il database sembra pronto ma la tabella movie è vuota. Come fare per inserire allora dei dati di esempio senza dover popolare manualmente la tabella? Viene in nostro aiuto un bundle chiamato DoctrineFixturesBundle, che tuttavia occorre installare tramite composer (lo installiamo solo in dev, perchè in prod non ne abbiamo di certo bisogno…).

composer require --dev orm-fixtures

I Fixture vengono utilizzati per caricare un set di dati fittizio in un database che può quindi essere utilizzato per il test o per fornire alcuni dati interessanti durante lo sviluppo dell’applicazione. Questo bundle è compatibile con qualsiasi database supportato da Doctrine ORM (MySQL, PostgreSQL, SQLite, ecc.). Per MongoDB, bisogna invece usare DoctrineMongoDBBundle.

Creiamo quindi la nostra fixture in un file chiamato MovieFixtures.php e lo posizioniamo nella seguente cartella src/DataFixtures

<?php

namespace App\DataFixtures;

use App\Entity\Movie;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;

class MovieFixtures extends Fixture
{
    public function load(ObjectManager $manager)
    {
        for ($i = 0; $i < 100; $i++) {
            $mov = new Movie();
            $mov->setTitolo('Movie '.$i);
            $mov->setGenere('Title '.$i);
            $mov->setAnno($i);
            $mov->setDurata($i);
            $manager->persist($mov);
        }

        $manager->flush();
    }
}

Infine, eseguiamo il seguente comando:

php bin/console doctrine:fixtures:load

Adesso possiamo interagire con le nostre api. Aggiorniamo l’URL http://127.0.0.1:8000/api e clicchiamo sul primo endpoint (/api/movies). Clicchiamo quindi su Try it out e poi su Esegui.

Possiamo notare la presenza di una sezione Parametri, in cui viene visualizzata un parametro di input di tipo intero. Questo è semplicemente il numero di pagina della collezione ritornata. Di default, API Platform impagina i risultati e restituisce 30 elementi per pagina per evitare di restituire tutti i dati in un sol colpo e causare problemi di prestazioni. Possiamo quindi passare il numero di pagina che vogliamo, sempre nei limiti del range dei risultati. Inoltre, possiamo modificare il numero di elementi per pagina direttamente nella configurazione in api_platform.yaml.

La seconda sezione rappresenta i risultati della query in formato JSON-LD. È sempre JSON ma con metadati aggiuntivi che spiegano il contesto.

Se proviamo ad avviare il secondo endpoint (POST) per salvare un nuovo movie, il sistema va in errore e ritorna l’errore 500. Il motivo è presto detto: quando abbiamo definito i campi della nostra entità, abbiamo stabilito che data_creazione e data_modifica fossero nullable=false. Quindi, all’atto dell’inserimento, poichè l’endpoint non prevede l’invio di questi due campi, arriveremo inevitabilmente a un errore 500.

Questi campi vanno compilati in automatico, anzichè essere compilati dall’utente. In questo modo ci garantiamo una data certa applicata dal server all’atto dell’inserimento e/o della modifica della singola entità. Per raggiungere il nostro scopo, possiamo implementare ciò che viene chiamato DataPersister.

Per modificare gli stati dell’applicazione durante le operazioni POST, PUT, PATCH o DELETE, API Platform utilizza classi denominate DataPersister, che ricevono un’istanza della classe dichiarata come risorsa API e contengono i dati inviati dal client durante il processo di deserializzazione. E’ qui che andremo ad aggiungere dinamicamente i campi mancanti convalidando di fatto l’aggiunta del nuovo movie.

Creiamo una nuova classe chiamata MovieDataPersister, che implementa l’interfaccia DataPersister di API Platform, e posizioniamolo nella cartella src/DataPersister. In sintesi diremo ad API Platform che dal momento in cui crea un nuovo movie dovrà inserire una nuova data di creazione. Allo stesso tempo, indipendentemente dal fatto che si tratti di una creazione o edizione di un movie, API Platform dovrà sempre registrare una data di modifica. Tutto questo glielo diciamo dentro la funzione persist().

<?php
namespace App\DataPersister;

use ApiPlatform\Core\DataPersister\DataPersisterInterface;
use App\Entity\Movie;
use Doctrine\ORM\EntityManagerInterface;

class MovieDataPersister implements DataPersisterInterface
{

   private $entityManager;

   public function __construct(EntityManagerInterface $entityManager)
   {
       $this->entityManager = $entityManager;
   }

   public function supports($data): bool
   {
       return $data instanceof Movie;
   }

   /**
    * @param Movie $data
    * @return void
    */
   public function persist($data)
   {
       if (!$data->getId()) {
           $data->setDataCreazione(new \DateTime('now'));
       }
       $data->setDataModifica(new \DateTime('now'));

       $this->entityManager->persist($data);
       $this->entityManager->flush();
   }

   public function remove($data)
   {
       $this->entityManager->remove($data);
       $this->entityManager->flush();
   }
}

Stavolta, se proviamo ad avviare l’endpoint POST/api/movies, riceveremo la risposta 201 che la richiesta ha avuto esito positivo e che è stata creata una nuova risorsa.

La convalida dei dati

Se un client API può inviare dati errati, lo farà (semicit.): può inviare JSON malformati o addirittura inviare un campo del titolo vuoto semplicemente perché un utente ha dimenticato di compilare un campo sul frontend… Il lavoro della nostra API è rispondere a tutti questi situazioni in modo che gli errori possano essere facilmente compresi, analizzati e comunicati. Questa è una delle aree in cui API Platform eccelle davvero sfruttando il potente componente Symfony Validator. Grazie a questo componente aggiungere regole di validazione diventa molto semplice, non dobbiamo far altro che aggiungere alcune annotazioni (@Assert ) ai tuoi attributi e il gioco è fatto:

<?php

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiResource;
use App\Repository\MovieRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;


/**
 * @ApiResource( 
 *          normalizationContext={"groups"={"movie:read"}}, 
 *          denormalizationContext={"groups"={"movie:write"}}
 * )
 * @ORM\Entity(repositoryClass=MovieRepository::class)
 */
class Movie
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @Groups({"movie:read"})
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @Groups({"movie:read", "movie:write"})
     * @Assert\NotBlank()
     * @ORM\Column(type="string", length=255, nullable=true)
     */
    private $titolo;

    /**
     * @Groups({"movie:read", "movie:write"})
     * @Assert\NotBlank()
     * @ORM\Column(type="string", length=255, nullable=true)
     */
    private $genere;

    /**
     * @Groups({"movie:read", "movie:write"})
     * @Assert\NotBlank()
     * @ORM\Column(type="integer", nullable=true)
     */
    private $anno;

    /**
     * @Groups({"movie:read", "movie:write"})
     * @Assert\NotBlank()
     * @ORM\Column(type="integer", nullable=true)
     */
    private $durata;

    /**
     * @Groups({"movie:read"})
     * @ORM\Column(type="datetime")
     */
    private $data_creazione;

    /**
     * @Groups({"movie:read"})
     * @ORM\Column(type="datetime")
     */
    private $data_modifica;

(...)