API REST in Laravel: routing e controller di risorsa
Nella prima puntata abbiamo creato il progetto, abilitato le rotte API ed esposto un endpoint di health check. Ora costruiamo l'impalcatura del nostro dominio: la risorsa libro. Vedremo come definire in un colpo solo tutti gli endpoint CRUD con Route::apiResource, come generare un controller di risorsa già predisposto per le API e come organizzare le rotte con gruppi, prefissi e nomi. Per non anticipare l'argomento della persistenza, in questa puntata i dati vivranno temporaneamente in memoria, all'interno di una piccola classe di supporto; nella prossima li sposteremo su database con Eloquent, senza dover riscrivere le rotte.
Le rotte di una risorsa REST
Una risorsa REST espone un insieme convenzionale di endpoint. Per la nostra collezione di libri la mappatura è la seguente:
GET /api/booksrestituisce l'elenco dei libri.POST /api/bookscrea un nuovo libro.GET /api/books/{book}restituisce un singolo libro.PUT /api/books/{book}oPATCH /api/books/{book}aggiorna un libro esistente.DELETE /api/books/{book}elimina un libro.
Scrivere queste cinque rotte a mano è possibile, ma ripetitivo ed esposto a errori di battitura. Laravel fornisce una scorciatoia che le genera tutte insieme, rispettando esattamente questa convenzione.
Il controller di risorsa
Generiamo un controller pensato per le API con l'opzione --api. A differenza di un controller di risorsa completo, questa variante omette i metodi create ed edit, che servirebbero solo a mostrare form HTML e non hanno senso in un'API.
php artisan make:controller BookController --api
Il comando crea app/Http/Controllers/BookController.php con lo scheletro dei cinque metodi. Ognuno corrisponde a una delle azioni convenzionali.
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class BookController extends Controller
{
public function index() {} // GET /books
public function store(Request $request) {} // POST /books
public function show(string $id) {} // GET /books/{book}
public function update(Request $request, string $id) {} // PUT/PATCH /books/{book}
public function destroy(string $id) {} // DELETE /books/{book}
}
Registrare la risorsa con una riga
Ora colleghiamo il controller a tutte le rotte convenzionali. In routes/api.php è sufficiente una singola istruzione.
<?php
use App\Http\Controllers\BookController;
use App\Http\Controllers\HealthController;
use Illuminate\Support\Facades\Route;
Route::get('/health', [HealthController::class, 'show']);
// Genera le cinque rotte CRUD per la risorsa "books"
Route::apiResource('books', BookController::class);
Verifichiamo il risultato con il comando di ispezione delle rotte.
php artisan route:list --path=api/books
Vedremo comparire tutte e cinque le rotte, correttamente associate ai metodi del controller. Osserva che il parametro dell'URL si chiama {book}, al singolare: Laravel lo deriva automaticamente dal nome della risorsa. Questo dettaglio sarà importante nella prossima puntata, quando attiveremo il route model binding.
Un archivio temporaneo in memoria
Prima di implementare i metodi ci serve un posto dove tenere i libri. Poiché la persistenza è argomento della prossima puntata, creiamo una piccola classe che simula un archivio, mantenendo i dati in un array statico. Creiamo il file app/Support/BookStore.php.
<?php
namespace App\Support;
class BookStore
{
// Archivio in memoria, condiviso per la durata della singola richiesta.
// Serve solo come segnaposto finché non introdurremo il database.
private static array $books = [
['id' => 1, 'title' => 'Il nome della rosa', 'author' => 'Umberto Eco', 'year' => 1980],
['id' => 2, 'title' => 'Se questo è un uomo', 'author' => 'Primo Levi', 'year' => 1947],
];
public static function all(): array
{
return array_values(self::$books);
}
public static function find(int $id): ?array
{
foreach (self::$books as $book) {
if ($book['id'] === $id) {
return $book;
}
}
return null;
}
public static function create(array $data): array
{
// Calcoliamo il prossimo identificatore disponibile
$nextId = self::$books === [] ? 1 : max(array_column(self::$books, 'id')) + 1;
$book = ['id' => $nextId, ...$data];
self::$books[] = $book;
return $book;
}
}
Questa classe non è pensata per durare: è un'impalcatura provvisoria che ci consente di far funzionare gli endpoint e di ragionare sul flusso richiesta-risposta senza distrazioni. Nella prossima puntata la rimpiazzeremo con il modello Eloquent.
Implementare i metodi del controller
Riempiamo ora il controller, collegando ogni azione all'archivio temporaneo e prestando attenzione ai codici di stato, che sono parte integrante della semantica REST.
<?php
namespace App\Http\Controllers;
use App\Support\BookStore;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class BookController extends Controller
{
// Elenco completo dei libri
public function index(): JsonResponse
{
return response()->json(BookStore::all());
}
// Creazione di un nuovo libro
public function store(Request $request): JsonResponse
{
$book = BookStore::create($request->only(['title', 'author', 'year']));
// 201 Created è il codice corretto per una risorsa appena creata
return response()->json($book, 201);
}
// Dettaglio di un singolo libro
public function show(string $id): JsonResponse
{
$book = BookStore::find((int) $id);
if ($book === null) {
// 404 Not Found quando la risorsa non esiste
return response()->json(['message' => 'Libro non trovato'], 404);
}
return response()->json($book);
}
public function update(Request $request, string $id): JsonResponse
{
// La logica di aggiornamento arriverà con la persistenza
return response()->json(['message' => 'Non ancora implementato'], 501);
}
public function destroy(string $id): JsonResponse
{
// La logica di eliminazione arriverà con la persistenza
return response()->json(['message' => 'Non ancora implementato'], 501);
}
}
Proviamo subito l'elenco e la creazione dal terminale.
curl -i http://localhost:8000/api/books
curl -i -X POST http://localhost:8000/api/books \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"title":"La coscienza di Zeno","author":"Italo Svevo","year":1923}'
L'intestazione Accept: application/json nella seconda chiamata è importante: comunica a Laravel che il client si aspetta una risposta JSON. Come vedremo nella puntata sulla validazione, il framework usa questa intestazione per decidere se restituire gli errori in formato JSON anziché reindirizzare a una pagina web.
Route model binding, in anteprima
Nei metodi show, update e destroy riceviamo l'identificatore come stringa e dobbiamo cercare manualmente il libro corrispondente. Questo schema si ripete e, con un vero modello, Laravel può eliminarlo del tutto grazie al route model binding: dichiarando come parametro un oggetto del tipo del modello, il framework lo carica automaticamente dal database in base all'identificatore presente nell'URL, restituendo un 404 se non esiste. Non possiamo ancora usarlo perché non abbiamo un modello, ma la firma a cui puntiamo è questa.
// Obiettivo della prossima puntata: Laravel carica il libro al posto nostro
public function show(Book $book): JsonResponse
{
return response()->json($book);
}
Organizzare le rotte con i gruppi
Man mano che gli endpoint aumentano conviene raggrupparli. I gruppi permettono di applicare un prefisso, un middleware o uno spazio dei nomi comune a più rotte. Anticipiamo una struttura che ci tornerà utile: un prefisso di versione. Nell'ultima puntata parleremo diffusamente di versioning; qui ne mostriamo solo la meccanica.
<?php
use App\Http\Controllers\BookController;
use App\Http\Controllers\HealthController;
use Illuminate\Support\Facades\Route;
Route::get('/health', [HealthController::class, 'show']);
// Tutte le rotte interne al gruppo saranno servite sotto /api/v1
Route::prefix('v1')->group(function () {
Route::apiResource('books', BookController::class);
});
Con questa modifica l'elenco dei libri diventa GET /api/v1/books. Il gruppo può ricevere anche un middleware, per esempio per proteggere un sottoinsieme di rotte con l'autenticazione: lo faremo nella puntata dedicata a Sanctum, aggiungendo ->middleware('auth:sanctum') al gruppo.
Limitare le azioni generate
Non sempre una risorsa deve esporre tutti e cinque i verbi. Il metodo apiResource accetta i modificatori only ed except per restringere l'insieme di rotte generate. Se, per ipotesi, i libri fossero di sola lettura tramite l'API, scriveremmo:
// Genera soltanto le rotte index e show
Route::apiResource('books', BookController::class)->only(['index', 'show']);
È buona norma esporre soltanto le azioni realmente necessarie: ogni endpoint pubblico è una superficie da proteggere e da documentare, quindi meno ce ne sono e meglio è.
Conclusione
Abbiamo costruito l'ossatura della nostra API. Con Route::apiResource e un controller generato con --api abbiamo ottenuto in poche righe l'intero insieme di endpoint CRUD per la risorsa libro, rispettando la convenzione REST sui metodi HTTP e sui codici di stato. Abbiamo introdotto un archivio in memoria come segnaposto e implementato le azioni di lettura e creazione, lasciando aggiornamento ed eliminazione per la puntata successiva, quando avremo una persistenza reale.
Nella prossima puntata sostituiremo l'archivio provvisorio con il database. Definiremo una migrazione per la tabella dei libri, creeremo il modello Eloquent Book e riscriveremo il controller sfruttando l'ORM e il route model binding, completando finalmente tutte le operazioni CRUD.