Pollito Dev
October 4, 2024

Pollito's Opinion on Spring Boot Development 5: Configuration of feignClient interfaces

Posted on October 4, 2024  •  6 minutes  • 1126 words  • Other languages:  Español

Some context

This is the fifth part of the Spring Boot Development blog series.

So far we created:

In this blog we are going to create the configuration needed to use the UsersApi. Let’s start!

Roadmap

Because feignClient interfaces are a declarative approach to make REST API calls, a lot of configuration is needed before being able to use them.

Some of these steps could be skipped in favour of a simpler approach. But because this is Pollito’s opinion, things are going to be made as how I consider them correct.

This blog is going to be a long one… Let’s start!

1. Create a new Exception

exception/JsonPlaceholderException.java

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Getter
public class JsonPlaceholderException extends RuntimeException{
  private final int status;
}

There’s no need to create fields in the class, it could be empty. But here are some things that can be helpful down the road:

Here I show an example of an Exception class that has an error field.

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import moe.jikan.models.Error; // <-- Generated by an openapi-generator-maven-plugin execution task

@RequiredArgsConstructor
@Getter
public class JikanException extends RuntimeException {
  private final transient Error error;
}

2. Handle the new created Exception

What NOT to do

Unless you have business logic that implies you have to do something when the REST API call fails (or another very good reason), always let the Exception propagate.

Don’t do this:

SomeObject foo(){
  try{
    //business code
    Something something = someClient.getSomething();
    //more business code and eventually return SomeObject
  }catch(Exception e){
    return null;
  }
}

For more info on why that is bad, I recommend this article on Fast Fail exception handling

What to do

Let the @RestControllerAdvice class take care of the propagated exception.

Once here you have two options:

  1. If you don’t care at all and is ok for it to be a 500 INTERNAL ERROR, then do nothing, skip to the next step.
  2. If you do care, handle the Exception.

Let’s go for scenario 2.

controller/advice/GlobalControllerAdvice.java

import dev.pollito.user_manager_backend.exception.JsonPlaceholderException;
import io.opentelemetry.api.trace.Span;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;

@RestControllerAdvice
@Slf4j
public class GlobalControllerAdvice {

  @ExceptionHandler(NoResourceFoundException.class)
  public ProblemDetail handle(@NotNull NoResourceFoundException e) {
    return buildProblemDetail(e, HttpStatus.NOT_FOUND);
  }

  @ExceptionHandler(JsonPlaceholderException.class)
  public ProblemDetail handle(@NotNull JsonPlaceholderException e) {
    return buildProblemDetail(
        e, e.getStatus() == 400 ? HttpStatus.BAD_REQUEST : HttpStatus.INTERNAL_SERVER_ERROR);
  }

  @ExceptionHandler(Exception.class)
  public ProblemDetail handle(@NotNull Exception e) {
    return buildProblemDetail(e, HttpStatus.INTERNAL_SERVER_ERROR);
  }

  @NotNull
  private static ProblemDetail buildProblemDetail(@NotNull Exception e, HttpStatus status) {
    String exceptionSimpleName = e.getClass().getSimpleName();
    log.error("{} being handled", exceptionSimpleName, e);
    ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(status, e.getLocalizedMessage());
    problemDetail.setTitle(exceptionSimpleName);
    problemDetail.setProperty("timestamp", DateTimeFormatter.ISO_INSTANT.format(Instant.now()));
    problemDetail.setProperty("trace", Span.current().getSpanContext().getTraceId());
    return problemDetail;
  }
}

3. Create an Error Decoder implementation

This is the simplest an Error Decoder implementation can be:

errordecoder/JsonPlaceholderErrorDecoder.java

import dev.pollito.user_manager_backend.exception.JsonPlaceholderException;
import feign.Response;
import feign.codec.ErrorDecoder;
import org.jetbrains.annotations.NotNull;

public class JsonPlaceholderErrorDecoder implements ErrorDecoder {
  @Override
  public Exception decode(String s, @NotNull Response response) {
    return new JsonPlaceholderException(response.status());
  }
}

You can get as creative as your business logic needs.

Here is an example of a more complex Error Decoder implementation. The error that you get from the REST API call gets mapped into an Error class that is part of an Exception, so it can be used somewhere else (most probably a @RestControllerAdvice class).

import com.fasterxml.jackson.databind.ObjectMapper;
import dev.pollito.user_manager_backend.exception.JikanException;
import feign.Response;
import feign.codec.ErrorDecoder;
import java.io.IOException;
import java.io.InputStream;
import moe.jikan.models.Error; // <-- Generated by an openapi-generator-maven-plugin execution task

public class JikanErrorDecoder implements ErrorDecoder {
  @Override
  public Exception decode(String s, Response response) {
    try (InputStream body = response.body().asInputStream()) {
      return new JikanException(new ObjectMapper().readValue(body, Error.class));
    } catch (IOException e) {
      return new Default().decode(s, response);
    }
  }
}

4. Add the URL value in application.yml

If by now you haven’t renamed application.properties, rename it to application.yml.

src/main/resources/application-dev.yml

jsonplaceholder:
  baseUrl: https://jsonplaceholder.typicode.com/
spring:
  application:
    name: user_manager_backend #name of your application here

5. Create a @ConfigurationProperties class

config/properties/JsonPlaceholderConfigProperties.java

import lombok.AccessLevel;
import lombok.Data;
import lombok.experimental.FieldDefaults;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Configuration
@ConfigurationProperties(prefix = "jsonplaceholder")
@Data
@FieldDefaults(level = AccessLevel.PRIVATE)
public class JsonPlaceholderConfigProperties {
  String baseUrl;
}

6. Configure the feignClient

api/config/JsonPlaceholderApiConfig.java

import com.typicode.jsonplaceholder.api.UserApi; //todo: replace here
import dev.pollito.user_manager_backend.config.properties.JsonPlaceholderConfigProperties;
import dev.pollito.user_manager_backend.errordecoder.JsonPlaceholderErrorDecoder;
import feign.Feign;
import feign.Logger;
import feign.gson.GsonDecoder;
import feign.gson.GsonEncoder;
import feign.okhttp.OkHttpClient;
import feign.slf4j.Slf4jLogger;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.ComponentScans;
import org.springframework.context.annotation.Configuration;

@Configuration
@ComponentScans(
    value = {
        @ComponentScan(
            basePackages = {
                "com.typicode.jsonplaceholder.api", //todo: replace here
            })
    })
@RequiredArgsConstructor
public class JsonPlaceholderApiConfig {
  private final JsonPlaceholderConfigProperties jsonPlaceholderConfigProperties;

  @Bean
  public UserApi userApi(){ //todo: replace here
    return Feign.builder()
        .client(new OkHttpClient())
        .encoder(new GsonEncoder())
        .decoder(new GsonDecoder())
        .errorDecoder(new JsonPlaceholderErrorDecoder())
        .logger(new Slf4jLogger(UserApi.class)) //todo: replace here
        .logLevel(Logger.Level.FULL)
        .target(UserApi.class, jsonPlaceholderConfigProperties.getBaseUrl()); //todo: replace here
  }
}

Replace the marked values using this image as a guide: Screenshot2024-10-03232447

7. Create a new @Pointcut

Let’s log whatever goes in and out of the feignClient interface.

To do that:

  1. Create a new @Pointcut that matches the feignClient you are interested in.
  2. Add the Pointcut to the @Before and @AfterReturning methods.

After both steps, the class should look something like this:

aspect/LogAspect.java

import java.util.Arrays;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.jetbrains.annotations.NotNull;
import org.springframework.stereotype.Component;

@Aspect
@Component
@Slf4j
public class LogAspect {

  @Pointcut("execution(public * dev.pollito.user_manager_backend.controller..*.*(..))")
  public void controllerPublicMethodsPointcut() {}

  @Pointcut("execution(public * com.typicode.jsonplaceholder.api.*.*(..))")
  public void jsonPlaceholderApiMethodsPointcut() {}

  @Before("controllerPublicMethodsPointcut() || jsonPlaceholderApiMethodsPointcut()")
  public void logBefore(@NotNull JoinPoint joinPoint) {
    log.info(
        "[{}] Args: {}",
        joinPoint.getSignature().toShortString(),
        Arrays.toString(joinPoint.getArgs()));
  }

  @AfterReturning(
      pointcut = "controllerPublicMethodsPointcut() || jsonPlaceholderApiMethodsPointcut()",
      returning = "result")
  public void logAfterReturning(@NotNull JoinPoint joinPoint, Object result) {
    log.info("[{}] Response: {}", joinPoint.getSignature().toShortString(), result);
  }
}

Next lecture

Pollito’s Opinion on Spring Boot Development 6: Business logic

Hey, check me out!

You can find me here