floriank
floriank

Reputation: 25698

Spring Problem Detail Response - How to add custom fields?

Stauts Quo & What I want

I'm trying to customize the Error Details response by including the validation errors. I'm running Spring Boot 3.4 and Java 21.

ProblemDetail::setProperty() is supposed to do this according to the documentation:

When Jackson JSON is present on the classpath, any properties set here are rendered as top level key-value pairs in the output JSON. Otherwise, they are rendered as a "properties" sub-map.

I've added all the dependencies, I've doubled checked them in IntelliJ and Maven dependency:list, they are there.

[INFO] com.fasterxml.jackson.core:jackson-databind:jar:2.18.1:compile -- module com.fasterxml.jackson.databind [INFO] com.fasterxml.jackson.core:jackson-core:jar:2.18.1:compile -- module com.fasterxml.jackson.core [INFO] com.fasterxml.jackson.core:jackson-annotations:jar:2.18.1:compile -- module com.fasterxml.jackson.annotation

Problem

My problem is, it doesn't work, it seems to fail to detect Jackson and keeps using properties instead of adding it on the top level. Any ideas what I'm missing?

Code

Maven Dependencies:

    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
        <version>2.18.1</version>
    </dependency>
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-core</artifactId>
        <version>2.18.1</version>
    </dependency>
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-annotations</artifactId>
        <version>2.18.1</version>
    </dependency>

Generated JSON

Notice the properties, the errors and timestamp should be on the top level, not below properties, according to the documentation.

{
   "type":"http://localhost:8080/errors/bad-request",
   "title":"Bad request",
   "status":400,
   "detail":null,
   "instance":"/api/sign-up",
   "properties":{
      "timestamp":"2024-12-22T23:07:11.789380900Z",
      "errors":[
         {
            "detail":"Password confirmation is required",
            "pointer":"#/passwordConfirmation"
         },
         {
            "detail":"Password is required",
            "pointer":"#/password"
         },
         {
            "detail":"Email is required",
            "pointer":"#/email"
         },
         {
            "detail":"Organization is required",
            "pointer":"#/organization"
         },
         {
            "detail":"First name is required",
            "pointer":"#/firstName"
         },
         {
            "detail":"Last name is required",
            "pointer":"#/lastName"
         }
      ]
   }
}

Controller Signature:

  @PostMapping( Routes.API_SIGN_UP)
  @Transactional
  public ResponseEntity<?> postSignUp(
      @Validated @RequestBody UserRegistrationTransfer userRegistrationTransfer
  ) {

Exception Handler:

import java.net.URI;
import java.time.Instant;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import java.util.*;

@ControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

  @Override
  protected ResponseEntity<Object> handleMethodArgumentNotValid(
      MethodArgumentNotValidException ex,
      HttpHeaders headers,
      HttpStatusCode status,
      WebRequest request
  ) {
    return ResponseEntity
        .status(status.value())
        .body(handleMethodArgumentNotValidException(ex));
  }

  private ProblemDetail handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
    List<Map<String, String>> details = getErrorsDetails(ex);
    ProblemDetail problemDetail = ProblemDetail.forStatus(ex.getStatusCode());
    problemDetail.setType(URI.create("http://localhost:8080/errors/bad-request"));
    problemDetail.setTitle("Bad request");
    problemDetail.setInstance(ex.getBody().getInstance());

    // adding more data using the Map properties of the ProblemDetail
    problemDetail.setProperty("timestamp", Instant.now().toString());
    problemDetail.setProperty("errors", details);

    return problemDetail;
  }

  private List<Map<String, String>> getErrorsDetails(MethodArgumentNotValidException ex) {
    return ex.getBindingResult().getAllErrors().stream()
        .map(this::toErrorMap)
        .toList();
  }

  private Map<String, String> toErrorMap(ObjectError error) {
    Map<String, String> result = new LinkedHashMap<>();
    result.put("detail", error.getDefaultMessage());

    if (error instanceof FieldError fieldError) {
      result.put("pointer", "#/" + fieldError.getField());
      return result;
    }

    result.put("pointer", "#/");
    return result;
  }
}

Upvotes: 0

Views: 122

Answers (0)

Related Questions