Examples of code
Rest Controller to shutdown a spring boot application
Controller
import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.SpringApplication; import org.springframework.context.ApplicationContext; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; @RestController public class AdminController { private static final Logger LOGGER = LoggerFactory.getLogger(AdminController.class); private ApplicationContext context; public AdminController(ApplicationContext context) { this.context = context; } @PostMapping(value = "/restart-app", consumes = MediaType.ALL_VALUE) @ResponseBody public ResponseEntity<Void> restartApp() { LOGGER.info("restart app submited"); new Thread(() -> SpringApplication.exit(context)).start(); LOGGER.info("restart app in progress"); return ResponseEntity.status(HttpStatus.OK) .build(); } } |
application properties/yaml
server: shutdown: graceful # Type of shutdown that the server performs. Default immediate spring: lifecycle: timeout-per-shutdown-phase: 40s # Timeout for the shutdown of any phase. Default 30s |
The content-type
How Spring values the Content-Type header and format the data consequently ?
A request specifies the Accept header to inform the sever which content-type(s) it supports.
And a response specifies the Content-Type header to inform the content type of the returned response.
In that content-type negotiation, Spring has an active role.
It reads the Accept header from the request and also considers the media-type value(s) specified in the produces
field annotation and returns a Content-Type
header along a content format that respects as much as possible both sides.
For example here Spring should return json type/data.
@GetMapping(value = "/person/{id}", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) @ResponseBody public ResponseEntity<Person> getPerson(@PathVariable("id") Long id) { Person person = personRepository.findById(id).orElseThrow(()-> new IllegalArgumentException("id person " + id + " not existing in the system")); return ResponseEntity.status(HttpStatus.OK) .body(person); } } |
That doesn’t guarantee that Spring will always return json data and content-type.
Especially if an exception is thrown somewhere.
To handle that problem we could :
– perform a try/catch in the controller to ensure that we returns the wished data and content-type.
– define a class annotated with @ControllerAdvice that defines our way to map the exception to the client response.
Beware : it works across all @Controller classes. It is very structuring for the client side.
Ex:
@ControllerAdvice public class GlobalRestExceptionHandler { @ExceptaionHandler(value = IllegalArgumentException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public ResponseEntity<?> badRequest(RuntimeException e) { return ResponseEntity.badRequest().contentType(MediaType.TEXT_PLAIN).body(e.getMessage()); } } |
@ControllerAdvice
Base code to illustrate
ChinchillaController
Some methods uses @Valid. Others don’t.
package davidxxx.component; import javax.validation.Valid; import java.net.URI; import java.util.List; import davidxxx.Chinchilla; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; 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.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; @RestController public class ChinchillaController { private ChinchillaLocalRepository chinchillaLocalRepository; public ChinchillaController(ChinchillaLocalRepository chinchillaLocalRepository) { this.chinchillaLocalRepository = chinchillaLocalRepository; } @GetMapping(value = "/chinchilla", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) @ResponseBody public ResponseEntity<List<Chinchilla>> getAllChinchillas() { List<Chinchilla> chinchillas = chinchillaLocalRepository.findAll(); return ResponseEntity.status(HttpStatus.OK) .body(chinchillas); } @GetMapping(value = "/chinchilla/{id}", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) @ResponseBody public ResponseEntity<Chinchilla> getChinchilla(@PathVariable("id") int id) { Chinchilla chinchilla = chinchillaLocalRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException( "id chinchilla " + id + " not existing in the system")); return ResponseEntity.status(HttpStatus.OK) .body(chinchilla); } @PostMapping(value = "/chinchilla", produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<String> addChinchilla(@RequestBody Chinchilla chinchilla) { if (chinchilla.getName() .toLowerCase() .equals("doudounou")) { throw new IllegalArgumentException("Doudounou forbidden as name. Honnor to my doudou"); } Chinchilla saved = chinchillaLocalRepository.save(chinchilla); return ResponseEntity.created(URI.create("chinchilla/" + saved.getId())) .body(""); } @PostMapping(value = "/chinchillaWithValid") public ResponseEntity<String> addValidChinchilla(@RequestBody @Valid Chinchilla chinchilla) { Chinchilla saved = chinchillaLocalRepository.save(chinchilla); return ResponseEntity.created(URI.create("chinchilla/" + saved.getId())) .body(""); } // @RequestParam accept arguments passed as GET request parameters. By default the param is mandatory but we can override that @GetMapping(value = "/chinchilla/search") public ResponseEntity<List<Chinchilla>> search(@RequestParam String name) { List<Chinchilla> list = chinchillaLocalRepository.findByName(name); return ResponseEntity.status(HttpStatus.OK) .body(list); } } |
ChinchillaLocalRepository
The save method needs to have a chinchilla parameter with name not null.
Otherwise an exception is thrown.
package davidxxx.component; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import davidxxx.Chinchilla; import davidxxx.ClientException; import org.springframework.stereotype.Service; @Service public class ChinchillaLocalRepository { private AtomicInteger idSequence = new AtomicInteger(1); private Map<Integer, Chinchilla> chinchillasById = new HashMap<>(); public Optional<Chinchilla> findById(Integer id) { return Optional.ofNullable(chinchillasById.get(id)); } public Chinchilla save(Chinchilla chinchilla) { if (chinchillasById.values() .stream() .map(c -> c.getName() .toLowerCase()) .anyMatch(n -> n.equals(chinchilla.getName()))) { throw new ClientException("Name already used"); } int id = idSequence.getAndIncrement(); chinchilla.setId(id); chinchillasById.put(id, chinchilla); return chinchilla; } public List<Chinchilla> findAll() { return new ArrayList<Chinchilla>(chinchillasById.values()); } public List<Chinchilla> findByName(String name) { return chinchillasById.values() .stream() .filter(c -> c.getName() .equals(name)) .collect(Collectors.toList()); } } |
Chinchilla
Some javax.validation constraints are specified on fields.
These are checked by Controller only if @Valid is specified on the controller method.
package davidxxx; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; import javax.validation.constraints.PositiveOrZero; import java.time.LocalDate; import java.util.List; public class Chinchilla { public enum Color { GRAY,BLACK,WHITE,BEIGE, OTHER } public enum Temperament { } private Integer id; @NotBlank private String name; @NotNull private Color color; @PositiveOrZero private float sizeCm; private LocalDate birthday; private List<Temperament> temperaments; public Chinchilla(String name, Color color, float sizeCm, LocalDate birthday, List<Temperament> temperaments) { this.name = name; this.color = color; this.sizeCm = sizeCm; this.birthday = birthday; this.temperaments = temperaments; } public String getName() { return name; } public Color getColor() { return color; } public float getSizeCm() { return sizeCm; } public LocalDate getBirthday() { return birthday; } public List<Temperament> getTemperaments() { return temperaments; } public Integer getId() { return id; } public void setId(Integer id) { this.id = id; } @Override public String toString() { return "Chinchilla{" + "id=" + id + ", name='" + name + '\'' + ", color=" + color + ", size='" + sizeCm + '\'' + ", birthday=" + birthday + ", temperaments=" + temperaments + '}'; } } |
Default behavior without @ControllerAdvice
Thrown exceptions
By default, DispatcherServlet class is used to perform the request and the exception handling occurs in classes and intercepting around that.
Finally the exception is logged with ERROR level on the backend.
And a 500 response is returned to the client with a json response like that :
{ "error": "Internal Server Error", "message": "Doudounou forbidden as name. Honnor to my doudou", "path": "/chinchilla", "status": 500, "timestamp": "2021-07-30T13:09:16.120+00:00", "trace": "java.lang.IllegalArgumentException:..." } |
The message is extracted from Exception.getMessage()
.
That is « No message available » if the field is empty.
The trace field is the exception stacktrace.
These 5 json fields are common to most of error responses returned by Spring Web facilities.
@Valid cases
The exception is logged with WARN level on the backend.
And a 400 response is returned to the client with a json response like that :
{ "error": "Bad Request", "errors": [ { "arguments": [ { "arguments": null, "code": "color", "codes": [ "chinchilla.color", "color" ], "defaultMessage": "color" } ], "bindingFailure": false, "code": "NotNull", "codes": [ "NotNull.chinchilla.color", "NotNull.color", "NotNull.davidxxx.Chinchilla$Color", "NotNull" ], "defaultMessage": "must not be null", "field": "color", "objectName": "chinchilla", "rejectedValue": null }, ... and so for for each field error ... ] "message": "Validation failed for object='chinchilla'. Error count: 2", "path": "/chinchillaWithValid", "status": 400, "timestamp": "2021-07-30T13:18:32.260+00:00", "trace": "org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument...." } |
The fields are the same those seen for thrown exceptions and we have an additional field : errors, an array that contains detailed information on each invalid fields.
Define @ControllerAdvice (We will focus on issues not related to @Valid processing)
Map exceptions to specific error response returned along custom body messages
As seen above, any thrown exception means returning a 500 to the client but in some cases, we would prefer to return a client error response : 4XX code.
Besides, default returned fields may not be desirable. For example we could need to mask the stacktrace or to obfuscate some things or we may also need to return a specific message or to return custom fields.
Here an exception handler class that :
– intercept IllegalArgumentException and ClientException exceptions and returns a 400 response with specific fields in the json body
– intercept any other RuntimeException(s) and returns a 500 response with specific fields in the json body
package davidxxx; import javax.servlet.http.HttpServletRequest; import java.io.PrintWriter; import java.io.StringWriter; import java.util.LinkedHashMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; @ControllerAdvice // We extend ResponseEntityExceptionHandler to override behavior for handleHttpRequestMethodNotSupported() public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionHandler.class); // @ResponseStatus(HttpStatus.BAD_REQUEST) // We must specify Exception because we have more than one exception (IllegalArgumentException and ClientException) @ExceptionHandler(value = {IllegalArgumentException.class, ClientException.class}) public ResponseEntity<?> badRequest(Exception e, HttpServletRequest request) { return commonResponseEntityForBadRequest(e.getMessage(), false); } @ExceptionHandler(value = {RuntimeException.class}) public ResponseEntity<?> serverError(RuntimeException e, HttpServletRequest request) { return commonResponseEntityForInternalServerErrror(e, true); } // Works with ResponseEntityExceptionHandler @Override protected ResponseEntity<Object> handleHttpRequestMethodNotSupported(HttpRequestMethodNotSupportedException ex, HttpHeaders headers, HttpStatus status, WebRequest request) { // custom processing if (ex.getMethod() .equalsIgnoreCase("delete")) { LOGGER.error("Beware. Delete is used by client! Should not"); // Save also in DB... // ... } // go on executing the logic defined in the base class return super.handleHttpRequestMethodNotSupported(ex, headers, status, request); } private ResponseEntity<?> commonResponseEntityForBadRequest(String errorMsg, boolean canRetry) { var map = new LinkedHashMap<String, String>(); map.put("error", errorMsg); map.put("canRetry", Boolean.toString(canRetry)); return ResponseEntity.status(HttpStatus.BAD_REQUEST) .contentType(MediaType.APPLICATION_JSON) .body(map); } private ResponseEntity<?> commonResponseEntityForInternalServerErrror(RuntimeException runtimeException, boolean canRetry) { String errorMsg = ""; if (runtimeException.getMessage() != null) { errorMsg = runtimeException.getClass() + " -> " + errorMsg; } else { errorMsg = runtimeException.getClass() .toString(); } StringWriter sw = new StringWriter(); runtimeException.printStackTrace(new PrintWriter(sw)); var map = new LinkedHashMap<String, String>(); map.put("error", errorMsg); map.put("stacktrace", sw.toString()); map.put("canRetry", Boolean.toString(canRetry)); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .contentType(MediaType.APPLICATION_JSON) .body(map); } } |
Request examples :
Forbidden value in the field name : ClientException (400 error)
curl -v -H "Content-type: application/json" -d '{"name": "doudounou"}' "localhost:8090/chinchilla" | python -m json.tool
400 error code response with body:
{ "canRetry": "false", "error": "Doudounou forbidden as name. Honnor to my doudou" } |
No duplicate allowed in the Chinchilla name : IllegalArgumentException (400 error)
First adding : pass.
curl -v -H "Content-type: application/json" -d '{"name": "doudoun"}' "localhost:8090/chinchilla" | python -m json.tool
second adding : fails !
curl -v -H "Content-type: application/json" -d '{"name": "doudoun"}' "localhost:8090/chinchilla" | python -m json.tool
400 error code response with body:
{ "canRetry": "false", "error": "Name already used" } |
Unexpected error in the server side code : any other RuntimeException (500 error)
Here we don’t type « name » correctly and no @Valid used. So the field is null while the service deferences it. Whereas a NullPointerException thrown.
curl -v -H "Content-type: application/json" -d '{"nnnname": "doudoun"}' "localhost:8090/chinchilla" | python -m json.tool
500 error code response with body:
{ "canRetry": "true", "error": "class java.lang.NullPointerException", "stacktrace": "java.lang.NullPointerException\n\tat davidxxx.component.ChinchillaController.addChinchilla(ChinchillaController.java:52)\n\tat java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)\n\tat ... } |
Override exception handling performed by Spring MVC for common error responses
It is possible by extending ResponseEntityExceptionHandler
and by overriding protected handle methods such as
handleHttpRequestMethodNotSupported
, handleAsyncRequestTimeoutException
, handleMissingPathVariable
and so for …
Example with Method Not Supported :
curl -v -X DELETE -H "Content-type: application/json" -d '{"name": "scoubi"}' "localhost:8090/chinchilla"
Inject and use predefined fields in the rest controller methods
Retrieve the complete url (that is scheme://host:port/context/
)of the spring boot application
The idea : injecting the HttpServletRequest
parameter and using it with ServletUriComponentsBuilder
.
Example : a rest controller that calls the shutdown actuator. Helpful to perform some other tasks before.
@PostMapping(value = "/shutdown", consumes = MediaType.ALL_VALUE) @ResponseBody public ResponseEntity<Void> shutdown(HttpServletRequest request) throws IOException, InterruptedException { // currentUrl : http + host + port + uri String currentUrl = ServletUriComponentsBuilder.fromRequestUri(request) .build() .toUriString(); System.out.println("currentUrl=" + currentUrl); String currentUrlWithoutUri = ServletUriComponentsBuilder.fromRequestUri(request) .replacePath("") .build() .toUriString(); System.out.println("currentUrlWithoutUri=" + currentUrlWithoutUri); |
swagger and openapi
Needed configuration with spring boot 2.7
Add the following dependency:
<dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-ui</artifactId> <version>1.7.0</version> </dependency> |
Artifacts produced by this library
– The OpenAPI definitions in JSON format:
GET /v3/api-docs
– The OpenAPI definitions in YML format:
GET /v3/api-docs.yaml
– Swagger UI:
GET /swagger-ui.html
Swagger customize configuration
Specify the location of the Swagger UI page:
springdoc.swagger-ui.path=/foo-swagger-ui.html