Spring Boot MVC REST Controller Example & Unit Tests

Learn to create a REST API controller using the Spring MVC @RestController annotation in a Spring Boot application. We will learn to write the REST APIs for performing CRUD (Create, Read, Update, Delete) operations.

1. Maven

Before beginning to write the actual REST controller logic, we must import the necessary dependencies in the project. Spring boot’s spring-boot-starter-web module transitively imports all the necessary dependencies such as spring-webmvc for REST API related annotations, spring-boot-starter-tomcat for embedded server, and spring-boot-starter-json for JSON request and response formats.

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

Additionally, spring-boot-starter-test module is added for writing the unit tests.

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

2. REST Resource

For demo purposes, we have added the following Item class with only two fields: id and name. We have added the Lombok annotations that generate the necessary boilerplate methods such as getters, setters, constructors, toString() etc.

@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Item {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  private String name;
}

3. REST Controller

The Spring REST controller is marked with the annotation @RestController annotation. It is a convenience annotation that is itself annotated with @Controller and @ResponseBody. You can read the difference between @Controller and @RestController annotations in detail in the linked article.

3.1. Method Annotations

In the REST controller, we annotate the request handler methods with HTTP-methods-specific annotations that forward all incoming requests to the respective method:

  • @GetMapping: for mapping HTTP GET requests.
  • @PostMapping: for mapping HTTP POST requests.
  • @PutMapping: for mapping HTTP PUT requests.
  • @DeleteMapping: for mapping HTTP DELETE requests.

Apart from the above annotations, we use several annotations for accepting the request parameters, headers, body, and exception handling purposes.

  • @PathVariable: binds a method parameter to a URI template variable.
  • @RequestHeader: binds the specified request header’s value to the method parameter.
  • @RequestParam: binds the specified request parameter’s value to the method parameter.
  • @RequestBody: binds the body of the web request to the method parameter.

3.2. REST APIs

For the Item resource, we can write the following REST APIs:

REST APIDescription
GET /itemsGet all items
POST /itemsCreate a new item
GET /items/{id}Get an item by ID
PUT /items/{id}Update an item by ID
DEPETE /items/{id}Delete an item by ID
Let’s write the simplest logic for these methods in the REST controller:
import com.howtodoinjava.dao.model.Item;
import com.howtodoinjava.dao.model.ItemRepository;
import com.howtodoinjava.web.errors.ItemNotFoundException;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ItemController {

  @Autowired
  ItemRepository itemRepository;

  @GetMapping("/items")
  List<Item> all() {
    return itemRepository.findAll();
  }

  @GetMapping("/items/{id}")
  Item getById(@PathVariable Long id) {

    return itemRepository.findById(id)
        .orElseThrow(() -> new ItemNotFoundException(id));
  }

  @PostMapping("/items")
  Item createNew(@RequestBody Item newItem) {
    return itemRepository.save(newItem);
  }

  @DeleteMapping("/items/{id}")
  void delete(@PathVariable Long id) {
    itemRepository.deleteById(id);
  }

  @PutMapping("/items/{id}")
  Item updateOrCreate(@RequestBody Item newItem, @PathVariable Long id) {

    return itemRepository.findById(id)
        .map(item -> {
          item.setName(newItem.getName());
          return itemRepository.save(item);
        })
        .orElseGet(() -> {
          newItem.setId(id);
          return itemRepository.save(newItem);
        });
  }
}

We can run the application at this step, and if the data access is setup correctly, we can fetch the items from the database using the ‘GET /items‘ API as follows:

4. Request Validation

Many times, the API user will send the request payload to save in the database. It is of utmost importance to validate such requests for any malformed or missing information. Spring boot’s spring-boot-starter-validation module helps in this very purpose.

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

Next, we add the Jakarta validation annotation in the model class i.e. Item. In our case, the item name must not be empty to be a valid item.

import jakarta.validation.constraints.NotBlank;

//...
public class Item {

  //...

  @NotBlank(message = "Item name must not be blank")
  private String name;
}

Next, we add the @Valid annotation in the handler method ‘POST /items’. This annotation triggers the validation of the annotated request parameter when the handler method is invoked.

@PostMapping("/items")
Item createNew(@Valid @RequestBody Item newItem) {
  return itemRepository.save(newItem);
}

We can test the validation support by sending a request with the missing item name:

5. Error Handling

The requests will not succeed everytime. Sometimes they will fail due to unknown reasons, and sometimes we need to fail them purposefully to inform the user of unwanted situations on the server side. In both cases, we will catch the exception (or throw it manually) and then let the exception handler catch it and inform the user.

A similar usecase we can see in the ‘GET /items/{id}‘ API. If the item ID does not exist in the database, we throw the ItemNotFoundException from the controller method.

This exception is caught by the ApplicationExceptionHandler class that has been annotated with @RestControllerAdvice annotation. This class defines an exception handler to invoke methods when a REST controller throws exceptions.

import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import java.net.URI;

@RestControllerAdvice
public class ApplicationExceptionHandler {

  @ExceptionHandler(ItemNotFoundException.class)
  public ProblemDetail handleItemNotFoundException(ItemNotFoundException ex, WebRequest request) {

    ProblemDetail body = ProblemDetail
      .forStatusAndDetail(HttpStatusCode.valueOf(404), ex.getLocalizedMessage());
    body.setType(URI.create("http://my-app-host.com/errors/not-found"));
    body.setTitle("Item Not Found");
    body.setProperty("hostname", "localhost");
    return body;
  }
}

6. Unit Testing

The code can not be complete until we write tests for it. In Spring Boot MVC, we annotate the test class with @WebMvcTest annotation that has the unit tests for the REST controller. This annotation disables full auto-configuration and instead applies only configuration relevant to MVC tests (@Controller, @ControllerAdvice etc.). It also autoconfigures the MockMvc instance that can be used to invoke the handler methods.

The @WebMvcTest annotation also configures the MockBean annotation to inject the mocked dependencies into the test class.

The following test class has the test methods for the handler methods in ItemController REST controller. The methods are self-explanatory, still, if you have doubts, drop me a comment.

import com.fasterxml.jackson.databind.ObjectMapper;
import com.howtodoinjava.dao.model.Item;
import com.howtodoinjava.dao.model.ItemRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.MockitoAnnotations;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;

import java.util.Arrays;
import java.util.Optional;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.when;

@WebMvcTest(ItemController.class)
public class ItemControllerTest {

  @Autowired
  private MockMvc mockMvc;

  @MockBean
  private ItemRepository itemRepository;

  @Autowired
  private ObjectMapper objectMapper;

  @BeforeEach
  public void setUp() {
    MockitoAnnotations.openMocks(this);
  }

  @Test
  public void testGetAllItems() throws Exception {
    when(itemRepository.findAll()).thenReturn(Arrays.asList(new Item(), new Item()));

    mockMvc.perform(MockMvcRequestBuilders.get("/items")
            .contentType(MediaType.APPLICATION_JSON))
        .andExpect(MockMvcResultMatchers.status().isOk())
        .andExpect(MockMvcResultMatchers.jsonPath("$.length()").value(2));
  }

  @Test
  public void testGetItemById() throws Exception {
    Item item = new Item();
    item.setId(1L);
    when(itemRepository.findById(1L)).thenReturn(Optional.of(item));

    mockMvc.perform(MockMvcRequestBuilders.get("/items/1")
            .contentType(MediaType.APPLICATION_JSON))
        .andExpect(MockMvcResultMatchers.status().isOk())
        .andExpect(MockMvcResultMatchers.jsonPath("$.id").value(1));
  }

  @Test
  public void testGetItemById_NotFound() throws Exception {
    when(itemRepository.findById(anyLong())).thenReturn(Optional.empty());

    mockMvc.perform(MockMvcRequestBuilders.get("/items/1")
            .contentType(MediaType.APPLICATION_JSON))
        .andExpect(MockMvcResultMatchers.status().isNotFound());
  }

  @Test
  public void testCreateNewItem() throws Exception {
    Item newItem = new Item();
    newItem.setName("Test Item");

    when(itemRepository.save(any(Item.class))).thenReturn(newItem);

    mockMvc.perform(MockMvcRequestBuilders.post("/items")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(newItem)))
        .andExpect(MockMvcResultMatchers.status().isOk())
        .andExpect(MockMvcResultMatchers.jsonPath("$.name").value("Test Item"));
  }

  @Test
  public void testUpdateOrCreateItem() throws Exception {
    Item newItem = new Item();
    newItem.setId(1L);
    newItem.setName("Updated Item");

    when(itemRepository.findById(1L)).thenReturn(Optional.of(new Item()));
    when(itemRepository.save(any(Item.class))).thenReturn(newItem);

    mockMvc.perform(MockMvcRequestBuilders.put("/items/1")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(newItem)))
        .andExpect(MockMvcResultMatchers.status().isOk())
        .andExpect(MockMvcResultMatchers.jsonPath("$.name").value("Updated Item"));
  }

  @Test
  public void testDeleteItem() throws Exception {
    mockMvc.perform(MockMvcRequestBuilders.delete("/items/1"))
        .andExpect(MockMvcResultMatchers.status().isOk());
  }
}

7. Conclusion

This Spring Boot tutorial demonstrated how to create a REST API controller. It discussed creating the API handler methods, adding validations, and error handling. Additionally, it showcased the initial unit tests for REST controller methods, and we can add more unit tests based on the business requirements.

Happy Learning !!

Source Code on Github

Comments

Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.