Spring Boot Rest

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

Ce contenu a été publié dans Non classé. Vous pouvez le mettre en favoris avec ce permalien.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *