A Comprehensive Guide to Implementing and Consuming REST Endpoints in Spring Boot

REST, or Representational State Transfer, is a way for applications to communicate over the internet using standard HTTP methods like GET, POST, PUT, and DELETE. It allows clients, such as web or mobile apps, and backend services to interact with server resources, which typically consist of data. Each request contains all the information needed for the server to respond, making it stateless and efficient.

This guide will first cover how to implement REST endpoints in Spring Boot and then explore how to consume them using three key tools: OpenFeign, WebClient, and RestTemplate. You’ll learn how to manage HTTP responses, send objects as response bodies, set response statuses and headers, handle exceptions at the endpoint level, and use @RequestBody to receive data from clients.

Part 1: Implementing REST Endpoints

When building REST endpoints in Spring Boot, the goal is to send data directly from the server to the client. This is a continuation of the previous post about building web applications with Spring Boot and Spring MVC. Let’s look at the basics of setting up REST controllers in Spring.

Setting Up a REST Controller

In Spring, REST endpoints return data, often as JSON, instead of rendering a view like a traditional MVC controller. To set this up, use the @RestController annotation. This annotation combines @Controller and @ResponseBody, ensuring all methods return data directly.

First, create a simple Country class:

public class Country {
    private int id;
    private String name;

    public Country(int id, String name) {
        this.id = id;
        this.name = name;
    }

    public int getId() {
        return id;
    }

    public String getName() {
        return name;
    }
}

Next, create a CountryService that provides methods to retrieve country data:

@Service
public class CountryService {
    private List<Country> countries = new ArrayList<>();

    public CountryService() {
        // Adding some sample countries
        countries.add(new Country(1, "United States"));
        countries.add(new Country(2, "Canada"));
        countries.add(new Country(3, "Mexico"));
    }

    public List<Country> getAllCountries() {
        return countries;
    }

    public Country getCountryById(int id) {
        return countries.stream()
                .filter(country -> country.getId() == id)
                .findFirst()
                .orElse(null);
    }
}

Now, you can set up the CountryController:

@RestController
public class CountryController {
   
    @Autowired
    private CountryService countryService;

    @GetMapping("/countries")
    public List<Country> getCountries() {
        return countryService.getAllCountries();
    }

    @GetMapping("/countries/{id}")
    public Country getCountryById(@PathVariable int id) {
        return countryService.getCountryById(id);
    }
}

In this example, the CountryController uses the CountryService to return JSON-formatted data. When you hit the /countries endpoint, you receive a list of countries, and for /countries/{id}, you get the specific country details based on the ID provided.

Controlling HTTP Responses

By default, Spring returns “200 OK” responses along with the data, but you can customize this using ResponseEntity. With ResponseEntity, you can control the status code, headers, and body of the response.

This code checks if the requested country exists and returns the corresponding response with the appropriate status code.

@GetMapping("/countries/{id}")
public ResponseEntity<Country> getCountry(@PathVariable int id) {
    Country country = countryService.getCountryById(id);
    if (country != null) {
        return ResponseEntity.ok(country); // Returns 200 OK
    } else {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(null); // Returns 404 Not Found
    }
}

This gives you finer control over the HTTP response, such as returning a “202 Accepted” or “404 Not Found” status if needed.

Handling Requests with @RequestBody

When a client sends data to the server, you can use @RequestBody to map the incoming JSON data to a Java object. This is useful for creating or updating resources. In this context, the Country object acts as a Data Transfer Object (DTO) used to transfer data between the client and the server.

@PostMapping("/countries")
public ResponseEntity<Country> addCountry(@RequestBody Country country) {
    countryService.addCountry(country);
    return ResponseEntity.status(HttpStatus.CREATED).body(country);
}

In this example, Spring converts the incoming JSON into a Country object. If the JSON is malformed, Spring returns a “400 Bad Request” response.

Centralized Error Handling with @RestControllerAdvice

Instead of catching exceptions in every controller method, you can use @RestControllerAdvice to centralize error handling. This allows you to manage exceptions in one place and return custom error responses. Below is an example that handles a custom exception for when a country is not found.

First, create a custom exception:

public class CountryNotFoundException extends RuntimeException {
    public CountryNotFoundException(String message) {
        super(message);
    }
}

Next, modify the CountryController to throw this exception if a country is not found:

@GetMapping("/countries/{id}")
public ResponseEntity<Country> getCountryById(@PathVariable int id) {
    Country country = countryService.getCountryById(id);
    if (country == null) {
        throw new CountryNotFoundException("Country not found with id: " + id);
    }
    return ResponseEntity.ok(country); // Returns 200 OK
}

Now, create a centralized exception handler using @RestControllerAdvice:

@RestControllerAdvice
public class GlobalExceptionHandler {
   
    @ExceptionHandler(CountryNotFoundException.class)
    public ResponseEntity<ErrorDetails> handleCountryNotFound(CountryNotFoundException ex) {
        ErrorDetails errorDetails = new ErrorDetails("Country Not Found", ex.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorDetails);
    }
}

Here’s the ErrorDetails class, which contains the error message and other details:

public class ErrorDetails {
    private String title; // Title of the error
    private String message; // Detailed error message
    // Omit getters and setters for simplicity
}

In this example, when a CountryNotFoundException is thrown, the centralized error handler returns a “404 Not Found” status along with a custom error message.

Part 2: Consuming REST Endpoints

In many cases, a backend app also needs to consume REST endpoints exposed by another app. In Spring Boot, you can do this using three main tools: OpenFeign, WebClient, and RestTemplate. For these examples, we’ll use a free online fake REST API, JSONPlaceholder, which provides fake data for testing. However, you can also create your own local REST API if you prefer.

1. OpenFeign: Simplified REST Client

OpenFeign simplifies the process of consuming REST APIs by automatically generating clients from defined interfaces. To get started, add the necessary dependency in your pom.xml:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>

Next, add the Spring Cloud BOM (Bill of Materials) to manage versions of Spring Cloud dependencies. The Spring team recommends using a BOM to ensure consistent version management and prevent conflicts among the many sub-dependencies in Spring Cloud. While the Spring Boot starter parent manages core Spring Boot dependencies, the BOM ensures that all Spring Cloud components work seamlessly together.

<properties>
    <spring-cloud.version>2023.0.2</spring-cloud.version> <!-- Use the latest version -->
</properties>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>${spring-cloud.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

If you ever need to find the correct version for Spring Cloud, the official Spring Cloud documentation is the best resource.

Next, enable OpenFeign in your main Spring Boot application class with the @EnableFeignClients annotation. This annotation tells Spring to scan for interfaces annotated with @FeignClient, allowing it to generate the necessary implementations.

@SpringBootApplication
@EnableFeignClients
public class MyApp {
    public static void main(String[] args) {
        SpringApplication.run(MyApp.class, args);
    }
}

Now, define your client interface using the @FeignClient annotation. This is how you specify the name and URL of the API you’ll be calling:

@FeignClient(name = "jsonPlaceholderClient", url = "https://jsonplaceholder.typicode.com")
public interface JsonPlaceholderClient {

    @GetMapping("/todos")
    List<Todo> getTodos();

    @GetMapping("/todos/{id}")
    Todo getTodoById(@PathVariable int id);
}

The name parameter gives your client a unique identifier, while the url parameter specifies where the API is located. The magic of OpenFeign is that Spring Cloud automatically provides the implementation for this interface. All you need to do is wire it into your application.

To handle the business logic, create a TodoService class that uses the Feign client:

@Service
public class TodoService {
   
    private final JsonPlaceholderClient jsonPlaceholderClient;

    @Autowired
    public TodoService(JsonPlaceholderClient jsonPlaceholderClient) {
        this.jsonPlaceholderClient = jsonPlaceholderClient;
    }

    public List<Todo> getAllTodos() {
        return jsonPlaceholderClient.getTodos();
    }

    public Todo getTodoById(int id) {
        return jsonPlaceholderClient.getTodoById(id);
    }
}

In this service, the jsonPlaceholderClient is injected, allowing you to call the API methods you defined in the interface. The getAllTodos() and getTodoById() methods use the client to fetch data.

To make sense of the data you expect from the API, create a Todo class that matches the structure of the JSON response. Use @JsonProperty to map the JSON fields to your class properties:

public class Todo {
    @JsonProperty("userId")
    private int userId;

    @JsonProperty("id")
    private int id;

    @JsonProperty("title")
    private String title;

    @JsonProperty("completed")
    private boolean completed;

    // Getters and Setters
}

Knowing what the data looks like beforehand helps you create a class that matches the expected structure, allowing Spring to properly serialize and deserialize the data when interacting with the API.

To expose the service methods to the clients of your application, create a new controller class:

@RestController
@RequestMapping("/api/todos")
public class TodoController {

    private final TodoService todoService;

    @Autowired
    public TodoController(TodoService todoService) {
        this.todoService = todoService;
    }

    @GetMapping
    public List<Todo> getAllTodos() {
        return todoService.getAllTodos();
    }

    @GetMapping("/{id}")
    public Todo getTodoById(@PathVariable int id) {
        return todoService.getTodoById(id);
    }
}

The @RequestMapping annotation specifies the base URL for the TodoController, which is /api/todos. This means that all methods in this controller will handle requests that start with this path. Combined with @RestController, which designates the class for handling RESTful requests and ensures the response is serialized to JSON, these annotations work together to route HTTP requests to the correct methods. 

If you are running the application locally on the default Spring Boot port 8080, you can expect to receive a list of todos from the JSONPlaceholder API by navigating to http://localhost:8080/api/todos in the browser.

2. WebClient: Reactive and Modern

WebClient is a modern, non-blocking alternative to RestTemplate, introduced in Spring 5. It’s well-suited for applications that handle multiple tasks at once, such as frequent HTTP requests, without needing to wait for each to finish before moving on. To use WebClient, add the spring-boot-starter-webflux dependency to your pom.xml:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

Unlike blocking operations, which wait for each task to complete before starting the next, non-blocking operations allow an app to keep working while awaiting responses. For instance, when a request is made using WebClient, it might trigger an I/O operation, like fetching data from an API. Instead of waiting idly for the data, the thread can switch to process a new incoming request, allowing the application to stay responsive. 

Once the original request receives its response, the response data is handled as a stream of elements in a reactive, non-blocking way. This keeps requests efficient, especially when multiple requests need to be processed concurrently, as WebClient allows each task to progress without waiting in line.

In a WebClient setup, Mono and Flux are two key types for handling responses. A Mono represents a single item, like one Todo entry, while Flux handles a stream of items, making it ideal for lists of Todos. 

Here’s an example TodoService that calls the JSONPlaceholder API with WebClient:

@Service
public class TodoService {
   
    private final WebClient webClient;

    @Autowired
    public TodoService(WebClient.Builder webClientBuilder) {
        this.webClient = webClientBuilder.baseUrl("https://jsonplaceholder.typicode.com").build();
    }

    public Mono<Todo> getTodoById(int id) {
        return webClient.get()
            .uri("/todos/{id}", id)
            .retrieve()
            .bodyToMono(Todo.class);
    }

    public Flux<Todo> getAllTodos() {
        return webClient.get()
            .uri("/todos")
            .retrieve()
            .bodyToFlux(Todo.class);
    }
}

In this example, the .retrieve() call sends the request and triggers the I/O operation, while .bodyToMono() or .bodyToFlux() prepares the response to be handled asynchronously once it arrives, allowing the application to process the response as soon as it’s ready.

We’ll also update TodoController to support the Mono and Flux responses, making the endpoints non-blocking and reactive.

@RestController
@RequestMapping("/api/todos")
public class TodoController {

    private final TodoService todoService;

    @Autowired
    public TodoController(TodoService todoService) {
        this.todoService = todoService;
    }

    @GetMapping
    public Flux<Todo> getAllTodos() {
        return todoService.getAllTodos();
    }

    @GetMapping("/{id}")
    public Mono<Todo> getTodoById(@PathVariable int id) {
        return todoService.getTodoById(id);
    }

    @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<Todo> streamTodos() {
        return todoService.getAllTodos()
                .take(5)
                .delayElements(Duration.ofSeconds(1)); // Simulate a 1-second delay for each Todo
    }
}

In this code snippet, when a user accesses the /api/todos endpoint, it returns all Todo items at once after fetching them from the API. In contrast, the /api/todos/stream endpoint uses delayElements() to emit each of the first five Todos with a 1-second interval. This non-blocking approach allows the application to provide data incrementally, enhancing responsiveness for the user.

The produces = MediaType.TEXT_EVENT_STREAM_VALUE in /api/todos/stream indicates that the endpoint returns a stream of data formatted as Server-Sent Events (SSE). This allows the client to receive updates in real time, which is especially useful for applications that require continuous data flow without refreshing the page.

3. RestTemplate: Traditional but Being Phased Out

RestTemplate is a widely used tool for making blocking HTTP calls. While it remains popular, it is currently in maintenance mode, and Spring recommends using WebClient for new projects. Nonetheless, RestTemplate can still be useful in many traditional applications.

To use RestTemplate, add the spring-boot-starter-web dependency to your pom.xml:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

Next, create a service class to handle API interactions:

@Service
public class TodoService {

    private final RestTemplate restTemplate;

    @Autowired
    public TodoService(RestTemplateBuilder restTemplateBuilder) {
        this.restTemplate = restTemplateBuilder.build();
    }

    public Todo getTodoById(int id) {
        String url = "https://jsonplaceholder.typicode.com/todos/" + id;
        return restTemplate.getForObject(url, Todo.class);
    }

    public List<Todo> getAllTodos() {
        String url = "https://jsonplaceholder.typicode.com/todos";
        Todo[] todos = restTemplate.getForObject(url, Todo[].class);
        return Arrays.asList(todos);
    }
}

In this example, the TodoService class uses RestTemplate to fetch data from the JSONPlaceholder API. The getTodoById() method retrieves a specific Todo item, while the getAllTodos() method returns a list of all Todo items. While straightforward, it is advisable to transition to WebClient for new development due to its reactive features and modern design.

Conclusion

In conclusion, implementing and consuming REST endpoints in Spring Boot involves creating REST controllers for requests, using services for business logic, and applying effective error handling. For consumption, OpenFeign simplifies client creation, while WebClient offers a modern, non-blocking approach. Understanding these tools enables developers to build robust applications that communicate effectively over HTTP.