API REST in Laravel: errori, versioning, rate limiting e test

API REST in Laravel: errori, versioning, rate limiting e test

Concludiamo la serie mettendo insieme i tasselli che distinguono un'API funzionante da un'API pronta per la produzione. Renderemo uniforme la gestione degli errori adottando lo standard problem+json, adotteremo una strategia di versioning per far evolvere l'interfaccia senza rompere i client esistenti, proteggeremo gli endpoint dall'abuso con il rate limiting e, soprattutto, scriveremo test automatici che verifichino il comportamento dell'intera API. Al termine il nostro catalogo di libri sarà un servizio completo e affidabile.

Gestione uniforme degli errori

Un'API dovrebbe rispondere agli errori sempre nello stesso formato, qualunque ne sia la causa. In Laravel la gestione delle eccezioni è centralizzata in bootstrap/app.php, nella sezione withExceptions. Il primo accorgimento è garantire che tutte le rotte API restituiscano JSON anche in caso di eccezioni non gestite, senza mai reindirizzare a una pagina HTML. Il modo più solido è forzare l'aspettativa JSON per l'intero gruppo delle API.

->withExceptions(function (Exceptions $exceptions) {
    // Per le richieste sotto /api rispondiamo sempre in JSON
    $exceptions->shouldRenderJsonWhen(function ($request, $throwable) {
        return $request->is('api/*') || $request->expectsJson();
    });
})

Lo standard problem+json

Esiste uno standard formale per rappresentare gli errori nelle API HTTP: la RFC 9457, che definisce il tipo di media application/problem+json. Un corpo di errore conforme contiene alcuni campi convenzionali: type, un URI che identifica il tipo di problema; title, un riassunto leggibile; status, il codice HTTP; detail, una spiegazione specifica dell'occorrenza. Adottare questo formato rende gli errori prevedibili e interoperabili. Personalizziamo la resa delle eccezioni più comuni per conformarci allo standard.

use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->shouldRenderJsonWhen(function ($request, $throwable) {
        return $request->is('api/*') || $request->expectsJson();
    });

    // Risorsa non trovata: sia dal route model binding sia da rotte inesistenti
    $exceptions->render(function (NotFoundHttpException $e, Request $request) {
        if ($request->is('api/*')) {
            return response()->json([
                'type'   => 'https://httpstatuses.io/404',
                'title'  => 'Risorsa non trovata',
                'status' => 404,
                'detail' => 'La risorsa richiesta non esiste.',
            ], 404, ['Content-Type' => 'application/problem+json']);
        }
    });
})

Con questa configurazione, quando il route model binding non trova un libro, il client riceve un errore strutturato e coerente anziché una pagina di errore generica. Possiamo aggiungere gestori analoghi per l'errore di autenticazione 401, per il permesso negato 403 e per gli errori interni 500, costruendo un vocabolario di errori uniforme in tutta l'API.

Eccezioni di dominio personalizzate

Per gli errori specifici del nostro dominio conviene definire eccezioni dedicate, che sappiano rendersi da sole in formato problem+json. Creiamone una per il caso in cui si tenti di eliminare un libro che risulta ancora in prestito.

<?php

namespace App\Exceptions;

use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class BookOnLoanException extends Exception
{
    // Laravel invoca automaticamente questo metodo per rendere l'eccezione
    public function render(Request $request): JsonResponse
    {
        return response()->json([
            'type'   => 'https://example.com/problems/book-on-loan',
            'title'  => 'Libro in prestito',
            'status' => 409,
            'detail' => 'Impossibile eliminare un libro attualmente in prestito.',
        ], 409, ['Content-Type' => 'application/problem+json']);
    }
}

Nel controller ci limitiamo a lanciarla quando la condizione si verifica; alla resa penserà l'eccezione stessa.

public function destroy(Book $book)
{
    $this->authorize('delete', $book);

    if ($book->isOnLoan()) {
        throw new BookOnLoanException();
    }

    $book->delete();

    return response()->json(status: 204);
}

Versioning dell'API

Un'API pubblica evolve, e le modifiche incompatibili vanno introdotte senza rompere i client esistenti. La strategia più diffusa è il versioning tramite prefisso nell'URL, che abbiamo anticipato nella seconda puntata. Raggruppiamo le rotte per versione, così da poter far convivere una v1 stabile e una v2 in evoluzione.

use App\Http\Controllers\V1\BookController as BookControllerV1;
use App\Http\Controllers\V2\BookController as BookControllerV2;
use Illuminate\Support\Facades\Route;

Route::prefix('v1')->group(function () {
    Route::apiResource('books', BookControllerV1::class);
});

Route::prefix('v2')->group(function () {
    Route::apiResource('books', BookControllerV2::class);
});

Organizzare i controller in sottocartelle e spazi dei nomi per versione, come App\Http\Controllers\V1, mantiene il codice ordinato man mano che le versioni si accumulano. Lo stesso vale per le risorse: una V1\BookResource e una V2\BookResource possono esporre forme diverse dello stesso modello, isolando i cambiamenti di contratto.

Rate limiting

Per proteggere l'API da un uso eccessivo, accidentale o malevolo, applichiamo dei limiti di frequenza. Laravel definisce i limiti tramite la facciata RateLimiter, tipicamente in un service provider. Configuriamo un limite differenziato: più generoso per gli utenti autenticati, più severo per le richieste anonime, distinte per indirizzo IP.

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

// Da collocare nel metodo boot di un service provider
RateLimiter::for('api', function (Request $request) {
    // 120 richieste al minuto per utente autenticato, 30 per gli anonimi
    return $request->user()
        ? Limit::perMinute(120)->by($request->user()->id)
        : Limit::perMinute(30)->by($request->ip());
});

Applichiamo poi il limitatore alle rotte tramite il middleware throttle, indicando il nome della configurazione.

Route::middleware('throttle:api')->group(function () {
    Route::apiResource('books', BookController::class);
});

Quando un client supera il limite riceve una risposta 429 Too Many Requests, corredata dalle intestazioni X-RateLimit-Limit e X-RateLimit-Remaining, che gli comunicano quante richieste gli restano e quando potrà riprovare. Un client ben educato le legge per regolare la propria frequenza di chiamata.

Testare l'API

Arriviamo al tassello più importante per la manutenibilità: i test. Laravel include un ambiente di test completo e permette di scrivere test funzionali che effettuano vere richieste HTTP alla nostra applicazione, verificandone le risposte. Gli esempi che seguono usano PHPUnit, ma la stessa logica si esprime in modo del tutto analogo con Pest, l'altro runner supportato dal framework.

Generiamo un test funzionale per la risorsa libro.

php artisan make:test BookApiTest

Il trait RefreshDatabase azzera e ricostruisce il database prima di ogni test, garantendo l'isolamento: ogni test parte da uno stato pulito e non risente degli altri. Scriviamo un primo test per la lettura pubblica dell'elenco.

<?php

namespace Tests\Feature;

use App\Models\Book;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class BookApiTest extends TestCase
{
    use RefreshDatabase;

    public function test_l_elenco_dei_libri_e_pubblico(): void
    {
        // Prepariamo tre libri nel database di test
        Book::factory()->count(3)->create();

        $response = $this->getJson('/api/books');

        // Verifichiamo lo stato e la struttura della risposta paginata
        $response->assertOk();
        $response->assertJsonStructure([
            'data' => [
                ['id', 'title', 'author', 'year'],
            ],
            'meta' => ['current_page', 'total'],
        ]);
    }
}

Aggiungiamo un test che verifica la validazione: una richiesta di creazione senza titolo deve fallire con codice 422 ed elencare l'errore sul campo corretto.

public function test_la_creazione_richiede_il_titolo(): void
{
    // Un utente autenticato tenta di creare un libro senza titolo
    $user = User::factory()->create();

    $response = $this->actingAs($user)->postJson('/api/books', [
        'author' => 'Autore senza titolo',
    ]);

    $response->assertStatus(422);
    $response->assertJsonValidationErrors('title');
}

Il metodo actingAs autentica un utente per la durata del test, sostituendo l'intero flusso di login: possiamo così concentrarci sul comportamento dell'endpoint. Scriviamo ora un test del percorso felice per la creazione.

public function test_un_utente_autenticato_crea_un_libro(): void
{
    $user = User::factory()->create();

    $payload = [
        'title'  => 'Il deserto dei Tartari',
        'author' => 'Dino Buzzati',
        'year'   => 1940,
    ];

    $response = $this->actingAs($user)->postJson('/api/books', $payload);

    // La risposta deve avere codice 201 e contenere il libro creato
    $response->assertCreated();
    $response->assertJsonPath('data.title', 'Il deserto dei Tartari');

    // Il libro deve esistere davvero nel database
    $this->assertDatabaseHas('books', ['title' => 'Il deserto dei Tartari']);
}

Infine verifichiamo l'autorizzazione: un utente non deve poter eliminare il libro di un altro. Questo test difende una regola di dominio importante, e il fatto di poterlo esprimere con poche righe è la ricompensa di aver strutturato bene il codice nelle puntate precedenti.

public function test_un_utente_non_puo_eliminare_il_libro_altrui(): void
{
    $owner = User::factory()->create();
    $other = User::factory()->create();

    // Il libro appartiene a owner
    $book = Book::factory()->create(['user_id' => $owner->id]);

    // other tenta di eliminarlo e deve ricevere un 403
    $response = $this->actingAs($other)->deleteJson("/api/books/{$book->id}");

    $response->assertForbidden();
    $this->assertDatabaseHas('books', ['id' => $book->id]);
}

Eseguiamo l'intera suite con Artisan.

php artisan test

Ogni test verde è una garanzia che una parte del comportamento dell'API continua a funzionare. Quando in futuro modificheremo il codice, la suite ci avviserà immediatamente se abbiamo rotto qualcosa: è la rete di sicurezza che rende serena l'evoluzione di un progetto.

Conclusione della serie

Con questa settima puntata il nostro catalogo di libri è diventato un'API REST completa. Nel corso della serie siamo partiti dai fondamenti di REST e da un primo endpoint, abbiamo costruito le rotte di risorsa con apiResource, dato persistenza ai dati con Eloquent, plasmato le risposte con le API Resource, difeso gli ingressi con la validazione delle Form Request, messo in sicurezza tutto con l'autenticazione Sanctum e l'autorizzazione tramite policy, e infine reso il servizio robusto con la gestione uniforme degli errori, il versioning, il rate limiting e una suite di test.

Il percorso ha seguito una progressione deliberata: ogni puntata ha aggiunto uno strato allo stesso progetto, mostrando come le funzionalità di Laravel si compongano fra loro in modo naturale. Da qui puoi proseguire in autonomia esplorando temi avanzati come la documentazione automatica con OpenAPI, il caching delle risposte, l'elaborazione asincrona con le code o l'osservabilità in produzione. Le fondamenta, però, sono ormai solide: hai un'API RESTful ben strutturata, sicura e verificata dai test.