Gestione personalizzata dei messaggi di errore per l'API REST

REST Top

Ho appena annunciato il nuovo corso Learn Spring , incentrato sui fondamenti di Spring 5 e Spring Boot 2:

>> SCOPRI IL CORSO

1. Panoramica

In questo tutorial, discuteremo come implementare un gestore di errori globale per un'API REST di Spring.

Useremo la semantica di ogni eccezione per costruire messaggi di errore significativi per il cliente, con il chiaro obiettivo di fornire a quel cliente tutte le informazioni per diagnosticare facilmente il problema.

2. Un messaggio di errore personalizzato

Cominciamo implementando una struttura semplice per inviare errori in rete: l' ApiError :

public class ApiError { private HttpStatus status; private String message; private List errors; public ApiError(HttpStatus status, String message, List errors) { super(); this.status = status; this.message = message; this.errors = errors; } public ApiError(HttpStatus status, String message, String error) { super(); this.status = status; this.message = message; errors = Arrays.asList(error); } }

Le informazioni qui dovrebbero essere chiare:

  • status : il codice di stato HTTP
  • messaggio : il messaggio di errore associato all'eccezione
  • errore : elenco dei messaggi di errore creati

E, naturalmente, per l'effettiva logica di gestione delle eccezioni in Spring, useremo l' annotazione @ControllerAdvice :

@ControllerAdvice public class CustomRestExceptionHandler extends ResponseEntityExceptionHandler { ... }

3. Gestire le eccezioni di richieste non valide

3.1. Gestire le eccezioni

Ora, vediamo come possiamo gestire gli errori del client più comuni, in pratica gli scenari di un client che hanno inviato una richiesta non valida all'API:

  • BindException : questa eccezione viene generata quando si verificano errori irreversibili di associazione.
  • MethodArgumentNotValidException : questa eccezione viene generata quando l'argomento annotato con @Valid non è riuscito a convalidare:

@Override protected ResponseEntity handleMethodArgumentNotValid( MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatus status, WebRequest request) { List errors = new ArrayList(); for (FieldError error : ex.getBindingResult().getFieldErrors()) { errors.add(error.getField() + ": " + error.getDefaultMessage()); } for (ObjectError error : ex.getBindingResult().getGlobalErrors()) { errors.add(error.getObjectName() + ": " + error.getDefaultMessage()); } ApiError apiError = new ApiError(HttpStatus.BAD_REQUEST, ex.getLocalizedMessage(), errors); return handleExceptionInternal( ex, apiError, headers, apiError.getStatus(), request); } 

Come puoi vedere, stiamo sovrascrivendo un metodo di base da ResponseEntityExceptionHandler e fornendo la nostra implementazione personalizzata .

Non è sempre così: a volte avremo bisogno di gestire un'eccezione personalizzata che non ha un'implementazione predefinita nella classe base, come vedremo più avanti qui.

Il prossimo:

  • MissingServletRequestPartException : questa eccezione viene generata quando la parte di una richiesta multipart non viene trovata

  • MissingServletRequestParameterException : questa eccezione viene generata quando si richiede il parametro mancante:

@Override protected ResponseEntity handleMissingServletRequestParameter( MissingServletRequestParameterException ex, HttpHeaders headers, HttpStatus status, WebRequest request) { String error = ex.getParameterName() + " parameter is missing"; ApiError apiError = new ApiError(HttpStatus.BAD_REQUEST, ex.getLocalizedMessage(), error); return new ResponseEntity( apiError, new HttpHeaders(), apiError.getStatus()); }
  • ConstrainViolationException : questa eccezione segnala il risultato delle violazioni dei vincoli:

@ExceptionHandler({ ConstraintViolationException.class }) public ResponseEntity handleConstraintViolation( ConstraintViolationException ex, WebRequest request) { List errors = new ArrayList(); for (ConstraintViolation violation : ex.getConstraintViolations()) { errors.add(violation.getRootBeanClass().getName() + " " + violation.getPropertyPath() + ": " + violation.getMessage()); } ApiError apiError = new ApiError(HttpStatus.BAD_REQUEST, ex.getLocalizedMessage(), errors); return new ResponseEntity( apiError, new HttpHeaders(), apiError.getStatus()); }
  • TypeMismatchException : questa eccezione viene generata quando si tenta di impostare la proprietà del bean con un tipo errato.

  • MethodArgumentTypeMismatchException : questa eccezione viene generata quando l'argomento del metodo non è il tipo previsto:

@ExceptionHandler({ MethodArgumentTypeMismatchException.class }) public ResponseEntity handleMethodArgumentTypeMismatch( MethodArgumentTypeMismatchException ex, WebRequest request) { String error = ex.getName() + " should be of type " + ex.getRequiredType().getName(); ApiError apiError = new ApiError(HttpStatus.BAD_REQUEST, ex.getLocalizedMessage(), error); return new ResponseEntity( apiError, new HttpHeaders(), apiError.getStatus()); }

3.2. Consumare l'API dal client

Diamo ora un'occhiata a un test che viene eseguito in un MethodArgumentTypeMismatchException : invieremo una richiesta con id come String invece che long :

@Test public void whenMethodArgumentMismatch_thenBadRequest() { Response response = givenAuth().get(URL_PREFIX + "/api/foos/ccc"); ApiError error = response.as(ApiError.class); assertEquals(HttpStatus.BAD_REQUEST, error.getStatus()); assertEquals(1, error.getErrors().size()); assertTrue(error.getErrors().get(0).contains("should be of type")); }

E infine, considerando questa stessa richiesta:

Request method: GET Request path: //localhost:8080/spring-security-rest/api/foos/ccc 

Ecco come apparirà questo tipo di risposta all'errore JSON:

{ "status": "BAD_REQUEST", "message": "Failed to convert value of type [java.lang.String] to required type [java.lang.Long]; nested exception is java.lang.NumberFormatException: For input string: \"ccc\"", "errors": [ "id should be of type java.lang.Long" ] }

4. Gestire NoHandlerFoundException

Successivamente, possiamo personalizzare il nostro servlet per lanciare questa eccezione invece di inviare la risposta 404, come segue:

 api  org.springframework.web.servlet.DispatcherServlet  throwExceptionIfNoHandlerFound true  

Quindi, una volta che ciò accade, possiamo semplicemente gestirlo come qualsiasi altra eccezione:

@Override protected ResponseEntity handleNoHandlerFoundException( NoHandlerFoundException ex, HttpHeaders headers, HttpStatus status, WebRequest request) { String error = "No handler found for " + ex.getHttpMethod() + " " + ex.getRequestURL(); ApiError apiError = new ApiError(HttpStatus.NOT_FOUND, ex.getLocalizedMessage(), error); return new ResponseEntity(apiError, new HttpHeaders(), apiError.getStatus()); }

Ecco un semplice test:

@Test public void whenNoHandlerForHttpRequest_thenNotFound() { Response response = givenAuth().delete(URL_PREFIX + "/api/xx"); ApiError error = response.as(ApiError.class); assertEquals(HttpStatus.NOT_FOUND, error.getStatus()); assertEquals(1, error.getErrors().size()); assertTrue(error.getErrors().get(0).contains("No handler found")); }

Diamo uno sguardo alla richiesta completa:

Request method: DELETE Request path: //localhost:8080/spring-security-rest/api/xx

E la risposta JSON di errore:

{ "status":"NOT_FOUND", "message":"No handler found for DELETE /spring-security-rest/api/xx", "errors":[ "No handler found for DELETE /spring-security-rest/api/xx" ] }

5. Gestire HttpRequestMethodNotSupportedException

Successivamente, diamo un'occhiata a un'altra interessante eccezione, la HttpRequestMethodNotSupportedException , che si verifica quando invii una richiesta con un metodo HTTP non supportato:

@Override protected ResponseEntity handleHttpRequestMethodNotSupported( HttpRequestMethodNotSupportedException ex, HttpHeaders headers, HttpStatus status, WebRequest request) { StringBuilder builder = new StringBuilder(); builder.append(ex.getMethod()); builder.append( " method is not supported for this request. Supported methods are "); ex.getSupportedHttpMethods().forEach(t -> builder.append(t + " ")); ApiError apiError = new ApiError(HttpStatus.METHOD_NOT_ALLOWED, ex.getLocalizedMessage(), builder.toString()); return new ResponseEntity( apiError, new HttpHeaders(), apiError.getStatus()); }

Ecco un semplice test che riproduce questa eccezione:

@Test public void whenHttpRequestMethodNotSupported_thenMethodNotAllowed() { Response response = givenAuth().delete(URL_PREFIX + "/api/foos/1"); ApiError error = response.as(ApiError.class); assertEquals(HttpStatus.METHOD_NOT_ALLOWED, error.getStatus()); assertEquals(1, error.getErrors().size()); assertTrue(error.getErrors().get(0).contains("Supported methods are")); }

Ed ecco la richiesta completa:

Request method: DELETE Request path: //localhost:8080/spring-security-rest/api/foos/1

E la risposta JSON di errore:

{ "status":"METHOD_NOT_ALLOWED", "message":"Request method 'DELETE' not supported", "errors":[ "DELETE method is not supported for this request. Supported methods are GET " ] }

6. Gestire HttpMediaTypeNotSupportedException

Ora, gestiamo HttpMediaTypeNotSupportedException , che si verifica quando il client invia una richiesta con un tipo di supporto non supportato, come segue:

@Override protected ResponseEntity handleHttpMediaTypeNotSupported( HttpMediaTypeNotSupportedException ex, HttpHeaders headers, HttpStatus status, WebRequest request) { StringBuilder builder = new StringBuilder(); builder.append(ex.getContentType()); builder.append(" media type is not supported. Supported media types are "); ex.getSupportedMediaTypes().forEach(t -> builder.append(t + ", ")); ApiError apiError = new ApiError(HttpStatus.UNSUPPORTED_MEDIA_TYPE, ex.getLocalizedMessage(), builder.substring(0, builder.length() - 2)); return new ResponseEntity( apiError, new HttpHeaders(), apiError.getStatus()); }

Ecco un semplice test su questo problema:

@Test public void whenSendInvalidHttpMediaType_thenUnsupportedMediaType() { Response response = givenAuth().body("").post(URL_PREFIX + "/api/foos"); ApiError error = response.as(ApiError.class); assertEquals(HttpStatus.UNSUPPORTED_MEDIA_TYPE, error.getStatus()); assertEquals(1, error.getErrors().size()); assertTrue(error.getErrors().get(0).contains("media type is not supported")); }

Infine, ecco una richiesta di esempio:

Request method: POST Request path: //localhost:8080/spring-security- Headers: Content-Type=text/plain; charset=ISO-8859-1

E la risposta JSON di errore:

{ "status":"UNSUPPORTED_MEDIA_TYPE", "message":"Content type 'text/plain;charset=ISO-8859-1' not supported", "errors":["text/plain;charset=ISO-8859-1 media type is not supported. Supported media types are text/xml application/x-www-form-urlencoded application/*+xml application/json;charset=UTF-8 application/*+json;charset=UTF-8 */" ] }

7. Gestore predefinito

Infine, implementiamo un gestore di fall-back, un tipo di logica catch-all che si occupa di tutte le altre eccezioni che non hanno gestori specifici:

@ExceptionHandler({ Exception.class }) public ResponseEntity handleAll(Exception ex, WebRequest request) { ApiError apiError = new ApiError( HttpStatus.INTERNAL_SERVER_ERROR, ex.getLocalizedMessage(), "error occurred"); return new ResponseEntity( apiError, new HttpHeaders(), apiError.getStatus()); }

8. Conclusione

La creazione di un gestore di errori appropriato e maturo per un'API REST di Spring è un processo difficile e decisamente iterativo. Si spera che questo tutorial sia un buon punto di partenza per farlo per la tua API e anche un buon punto di riferimento per come dovresti guardare per aiutare i clienti della tua API a diagnosticare rapidamente e facilmente gli errori e superarli.

L' implementazione completa di questo tutorial può essere trovata nel progetto Github: questo è un progetto basato su Eclipse, quindi dovrebbe essere facile da importare ed eseguire così com'è.

REST fondo

Ho appena annunciato il nuovo corso Learn Spring , incentrato sui fondamenti di Spring 5 e Spring Boot 2:

>> SCOPRI IL CORSO