In this article, we’ll go through how Spring Boot Rest Controller works through detailed examples.
1. Setting up the project
First of all, you need to have Java installed, if you do not you can go through these tutorials depending on the OS your machine runs:
For this tutorial, we are using the IntelliJ IDEA Community edition which you can download here.
If you are not using this IDE, you also need to download Maven here.
The next step is to visit Spring initializr and generate a project according to these settings shown in the image:
After having chosen the above options, press the GENERATE button, and a new spring boot project will be downloaded for you. Then you have to unzip the compressed archive and open the folder with your favorite IDE.
2. Creating a model
The model of our project is pretty simple, just a Car
class under a new package model
under com.codelearnhub. springbootrestcontrollertutorial
:
package com.codelearnhub.springbootrestcontrollertutorial.model; public class Car { private Long id; private String model; private String brand; private Integer horses; private Double price; public Car() { } public Car(Long id, String model, String brand, Integer horses, Double price) { this.id = id; this.model = model; this.brand = brand; this.horses = horses; this.price = price; } //setters, getters, equas, hashCode and toString }
3. Creating a Service
For this tutorial, we will create a service that will handle all the data in memory as connecting, retrieving, and updating a database is out of scope for this tutorial.
3.1 CarService Interface
First of all, we’ll create an interface that will contain all the supported service methods, the interface is the following:
package com.codelearnhub.springbootrestcontrollertutorial.service; import com.codelearnhub.springbootrestcontrollertutorial.model.Car; import java.util.List; public interface CarService { /** * Retrieves all cars currently existing * @return */ List<Car> getAllCars(); /** * * @param min The minimum price inclusive * @param max The maximum price exclusive * @return A list of cars with price inside [min, max] */ List<Car> getCarsWithPriceFilter(Double min, Double max); /** * * @param id The id of the car * @return The car with the matching id */ Car getById(Long id); /** * * @param id The id of the car to be updated * @param carRequest The car object to be updated * @return The updated car */ Car update(Long id, Car carRequest); /** * * @param The car object to be created * @return The car object that was created */ Car create(Car car); /** * * @param id The id of the car to be deleted */ void delete(Long id); }
3.2 CarService Interface Implementation
After having created the CarService
interface, we should create a class to implement that interface. Note that this class must have @Service
annotation in order to be able to inject it later in the RestController.
The CarServiceImpl class will have a list of cars as a private member. Also, we’ll implement all the methods defined in CarService
:
package com.codelearnhub.springbootrestcontrollertutorial.service; import com.codelearnhub.springbootrestcontrollertutorial.model.Car; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.NoSuchElementException; import java.util.Optional; @Service public class CarServiceImpl implements CarService{ private List<Car> cars = new ArrayList<>( Arrays.asList( new Car(1L,"Astra", "Opel", 100, 18000d), new Car(2L, "Insignia", "Opel", 120, 22000d), new Car(3L, "Golf", "VW", 90, 17000d) ) ); @Override public List<Car> getAllCars() { return cars; } @Override public List<Car> getCarsWithPriceFilter(Double min, Double max) { return cars.stream() .filter(car -> car.getPrice() >= min && car.getPrice() <= max) .toList(); } @Override public Car getById(Long id) { return cars.stream() .filter(car -> car.getId().equals(id)) .findAny() .orElseThrow(); } @Override public Car create(Car car) { Long newId = cars.stream().mapToLong(car_ -> Long.valueOf(car_.getId())).max().orElse(0L) + 1L; car.setId(newId); cars.add(car); return getById(car.getId()); } @Override public Car update(Long id, Car carRequest) { Car carToBeUpdated = getById(id); carToBeUpdated.setBrand(carRequest.getBrand()); carToBeUpdated.setHorses(carRequest.getHorses()); carToBeUpdated.setModel(carRequest.getModel()); carToBeUpdated.setPrice(carRequest.getPrice()); return carToBeUpdated; } @Override public void delete(Long id) { boolean successfulDeletion = cars.removeIf(car -> car.getId().equals(id)); if(!successfulDeletion){ throw new NoSuchElementException(); } } }
The logic of the methods is pretty straightforward.
getAllCars()
just returns the car list.getCarsWithPriceFilter(Double min, Double max)
filters the car list and returns only the cars within the desired price range.getById(Long id)
returns the car which has the specified id or throws an exception if it wasn’t found.update(Long id, Car carRequest)
updates the car with every value and returns the updated car. If the car does not exist, it throws an exception.create(Car car)
creates a new car based on the request body.delete(Long id)
deletes a car if it exists, else it throws an exception
4. Creating the Spring Boot Rest Controller
REST stands for REpresentational State Transfer and is a set of architectural constraints. REST APIs utilize the HTTP protocol in order to transfer information between the client and the server.
The most common format used by REST APIs is the JSON format, however, you can use other formats such as XML, plain text, etc.
4.1 CarRestController Overview
Firstly, create a new package under com.codelearnhub. springbootrestcontrollertutorial
named controller
. Then inside that package create a new class CarRestController.
To mark your class as a Rest Controller, you just have to add the @RestController
annotation on top of your class; this annotation contains both @Controller
annotation and @ResponseBody
annotation. Now let’s explain those two:
@Controller
is an annotation just like@Component
and it marks our class as a bean to be picked up by Spring Context.@ResponseBody
annotation is an instruction to serialize automatically any response coming from our endpoints inside this class
Now that we have marked our class as @RestController
, we should choose the path of the REST API by adding a @RequestMapping
annotation on top of the class.
Therefore, we will use @RequestMapping and we will add 2 attributes as shown below:
@RequestMapping(value = "/cars", produces = MediaType.APPLICATION_JSON_VALUE)
The value attribute is the path of the REST API, which means the path for every method-endpoint that we will create will be http://localhost:8080/cars
.
The produces attribute defines what will be the format of the response of the endpoints. Here we set it to application/json
as the REST API, we are building will use JSON as the format.
Now that we have explained the class-level annotation let’s go through the implementation of the Rest Controller:
package com.codelearnhub.springbootrestcontrollertutorial.controller; import com.codelearnhub.springbootrestcontrollertutorial.model.Car; import com.codelearnhub.springbootrestcontrollertutorial.service.CarService; import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.List; /** * Rest Controller for cars */ @RestController @RequestMapping(value = "/cars", produces = MediaType.APPLICATION_JSON_VALUE) public class CarRestController { private final CarService carService; @Autowired public CarRestController(CarService carService) { this.carService = carService; } }
Here we injected the carService that we previously created through constructor dependency injection, in order to use it inside each endpoint.
4.2 Retrieving all cars
HTTP GET requests are read-only requests that must be used to retrieve records from the server. Additionally, GET requests do not have any request body, but they can have path variables(e.g. to specify the id of the record to be retrieved) and request parameters(e.g. to filter the records based on specific criteria).
Now it’s time to create our first endpoint. Since we want to retrieve the information we should map this request to HTTP GET requests. The endpoint for retrieving all the cars is the following:
@GetMapping public List<Car> getAll() { return carService.getAllCars(); }
@GetMapping
maps the GET http://localhost:8080/cars to the method described above. Alternatively, you could use @RequestMapping(method = RequestMethod.GET)
which is effectively the same thing as @GetMapping
.
Note that if you do not add @ResponseStatus
annotation for a method, the response code will be 200. Since we do want that response, there is no need to add it.
4.3 Retrieving cars based on the price filter
Again we need a GET request to be mapped, but this time we want to map requests such as http://localhost:8080/cars?minPrice=NUMBER_A&maxPrice=NUMBER_B. Our endpoint is the following:
@GetMapping(params = {"minPrice", "maxPrice"}) public List<Car> getAllFilteredByPrice( @RequestParam Double minPrice, @RequestParam Double maxPrice ){ return carService.getCarsWithPriceFilter(minPrice, maxPrice); }
First of all, we added the annotations @RequestParam
to both parameters, so that the parameters of the requests are mapped to these variables. If for any reason we wanted the request parameters to have a different name, let’s say min-price and max-price, then we would have to add them as follows:
@RequestParam(value = "min-price") Double minPrice, @RequestParam(value = "max-price") Double maxPrice
Additionally, we added the attribute params inside the @GetMapping
, which makes the existence of these two params mandatory in order for the request to be matched. If we hadn’t added the params attribute the error that will get when we start our app will be the following:
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'requestMappingHandlerMapping' defined in class path resource [org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration$EnableWebMvcConfiguration.class]: Invocation of init method failed; nested exception is java.lang.IllegalStateException: Ambiguous mapping. Cannot map 'carRestController' method com.codelearnhub.springbootrestcontrollertutorial.controller.CarRestController#getAllFilteredByPrice(Double, Double) to {GET [/cars], produces [application/json]}: There is already 'carRestController' bean method
This means there isn’t enough information to differentiate this endpoint from the previous endpoint we created.
Now if we want an endpoint to have optional request parameters, then we should not add the params attribute at all and at the same time, set the request parameters as required false. So if we didn’t have getAll()
endpoint at all, we could have created the following endpoint:
@GetMapping public List<Car> getAllFilteredByPrice( @RequestParam(required = false) Double minPrice, @RequestParam(required = false) Double maxPrice ){ return carService.getCarsWithPriceFilter(minPrice, maxPrice); }
All of the following requests would get mapped to the endpoint above:
- http:localhost:8080/cars
- http:localhost:8080/cars?minPrice=10000
- http:localhost:8080/cars?minPrice=12000&maxPrice=20000
- http:localhost:8080/cars?maxPrice=20000
4.4 Retrieve a car by id
This endpoint should have the id as a path variable and not as a request parameter that we previously used.
@GetMapping("/{id}") public Car getById(@PathVariable Long id){ return carService.getById(id); }
Firstly, we add the “/{id}” inside the @GetMapping
annotation so that all requests with the same pattern:
- http:localhost:8080/cars/1
- http:localhost:8080/cars/2
- http:localhost:8080/cars/hello
Are mapped to this endpoint.
Then we want to map the variable of the request to the id variable of our method by adding @PathVariable
annotation in front of the variable. Hadn’t we added this annotation, the variable would always be null
.
Additionally, if we send a request like http:localhost:8080/cars/hello, the id variable will be null since hello cannot be interpreted as a Long variable. The same applies to decimals.
4.5 Creating a new Car
HTTP POST requests are used to create a new record in the server. Additionally, POST requests do have request body which will be used for the creation of the new record.
In order for the HTTP POST http:localhost:8080/cars to be matched by our controller method, we should create the following method:
@PostMapping public Car create(@RequestBody Car car) { return carService.create(car); }
- The
@PostMapping
annotation means that this controller will only match POST requests. - The
@RequestBody
annotation maps the request body from the request to our Car object.
Then our service handles the addition of the new car to our list.
4.6 Updating a car
HTTP PUT requests are used to update record in the server. Additionally, PUT requests do have request body and they usually have path variables(e.g. the id of the record to be updated).
In order for the HTTP PUT http:localhost:8080/cars/{id} ({id} is a long number in our case) to be matched by our controller method, we should create the following method:
@PutMapping("/{id}") public Car update(@RequestBody Car car, @PathVariable Long id){ return carService.update(id, car); }
Here we have both a request body that will be used to update the values of our car and a path variable that specifies the id of the car to be updated.
4.7 Deleting a car
HTTP DELETE requests are used to delete a record in the server. Additionally, DELETE requests do not have request body and they must have a path variable to specify the record to be deleted.
In order for the HTTP DELETE http:localhost:8080/cars/{id}({id} is a long number in our case) to be matched by our controller method, we should create the following method:
@DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) public void delete(@PathVariable Long id) { carService.delete(id); }
As you can observe, the same logic applies as with getById()
endpoint, but now the HTTP DELETE request will be matched.
In addition, we do not want to return a 200 OK if we do not want to include any response body in the response, but a 204 NO CONTENT code according to Mozilla’s docs.
To achieve that, we add the @ResponseStatus
annotation to our method so in case of successful deletion, this status is returned instead of 200 OK.
5. Validating requests to Spring Boot Rest Controller
Let’s say we get one of the following requests:
- GET /cars/-1
- GET /cars?minPrice=-100&maxPrice=-10
- DELETE /cars/-1
- PUT /cars/-1
All of these are invalid requests since our ids should only be a positive number. So why bother searching inside the list while we could “reject” the request at the moment we see a negative or zero number?
Additionally, for POST and PUT requests the values of the Car object could be invalid, for example:
{ "model" : "", "brand": "", "horses": -10, "price": 17000.0 }
Should be invalid as we always want a model and a brand to be present and the number of horses cannot be negative.
5.1 Validation in the Car class
To validate the attributes of the Car object to be inserted or updated, let’s jump back to our Car class. Then we should add all the validations needed on top of the getter methods as shown below:
@NotNull(message = "Model must not be null") @NotEmpty(message = "Model must have value") public String getModel() { return model; } @NotNull(message = "Brand must not be null") @NotEmpty(message = "Brand must have value") public String getBrand() { return brand; } @NotNull(message = "Horses must not be null") @Positive public Integer getHorses() { return horses; } @NotNull(message = "Price must not be null") @Positive public Double getPrice() { return price; }
Let’s explain what the annotation should do here:
@NotNull
annotation means that this field must not be null and it must be included in the request body.@NotEmpty
annotation means that this String field cannot be empty.@Positive
means that this field must have a value greater than zero
5.2 Adding the validation at the Controller level
First of all, we must add @Validated
annotation on the class level so that the validations are taken into account.
Now let’s go each by each endpoint:
5.2.1 getAll()
This endpoint does not need any validation as there isn’t any input
5.2.2 getAllFilteredByPrice
This endpoint accepts 2 request parameters, minPrice
and maxPrice
both of which must be positive in order to have meaning. Therefore the @Positive
annotation should be added as shown below:
@GetMapping(params = {"minPrice", "maxPrice"}) public List<Car> getAllFilteredByPrice( @RequestParam @Positive(message = "minPrice parameter must be greater than zero") Double minPrice, @RequestParam @Positive(message = "maxPrice parameter must be greater than zero") Double maxPrice ){ return carService.getCarsWithPriceFilter(minPrice, maxPrice); }
5.2.3 getById
Same as above but this time the @Positive
will be applied to the id variable.
5.2.4 create
Now we want to validate the request body so the @Valid
annotation must be added in front of the request body so that it gets validated according to the constraints we added in section 4.1.
@PostMapping public Car create(@Valid @RequestBody Car car) { return carService.create(car); }
5.2.5 update
Same as with create, we will add @Valid
in front of the @RequestBody
annotation and a @Positive
annotation in front of the id.
Note that here we require the presence of all attributes of the car even if the only the request was sent to update only one attribute. If we want to accept requests like this:
PUT http://localhost:8080/cars/1 with request body:
{ "price": 12000.0 }
We should remove the @Valid
and handle the absence of attributes inside the service.
5.2.6 delete
Same as getById.
6. Handling Exceptions Using @RestControllerAdvice
Alright, now we have set our validations, but if any of the validations fail, an exception will be thrown and the server will return a generic 500 - INTERNAL SERVER ERROR
which is not very helpful for the client.
For that reason, we will create a new class ControllerAdvice
which will be responsible for handling the exceptions thrown.
The exceptions that we need to handle are the following:
- NoSuchElementException: This will be thrown when the resource that was requested does not exist
- ConstraintViolationException: This will be thrown if any of the constraints we have set for
id
,minPrice
andmaxPrice
failed. - MethodArgumentNotValidException: This will be thrown if the request body violates any of the validations we have previously set.
Now let’s jump to our ControllerAdvice class:
package com.codelearnhub.springbootrestcontrollertutorial.exception; import jakarta.validation.ConstraintViolationException; import org.springframework.http.HttpStatus; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; import java.util.List; import java.util.NoSuchElementException; import java.util.stream.Stream; /** * Class to handle exceptions thrown by controller */ @RestControllerAdvice public class ControllerAdvice { @ExceptionHandler(value = NoSuchElementException.class) @ResponseStatus(value = HttpStatus.NOT_FOUND) public void carNotFound() {} @ExceptionHandler(value = ConstraintViolationException.class) @ResponseStatus(value = HttpStatus.BAD_REQUEST) public List<String> wrongInputPathVariableRequestParam(ConstraintViolationException exception) { return exception.getConstraintViolations() .stream() .map(constraintViolation -> constraintViolation.getMessage()) .toList(); } @ExceptionHandler(value = MethodArgumentNotValidException.class) @ResponseStatus(value = HttpStatus.BAD_REQUEST) public List<String> wrongInputRequestBody(MethodArgumentNotValidException exception) { return exception.getBindingResult().getFieldErrors() .stream() .map(error -> error.getDefaultMessage()) .toList(); } }
Let’s explain what happened here:
@RestControllerAdvice
is a class-level annotation that included @ControllerAdvice
and @ResponseBody
annotation and is like a controller that is responsible for handling exceptions.
@ExceptionHandler
annotation defines which exception should be handled by this method and it takes the type of the exceptions as a parameter(more than one exceptions can be handled by the same method)
@ResponseStatus
as we have already said defines the status code to be returned by the method.
- For
carNotFound()
method we just return404 - NOT FOUND
status as it is pretty descriptive and it does not need a response body. - For
wrongInputPathVariableRequestParam()
, we return a List that contains the messages we have defined at the rest controller level. - For
wrongInputInputRequestBody()
, we return a List that contains the messages we have defined at the Car class level.
7. Testing our Spring Boot Rest Controller using MockMvc
To test our rest controller our strategy will be pretty simple.
First, we will create a new class called CarRestControllerTest that we will annotate with:
@SpringBootTest
: This annotation indicates that we require the Spring Context when we run each test.@AutoConfigureMockMvc
which enables auto-configuration of MockMvc
Additionally, we will inject MockMvc using @Autowired at the field level which will allow us to perform requests and test the responses and an ObjectMapper object to read and convert the JSON responses into objects.
@DirtiesContext indicates that this test will change the data of the context so it resets the context to the previous state. As a result, the next test will start with a fresh Spring Context
Our test class will be the following:
package com.codelearnhub.springbootrestcontrollertutorial.controller; import com.codelearnhub.springbootrestcontrollertutorial.model.Car; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import java.util.List; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @AutoConfigureMockMvc class CarRestControllerTest { @Autowired private MockMvc mockMvc; private ObjectMapper objectMapper = new ObjectMapper(); @Test @DisplayName("Test getting all cars") void getAll() throws Exception { var expectedResult = objectMapper.readValue(TestHelper.readFile("/get_all_cars.json"), List.class); MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.get("/cars")) .andExpect(status().isOk()).andReturn(); var actualResult = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), List.class); Assertions.assertEquals(expectedResult, actualResult); } @Test @DisplayName("Test getting car with id 1") void getById() throws Exception { var expectedResult = objectMapper.readValue(TestHelper.readFile("/get_car_id_1.json"), Car.class); MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.get("/cars/1")) .andExpect(status().isOk()).andReturn(); var actualResult = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), Car.class); Assertions.assertEquals(expectedResult, actualResult); } @Test @DisplayName("Test getting car with id 4 - does not exist") void getByIdNonExistent() throws Exception { mockMvc.perform(MockMvcRequestBuilders.get("/cars/4")) .andExpect(status().isNotFound()); } @Test @DisplayName("Update price of car with id 1 to 19000") @DirtiesContext void update() throws Exception { var requestBody = TestHelper.readFile("/update_car_with_id_1_request.json"); var responseBody = TestHelper.readFile("/update_car_with_id_1_response.json"); var expectedResult = objectMapper.readValue(responseBody, Car.class); MvcResult mvcResult = mockMvc.perform( MockMvcRequestBuilders.put("/cars/1") .content(requestBody) .contentType(MediaType.APPLICATION_JSON_VALUE) ) .andExpect(status().isOk()) .andReturn(); var actualResult = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), Car.class); Assertions.assertEquals(expectedResult, actualResult); } @Test @DisplayName("Create a new car") @DirtiesContext void create() throws Exception { var requestBody = TestHelper.readFile("/create_lamborghini_gallardo_response.json"); var responseBody = TestHelper.readFile("/create_lamborghini_gallardo_response.json"); var expectedResult = objectMapper.readValue(responseBody, Car.class); MvcResult mvcResult = mockMvc.perform( MockMvcRequestBuilders.post("/cars") .content(requestBody) .contentType(MediaType.APPLICATION_JSON_VALUE) ) .andExpect(status().isOk()) .andReturn(); var actualResult = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), Car.class); Assertions.assertEquals(expectedResult, actualResult); } @Test @DisplayName("Delete an existing car") @DirtiesContext void deleteAnExistingCar() throws Exception { mockMvc.perform(MockMvcRequestBuilders.delete("/cars/3")) .andExpect(status().isNoContent()); } @Test @DisplayName("Get all cars with price between 10000 and 20000 euro") void getCarsFilteredByPrice() throws Exception { var expectedResult = objectMapper.readValue(TestHelper.readFile("/get_cars_by_price.json"), List.class); MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.get("/cars?minPrice=10000&maxPrice=20000")) .andExpect(status().isOk()).andReturn(); var actualResult = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), List.class); Assertions.assertEquals(expectedResult, actualResult); } }
Let’s explain the getAll()
test:
- We read from a file the expected response using a TestHelper class that just reads files and then we convert it back to a list of objects
- We perform a get request using the
mockMvc
and before we get the response, we validate that indeed the response code was 200. - Then we read the response and convert it back to a list of car objects.
- We assert that the expected and the actual response are equal.
For a post/update request we follow the same approach but we must also send a request body and specify the contentType. Then again we compare the responses.
You can find all the expected responses here.
8. Testing our Spring Boot Rest Controller using Postman
Postman makes sending HTTP requests as easy as possible. If you don’t have postman installed, you can visit this page to download it:
The next step is to head to SpringBootRestControllerTutorialApplication.java
and run the application.
8.1 Retrieve all cars
8.2 Retrieve all cars with a price filter
When we give a negative number:
8.3 Get a Car by Id
When we give a negative id:
When we give an id that does not exist:
8.4 Create a new Car
When any of the required attributes is missing:
8.5 Update a Car
When we give negative horses and set the model as empty:
8.6 Delete a car
When we try to delete a car that does not exist:
8.7 Retrieve all cars after all the of the above requests:
9. Conclusion
By now you should be able to create your own REST APIs using Spring Boot Rest Controller. You can find the source code on our GitHub page.
10. Sources
[1]: RestController (Spring Framework 5.3.18 API)
[2]: Getting Started | Testing the Web Layer – Spring