Tratamento de erros personalizados para APIs rest com Spring Boot.

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:

image

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:

image

E por fim segue os prints dos testes:

Erros de validação:

Erros de negócio:

image

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.

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *