Creare e gestire le API REST con Spring Boot

LT
Luca Terribili
Autore

Una volta definito il livello dei servizi, il passo successivo nello sviluppo di applicazioni con Spring Boot è quello di esporre queste funzionalità al mondo esterno tramite API REST. In altre parole, i servizi che abbiamo costruito diventano i “motori” della nostra applicazione, mentre le API REST costituiscono l’interfaccia attraverso la quale client esterni, applicazioni front-end o altri microservizi possono interagire con essi.

Le API RESTful rappresentano oggi lo standard più diffuso per la comunicazione tra sistemi distribuiti. Grazie alla loro semplicità e flessibilità, permettono di scambiare dati attraverso il protocollo HTTP utilizzando i metodi standard: GET, POST, PUT, PATCH, DELETE. In combinazione con Spring Boot, possiamo creare rapidamente endpoint robusti, sicuri e facili da manutenere, senza dover scrivere codice boilerplate complicato.

L’adozione di un’architettura RESTful consente inoltre di rispettare alcuni principi chiave:

  • Statelessness: ogni richiesta HTTP è indipendente, semplificando la scalabilità dell’applicazione.
  • Chiarezza nella struttura delle risorse: le URL rappresentano le risorse (ad esempio /products, /orders) e non le azioni.
  • Separazione chiara dei livelli: i controller gestiscono la comunicazione con il client, i servizi gestiscono la logica di business e i repository gestiscono l’accesso ai dati.

Seguendo questo approccio, otteniamo applicazioni più modulabili, testabili e facilmente estendibili, con una divisione dei ruoli chiara e coerente tra le componenti.

Creare un controller REST

Il controller REST è la classe responsabile di ricevere le richieste HTTP, delegare il lavoro al servizio corrispondente e restituire la risposta appropriata. In Spring Boot, la classe si annota con @RestController, che è una combinazione di @Controller e @ResponseBody. Questo significa che tutti i metodi della classe restituiranno automaticamente dati serializzati in JSON, senza bisogno di configurazioni aggiuntive.

Insieme a @RestController, possiamo utilizzare @RequestMapping per definire il percorso base delle risorse gestite dal controller.

Esempio di base. Il controller non restituisce mai l'entità Product: accetta e restituisce un DTO. Esporre l'entità JPA direttamente nel JSON è una scelta che si paga subito, perché finisce per pubblicare i campi dell'entità, che cambiano quando cambia il mapping del database, e rende impossibile validare l'ingresso in modo controllato.

@RestController
@RequestMapping("/products")
public class ProductController {

    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping
    public List<ProductResponse> getAllProducts() {
        return productService.findAllProducts();
    }

    @GetMapping("/{id}")
    public ProductResponse getProductById(@PathVariable Long id) {
        return productService.findProductById(id);
    }

    @PostMapping
    public ResponseEntity<ProductResponse> createProduct(@Valid @RequestBody ProductRequest request) {
        ProductResponse created = productService.createProduct(request);

        return ResponseEntity
                .created(URI.create("/products/" + created.id()))
                .body(created);
    }

    @PutMapping("/{id}")
    public ProductResponse updateProduct(@PathVariable Long id, @Valid @RequestBody ProductRequest request) {
        return productService.updateProduct(id, request);
    }

    @PatchMapping("/{id}")
    public ProductResponse patchProduct(@PathVariable Long id, @Valid @RequestBody ProductRequest request) {
        return productService.patchProduct(id, request);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
        productService.deleteProduct(id);
        return ResponseEntity.noContent().build();
    }
}

I DTO che il controller usa in ingresso e in uscita sono semplici record:

public record ProductRequest(
        @NotBlank(message = "il nome è obbligatorio")
        @Size(max = 120, message = "il nome non può superare 120 caratteri")
        String name,

        @DecimalMin(value = "0.01", message = "il prezzo deve essere maggiore di zero")
        Double price
) {
}

public record ProductResponse(Long id, String name, Double price) {

    public static ProductResponse from(Product product) {
        return new ProductResponse(product.getId(), product.getName(), product.getPrice());
    }
}

Analisi delle annotazioni principali

  • @RestController: indica a Spring Boot che questa classe gestirà richieste REST e restituirà dati JSON.
  • @RequestMapping("/products"): definisce il percorso base per tutte le richieste a questo controller.
  • @GetMapping, @PostMapping, @PutMapping, @PatchMapping, @DeleteMapping: mappano i metodi HTTP sui metodi della classe.
  • @PathVariable: consente di catturare valori dalla URL, ad esempio l’ID di una risorsa.
  • @RequestBody: indica che il corpo della richiesta HTTP deve essere deserializzato in un oggetto Java.
  • @Valid: attiva la validazione Bean Validation sul DTO ricevuto. Senza @Valid le annotazioni di vincolo non vengono valutate e la richiesta arriva al servizio con dati non controllati.

Due dettagli del controller che vale la pena notare. Il POST restituisce 201 Created con lintestazione Location che punta alla risorsa appena creata, come promesso tra le buone pratiche. Il PATCH è implementato davvero, con @PatchMapping, accettando solo i campi da modificare. Infine, il prodotto inesistente non è un caso speciale da gestire nel controller: il servizio solleva ProductNotFoundException e il gestore globale la traduce in 404.

Validazione dei dati di ingresso

Le annotazioni @NotBlank, @Size e @DecimalMin fanno parte di Jakarta Bean Validation, il pacchetto jakarta.validation. Dal punto di ripartenza importante: con Spring Boot 3.x e successivi il namespace corretto è jakarta.validation, non javax.validation, che era quello di Spring Boot 2.x e non è più supportato. Le due dipendenze si dichiarano così:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Lo starter importa la API Jakarta Validation e un provider, per esempio Hibernate Validator. Se nel progetto manca, @Valid non fallisce con un errore evidente: semplicemente non valida niente e ogni richiesta passa.

Quando la validazione fallisce Spring solleva MethodArgumentNotValidException, e il gestore globale la trasforma in un 400 Bad Request con l'elenco dei campi non validi.

Gestione delle eccezioni e risposta coerente

In un’applicazione reale, non tutte le richieste avranno successo. È quindi fondamentale gestire correttamente le eccezioni e restituire messaggi chiari e coerenti al client. Spring Boot mette a disposizione il meccanismo di @RestControllerAdvice per centralizzare la gestione degli errori. @RestControllerAdvice è la variante di @ControllerAdvice che serializza automaticamente in JSON le risposte.

@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(ProductNotFoundException.class)
    public ResponseEntity<ApiError> handleProductNotFound(ProductNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(ApiError.of(404, "Not Found", ex.getMessage(), List.of()));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiError> handleValidation(MethodArgumentNotValidException ex) {
        List<String> details = ex.getFieldErrors().stream()
                .map(error -> error.field() + ": " + error.message())
                .toList();

        return ResponseEntity.badRequest()
                .body(ApiError.of(400, "Bad Request", "Dati di input non validi", details));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiError> handleGeneralError(Exception ex) {
        log.error("Errore non gestito durante la richiesta", ex);

        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(ApiError.of(
                        500,
                        "Internal Server Error",
                        "Si è verificato un errore interno",
                        List.of()
                ));
    }
}

Il corpo della risposta è sempre lo stesso record, così il client trova gli stessi campi in ogni errore:

public record ApiError(
        int status,
        String error,
        String message,
        List<String> details,
        Instant timestamp
) {

    public static ApiError of(int status, String error, String message, List<String> details) {
        return new ApiError(status, error, message, details, Instant.now());
    }
}

La distinzione importante è fra i due gestori finali. Su ProductNotFoundException ha senso restituire ex.getMessage(): il messaggio è stato scritto apposta per il client. Sul gestore generico no. Il testo di un’eccezione non prevista contiene quasi sempre dettagli interni, nomi di host, frammenti di SQL o stack trace, che è esattamente il tipo di informazione che non va verso l’esterno. Per questo l’eccezione va registrata nei log con la sua stack trace e al client si restituisce un messaggio controllato e generico.

In questo modo, tutte le eccezioni sollevate dai servizi vengono intercettate e trasformate in risposte HTTP significative, migliorando l’esperienza del client e la manutenibilità del codice.

Gestione dei parametri e query string

Spesso le API REST devono ricevere parametri dinamici per filtrare, ordinare o cercare risorse. Spring Boot offre strumenti semplici per gestire questi scenari:

@GetMapping("/search")
public List<ProductResponse> searchProducts(@RequestParam String name, @RequestParam(required = false) Double maxPrice) {
    return productService.searchByNameAndPrice(name, maxPrice);
}
  • @RequestParam permette di leggere parametri dalla query string.
  • Il parametro required = false indica che il parametro non è obbligatorio.

Questa gestione è essenziale per creare API flessibili e adatte a scenari complessi senza appesantire i controller.

Buone pratiche nella progettazione delle API REST

  1. URL chiare e coerenti: usare nomi di risorse al plurale (/products, /orders) e gerarchie logiche.
  2. Metodi HTTP coerenti: GET per leggere, POST per creare, PUT/PATCH per aggiornare, DELETE per rimuovere.
  3. Status code significativi: 200 OK per successo, 201 Created per nuove risorse, 404 Not Found per risorse mancanti, 400 Bad Request per errori di input.
  4. JSON coerente: risposte consistenti, evitando campi null o non necessari.
  5. Validazione dei dati: usare @Valid insieme a Bean Validation (jakarta.validation da Spring Boot 3 in poi) per garantire integrità dei dati in ingresso.
  6. Non esporre le entità: scambiare DTO, non oggetti JPA, così il contratto pubblico non dipende dal mapping del database.
  7. Non perdere i dettagli nei log: i messaggi di errore per il client restano generici, mentre la causa reale va scritta nel log con la stack trace.

Test dell'API

Un controller con DTO, validazione e gestione degli errori ha un contratto preciso, e il modo più economico per verificarlo è @WebMvcTest: carica solo il layer web, senza database, e ti permette di provare le risposte HTTP reali.

@WebMvcTest(ProductController.class)
class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private ProductService productService;

    @Test
    void creaProdottoRestituisce201ConLocation() throws Exception {
        given(productService.createProduct(any(ProductRequest.class)))
                .willReturn(new ProductResponse(1L, "Tastiera", 49.90));

        mockMvc.perform(post("/products")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"name":"Tastiera","price":49.90}
                                """))
                .andExpect(status().isCreated())
                .andExpect(header().string("Location", "/products/1"));

        verify(productService).createProduct(any(ProductRequest.class));
    }

    @Test
    void nomeVuotoRestituisce400() throws Exception {
        mockMvc.perform(post("/products")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"name":"","price":-1}
                                """))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.details").isArray());
    }
}

Il primo test verifica il 201 Created e l'intestazione Location; il secondo verifica che la validazione produca un 400 con l'elenco dei problemi. Entrambi fallirebbero se @Valid o il gestore delle eccezioni venissero rimossi.

Una nota di versione: da Spring Boot 3.4 @MockBean è deprecato in favore di @MockitoBean, che fa la stessa cosa. Su versioni più recenti conviene usare il nuovo.

Conclusione

Creare API REST in Spring Boot è un passo naturale dopo aver implementato il livello dei servizi. I controller REST, combinati con servizi ben strutturati e repository affidabili, costituiscono la base di applicazioni moderne, scalabili e facili da manutenere. La chiara separazione dei livelli consente di concentrarsi sulla logica di business senza compromettere la leggibilità, la testabilità e la qualità del codice.

Con una solida base di servizi e controller REST, sei pronto per affrontare step successivi come autenticazione e autorizzazione, documentazione delle API con Swagger/OpenAPI, e gestione avanzata delle query e dei filtri, rendendo la tua applicazione robusta e pronta per il mondo reale.