Tratamento de erros personalizados para APIs rest com Spring Boot.

ledsonsilva

Ledson Oliveira da Silva

Posted on December 20, 2020

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>
Enter fullscreen mode Exit fullscreen mode

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());
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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!");
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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:
image

Erros de negócio:
image

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.

Por enquanto é isso galera!! esta é a forma com que gosto de organizar meus projetos em relação aos erros de API e espero que tenham gostado da implementação.

Todos os arquivos citados no post pode ser encontrado em meu github.

💖 💪 🙅 🚩
ledsonsilva
Ledson Oliveira da Silva

Posted on December 20, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related