API REST in Laravel: validazione e Form Request

API REST in Laravel: validazione e Form Request

Fino a questo punto abbiamo accettato senza controlli qualsiasi dato arrivasse nel corpo delle richieste di creazione e aggiornamento. È una falla seria: un client potrebbe inviare un titolo vuoto, un anno impossibile o un ISBN già presente, e noi lo salveremmo comunque. In questa puntata introduciamo la validazione. Partiremo dalla validazione inline, per poi spostarla in classi dedicate, le Form Request, che tengono il controller pulito e centralizzano le regole. Vedremo come Laravel restituisca automaticamente errori JSON strutturati con il codice di stato corretto, come scrivere messaggi personalizzati e come definire regole su misura.

Validazione inline nel controller

Il modo più diretto per validare è il metodo validate disponibile su ogni richiesta. Riceve un array di regole e, se la validazione fallisce, interrompe l'esecuzione lanciando un'eccezione che Laravel converte nella risposta appropriata.

public function store(Request $request)
{
    // Se la validazione fallisce, l'esecuzione si interrompe qui
    $validated = $request->validate([
        'title'  => ['required', 'string', 'max:255'],
        'author' => ['required', 'string', 'max:255'],
        'year'   => ['nullable', 'integer', 'min:1450', 'max:2100'],
        'isbn'   => ['nullable', 'string', 'unique:books,isbn'],
    ]);

    // $validated contiene solo i campi che hanno superato la validazione
    $book = Book::create($validated);

    return (new BookResource($book))->response()->setStatusCode(201);
}

Osserviamo due vantaggi. Primo, il metodo restituisce soltanto i dati validati: usando $validated anziché $request->all() passiamo a create esclusivamente i campi che abbiamo esplicitamente previsto, aggiungendo un ulteriore livello di sicurezza a quello del mass assignment. Secondo, non dobbiamo scrivere alcuna gestione degli errori: ci pensa il framework.

La risposta di errore automatica

Quando la validazione fallisce e la richiesta si aspetta JSON, come indicato dall'intestazione Accept: application/json, Laravel produce automaticamente una risposta con codice 422 Unprocessable Content e un corpo che descrive gli errori campo per campo.

{
  "message": "The title field is required. (and 1 more error)",
  "errors": {
    "title": [
      "The title field is required."
    ],
    "year": [
      "The year field must be an integer."
    ]
  }
}

Questa struttura è uno standard di fatto: la chiave message riassume l'errore, mentre errors raccoglie tutti i problemi raggruppati per campo, permettendo a un frontend di mostrare i messaggi accanto ai rispettivi input. Ottenere tutto questo gratuitamente è uno dei motivi per cui Laravel è così produttivo nella costruzione di API.

È bene ricordare che questa risposta JSON scatta solo se la richiesta segnala di aspettarsi JSON. In un'API è quindi buona norma imporre l'intestazione Accept lato client. Nell'ultima puntata vedremo comunque come forzare il comportamento JSON per l'intero gruppo di rotte API, così da essere sicuri di non ricevere mai un reindirizzamento HTML.

Spostare le regole in una Form Request

Man mano che le regole si arricchiscono, tenerle nel controller lo appesantisce. Le Form Request sono classi dedicate che incapsulano sia le regole di validazione sia la logica di autorizzazione. Generiamone una per la creazione di un libro.

php artisan make:request StoreBookRequest

Il comando crea app/Http/Requests/StoreBookRequest.php con due metodi. Il metodo authorize decide se l'utente ha il permesso di eseguire l'operazione; il metodo rules restituisce le regole di validazione.

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreBookRequest extends FormRequest
{
    // Per ora autorizziamo chiunque; l'autorizzazione arriverà con Sanctum
    public function authorize(): bool
    {
        return true;
    }

    // Regole di validazione per la creazione di un libro
    public function rules(): array
    {
        return [
            'title'  => ['required', 'string', 'max:255'],
            'author' => ['required', 'string', 'max:255'],
            'year'   => ['nullable', 'integer', 'min:1450', 'max:2100'],
            'isbn'   => ['nullable', 'string', 'unique:books,isbn'],
        ];
    }
}

Ora nel controller basta dichiarare la Form Request come tipo del parametro: Laravel la istanzia, esegue l'autorizzazione e la validazione prima ancora di entrare nel corpo del metodo. Se qualcosa fallisce, il metodo non viene nemmeno eseguito.

use App\Http\Requests\StoreBookRequest;

public function store(StoreBookRequest $request)
{
    // Arriviamo qui solo se autorizzazione e validazione sono andate a buon fine
    $book = Book::create($request->validated());

    return (new BookResource($book))->response()->setStatusCode(201);
}

Il controller torna a essere essenziale, e le regole vivono in un unico punto, facile da individuare e da testare in isolamento.

Regole diverse per la creazione e l'aggiornamento

Creazione e aggiornamento hanno spesso esigenze diverse. In creazione tutti i campi obbligatori devono essere presenti; in aggiornamento parziale, invece, il client potrebbe inviare solo i campi da modificare. La regola sometimes serve proprio a validare un campo soltanto quando è presente. Generiamo una Form Request separata per l'aggiornamento.

php artisan make:request UpdateBookRequest
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class UpdateBookRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        // Recuperiamo il libro in corso di aggiornamento dal route model binding
        $bookId = $this->route('book')->id;

        return [
            'title'  => ['sometimes', 'required', 'string', 'max:255'],
            'author' => ['sometimes', 'required', 'string', 'max:255'],
            'year'   => ['sometimes', 'nullable', 'integer', 'min:1450', 'max:2100'],
            // L'ISBN deve restare unico, ma ignoriamo il libro corrente nel controllo
            'isbn'   => ['sometimes', 'nullable', 'string', Rule::unique('books', 'isbn')->ignore($bookId)],
        ];
    }
}

Il dettaglio importante è nella regola di unicità sull'ISBN. Durante un aggiornamento, il libro conserva spesso lo stesso ISBN: senza accorgimenti, il controllo di unicità lo rileverebbe come duplicato di sé stesso e fallirebbe. Il metodo ignore esclude il record corrente dal confronto, risolvendo il problema. Colleghiamo la nuova Form Request al metodo update del controller.

use App\Http\Requests\UpdateBookRequest;

public function update(UpdateBookRequest $request, Book $book)
{
    $book->update($request->validated());

    return new BookResource($book);
}

Messaggi personalizzati

I messaggi di errore predefiniti sono in inglese e generici. Possiamo personalizzarli sovrascrivendo il metodo messages nella Form Request, sia per tradurli sia per renderli più chiari.

// Messaggi di errore su misura, indicizzati per campo e regola
public function messages(): array
{
    return [
        'title.required' => 'Il titolo è obbligatorio.',
        'year.integer'   => "L'anno deve essere un numero intero.",
        'year.max'       => "L'anno non può essere nel futuro remoto.",
        'isbn.unique'    => 'Esiste già un libro con questo ISBN.',
    ];
}

Per un'applicazione multilingua, Laravel offre un sistema di traduzione completo basato sui file di lingua, ma per un'API con un pubblico ristretto la sovrascrittura diretta dei messaggi è spesso sufficiente.

Normalizzare i dati prima della validazione

A volte i dati vanno ripuliti prima di essere validati: rimuovere spazi superflui, uniformare il formato di un ISBN, convertire una stringa vuota in un valore nullo. Il metodo prepareForValidation viene eseguito prima delle regole e permette di intervenire sui dati in ingresso.

// Eseguito prima della validazione: normalizza i dati in ingresso
protected function prepareForValidation(): void
{
    $this->merge([
        'title'  => trim($this->input('title', '')),
        'author' => trim($this->input('author', '')),
        // Rimuove trattini e spazi dall'ISBN per uniformarlo
        'isbn'   => $this->filled('isbn')
            ? str_replace(['-', ' '], '', $this->input('isbn'))
            : null,
    ]);
}

Regole di validazione personalizzate

Quando le regole predefinite non bastano, possiamo scriverne di nostre. Per una logica riutilizzabile conviene generare una classe regola.

php artisan make:rule ValidIsbn

La classe implementa un metodo validate che riceve il nome del campo, il valore e una funzione da chiamare in caso di fallimento.

<?php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class ValidIsbn implements ValidationRule
{
    // Verifica che il valore sia un ISBN-13 formalmente corretto
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $digits = preg_replace('/\D/', '', (string) $value);

        if (strlen($digits) !== 13) {
            $fail("Il campo :attribute deve essere un ISBN-13 valido.");
        }
    }
}

Usarla è immediato: la si inserisce nell'array delle regole al posto di una regola testuale.

use App\Rules\ValidIsbn;

'isbn' => ['nullable', 'string', new ValidIsbn(), 'unique:books,isbn'],

Conclusione

La nostra API non accetta più dati arbitrari. Abbiamo introdotto la validazione, spostandola dal controller a classi dedicate, le Form Request, che centralizzano regole e autorizzazione. Abbiamo visto come Laravel produca automaticamente risposte di errore 422 strutturate, come differenziare le regole fra creazione e aggiornamento gestendo correttamente l'unicità, come personalizzare i messaggi, normalizzare i dati in ingresso e scrivere regole su misura.

Il metodo authorize delle Form Request, per ora, restituisce sempre true: chiunque può fare qualsiasi cosa. Nella prossima puntata colmeremo questa lacuna introducendo l'autenticazione con Laravel Sanctum e l'autorizzazione tramite policy, così da controllare chi può accedere alla nostra API e quali operazioni può compiere.