Hello guys! Aqui quem vos escreve é o Ledson Silva, desenvolvedor de software desde meados de 2014 e o objetivo desta publicação é demonstrar como faço para implementar tratamento de erros personalizados para api rest no spring boot.
Atualmente o spring tem uma resposta genérica para caso de erros e que na maioria das vezes queremos devolver para os clients uma resposta mais elaborada, que tenha mais informações e principalmente quando estamos trabalhando com javax validation elaborar uma resposta elegante.
Ah!!! e além de implementar uma resposta personalizada, também vou mostrar como faço para organizar minhas exceptions e as mensagens de erros que minhas apis devolvem de resposta.
Vamos lá!!
OBS: Neste exemplo estou usando Java 11 e nosso projeto spring tem as seguintes dependencias:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.5</version>
</dependency>
Passo 01: No primeiro passo iremos criar dois arquivos, onde será colocado nossas mensagens de erros. Faremos algo com esta estrutura dentro do pacote resources:
No arquivo business.properties
concentraremos as mensagems de regras de negócio, e no arquivo validation.properties
serão as mensagens de validações de dados.
Passo 02: Criar o arquivo de configuração para o spring recuperar estes arquivos de mensagens, no caso iremos apenas utilizar o esquema de internacionalização do spring.
MessageConfiguration.java
@Configuration
public class MessageConfiguration implements WebMvcConfigurer {
@Bean
public MessageSource messageSource() {
ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
messageSource.setBasenames("classpath:/messages/business/business", "classpath:/messages/validation/validation");
messageSource.setDefaultEncoding("UTF-8");
return messageSource;
}
@Bean
public LocalValidatorFactoryBean validator(MessageSource messageSource) {
LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean();
bean.setValidationMessageSource(messageSource);
return bean;
}
@Bean
public LocaleResolver localeResolver() {
SessionLocaleResolver localeResolver = new SessionLocaleResolver();
localeResolver.setDefaultLocale(Locale.US);
return localeResolver;
}
@Bean
public LocaleChangeInterceptor localeChangeInterceptor() {
LocaleChangeInterceptor localeChangeInterceptor = new LocaleChangeInterceptor();
localeChangeInterceptor.setParamName("lang");
return localeChangeInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localeChangeInterceptor());
}
}
Passo 03: Criar a estrutura dos erros personalizados, no caso os dtos que retornaremos em nossa api:
ErrorDTO.java
public class ErrorDTO {
private String key;
private String message;
public ErrorDTO() {
}
public ErrorDTO(String key, String message) {
this.key = key;
this.message = message;
}
... getters and setters
}
ApiErrorDTO.java
public class ApiErrorDTO {
private Date timestamp;
private Integer status;
private String code;
private Set<ErrorDTO> errors;
public ApiErrorDTO() {
}
public ApiErrorDTO(Date timestamp, Integer status, String code, Set<ErrorDTO> errors) {
this.timestamp = timestamp;
this.status = status;
this.code = code;
this.errors = errors;
}
... getters and setters
}
Passo 04: No quarto passo criaremos nossa exception base, que utilizaremos para estende-las ao criar nossas exceptions de negócio.
MessageException.java
public interface MessageException {
String getExceptionKey();
Map<String, Object> getMapDetails();
}
BaseRuntimeException.java
public abstract class BaseRuntimeException extends RuntimeException implements MessageException {
private final Map<String, Object> mapDetails;
public BaseRuntimeException() {
mapDetails = null;
}
public BaseRuntimeException(final Map<String, Object> mapDetails) {
this.mapDetails = mapDetails;
}
public abstract String getExceptionKey();
public Map<String, Object> getMapDetails() {
return this.mapDetails;
}
}
Passo 05: Aqui está o pulo do gato!! criaremos a nossa clase que será responsável por interceptar as exceptions e transforma-las em nossa resposta personalizada conforme os dtos que criamos anteriormente.
ExceptionHandlerAdvice.java
@ControllerAdvice
public class ExceptionHandlerAdvice {
private static final String UNKNOWN_ERROR_KEY = "unknown.error";
private static final Logger logger = LoggerFactory.getLogger(ExceptionHandlerAdvice.class);
private final MessageSource messageSource;
public ExceptionHandlerAdvice(MessageSource messageSource) {
this.messageSource = messageSource;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiErrorDTO> handlerMethodArgumentNotValid(
MethodArgumentNotValidException exception
) {
logger.error("Exception {}, Message: {}", exception.getClass().getName(), exception.getMessage());
Set<ErrorDTO> errors = exception.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> buildError(error.getCode(), error.getDefaultMessage()))
.collect(Collectors.toSet());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(baseErrorBuilder(HttpStatus.BAD_REQUEST, errors));
}
@ExceptionHandler(BaseRuntimeException.class)
public ResponseEntity<ApiErrorDTO> handlerBaseException(Throwable exception) {
logger.error("Exception {}", exception.getClass().getName());
MessageException messageException = (MessageException) exception;
ErrorDTO error = buildError(messageException.getExceptionKey(),
bindExceptionKeywords(messageException.getMapDetails(),messageException.getExceptionKey()));
Set<ErrorDTO> errors = Set.of(error);
ApiErrorDTO apiErrorDto = baseErrorBuilder(getResponseStatus(exception), errors);
return ResponseEntity
.status(getResponseStatus(exception))
.body(apiErrorDto);
}
@ExceptionHandler(Throwable.class)
public ResponseEntity<ApiErrorDTO> handlerMethodThrowable(Throwable exception) {
logger.error("Exception {}, Message: {}", exception.getClass().getName(), exception.getMessage());
Set<ErrorDTO> errors = Set.of(buildError(UNKNOWN_ERROR_KEY, exception.getMessage()));
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(baseErrorBuilder(HttpStatus.INTERNAL_SERVER_ERROR, errors));
}
private ErrorDTO buildError(String code, String message) {
return new ErrorDTO(code, message);
}
private ApiErrorDTO baseErrorBuilder(HttpStatus httpStatus, Set<ErrorDTO> errorList) {
return new ApiErrorDTO(
new Date(),
httpStatus.value(),
httpStatus.name(),
errorList);
}
private String bindExceptionKeywords(Map<String, Object> keywords, String exceptionKey) {
String message = messageSource.getMessage(exceptionKey, null, LocaleContextHolder.getLocale());
return Objects.nonNull(keywords) ? new StrSubstitutor(keywords).replace(message) : message;
}
private HttpStatus getResponseStatus(Throwable exception) {
ResponseStatus responseStatus = exception.getClass().getAnnotation(ResponseStatus.class);
if (exception.getClass().getAnnotation(ResponseStatus.class) == null) {
return HttpStatus.INTERNAL_SERVER_ERROR;
}
return responseStatus.value();
}
}
Isso já é suficiente para transformar nossas exceptions em nossa resposta personalizada. E agora iremos criar uma estrutura com uma Controller e um DTO para requests, onde teremos dois endpoints POST
mesmo um para exemplificar erros de validação e outro para erros de negócio!
ExampleDTO.java
public class ExampleDTO {
@NotNull(message = "{required.validation}")
private Long id;
@NotBlank(message = "{required.validation}")
@Size(min = 4, max = 30, message = "{size.validation}")
private String name;
public ExampleDTO() {
}
public ExampleDTO(Long id, String name) {
this.id = id;
this.name = name;
}
... getters and setters
}
ExampleExceptionController.java
@RestController
@RequestMapping(path = "custom-exception-example")
public class ExampleExceptionController {
@PostMapping(path = "validation")
public ResponseEntity<ExampleDTO> exampleModelValidationEndpoint(@Validated @RequestBody ExampleDTO dto) {
return ResponseEntity.ok(dto);
}
@PostMapping(path = "business")
public ResponseEntity<String> exampleBusinessValidationEndpoint(@Validated @RequestBody ExampleDTO dto) {
if (dto.getName().equalsIgnoreCase("params")) {
throw new ExampleNameRuleWithParamsException("params");
}
if (!dto.getName().equalsIgnoreCase("business")) {
throw new ExampleNameRuleException();
}
return ResponseEntity.ok("Success!");
}
}
E por último e não menos importante são as mensagens nos arquivos que criamos no primeiro passo.
business.properties
example.name.rule=No campo name somente é permitido o valor: business
example.name.rule.with.params=Não é permitido digitar o valor: ´${value}´ no campo name
validation.properties
required.validation=Existem campos obrigatórios que não foram preenchidos
size.validation=Tamanho inválido! Digite no mínimo {min} e no máximo {max} caracteres
Só uma explicação!! Em nosso DTO de exemplo quando temos uma anotação de validation, por exemplo @NotBlank
é necessário definir a chave na qual irá representar em seu arquivo de mensagem por exemplo:
@NotNull(message = "{required.validation}")
E quando for exceptions de negócio, no qual precisaremos criar uma exception que estende de BaseRuntimeException.java
a mesma nos obriga a implementar o método String getExceptionKey()
, e será neste método que retornaremos nossa chave correspondente em nosso arquivo business.properties
.
Exemplo:
ExampleNameRuleException.java
@ResponseStatus(HttpStatus.BAD_REQUEST)
public class ExampleNameRuleException extends BaseRuntimeException {
private static final String KEY = "example.name.rule";
public ExampleNameRuleException() {
super();
}
@Override
public String getExceptionKey() {
return KEY;
}
}
ExampleNameRuleWithParamsException.java
@ResponseStatus(HttpStatus.BAD_REQUEST)
public class ExampleNameRuleWithParamsException extends BaseRuntimeException {
private static final String KEY = "example.name.rule.with.params";
public ExampleNameRuleWithParamsException(String value) {
super(Map.of("value", value));
}
@Override
public String getExceptionKey() {
return KEY;
}
}
Entendido??!!! se não deixa um comentário aqui que trocaremos uma idéia.
Beleza galera!!! temos nossa implementação, e no final meu projeto ficou com esta estrutura:
E por fim segue os prints dos testes:
Porque eu gosto desta abordagem?? cara desta forma eu consigo ter um padrão de resposta de erros para minha api
, tanto para erros de validações, obrigatoriedade etc.. quanto para erros de negócio!! além de também separar as mensagens de erros em arquivos especificos e com chaves que podem ser reutilizadas pelo sistema!!! quando precisar mudar uma mensagem de erro não preciso dar um ctrl + f
no projeto e sair alterando em vários arquivos e códigos… fica concentrado apenas nos arquivos das mensagens.