-1

please bear with me. I am new to java spring boot application.

I have a Student model with attribute int age

@Entity
@Table(name = "students")
public class Student {

    @NotNull
    @Column(name = "age")
    @Positive
    @Digits(fraction = 0, integer = 10, message ="add a digit msg")
    private int age;

    // more codes below
}

Controller

package com.crudtest.demo.controller;

import com.crudtest.demo.model.Student;
import com.crudtest.demo.service.StudentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;

@RestController
@RequestMapping("/api")
public class StudentController {

   @Autowired
   private StudentService studentService;

   @PostMapping("/students")
    public ResponseEntity<Student> createStudent(@Valid @RequestBody Student student) {
    Student stud = studentService.create(student);
    return new ResponseEntity<>(stud, HttpStatus.CREATED);
 }
}

Service

@Service
public class StudentService {

  @Autowired
  private StudentRepository studentRepository;

  public Student create(Student student) {
     return studentRepository.save(student);
  }
}

Repository

@Repository
public interface StudentRepository extends JpaRepository<Student, Long> 
{}

On my request, if I put age as an integer, there is no error, but if I put a number with string, it returns 1.

This is the postman result:

https://i.stack.imgur.com/aGBxH.png

I also tried to add my custom annotation here:

package //
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import javax.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.FIELD) // inside field
@Retention(RetentionPolicy.RUNTIME)
public @interface AgeValidation {
   String message() default "Age must be a number and not greater than 200.";

  Class<?>[] groups() default {};

  public abstract Class<? extends Payload>[] payload() default {};
}

class AgeValidationInterface implements ConstraintValidator<AgeValidation, Integer> {

  @Override
  public boolean isValid(Integer age, ConstraintValidatorContext constraintValidatorContext) {
    return age > 0 && age < 200;
  }
}

And also updated

//Student.java
@AgeValidation
private int age;

But the same error result.

How can I validate this using a custom validation?

smzapp
  • 809
  • 1
  • 12
  • 33

3 Answers3

0

The reason why this is not working, has to do with the type conversions Spring attempts to do (through Jackson) when attempting to deserialize your posted payload into a concrete object (i.e Student).

Your payload is expected to contain either "1" or 1 that are eligible for deserialization (as they are valid numbers). The provided string is not a valid number representation, hence the process fails with an unhandled exception which in turn causes the 'blank' response.

You need to ensure that your provided JSON payload is well formatted and that you have a good way of handling unexpected exceptions.

akortex
  • 5,067
  • 2
  • 25
  • 57
0

The question can be split into

  • Q1: how to distinguish exceptions between
    • data binding when conversion http request body to object
    • customized validation defined as annotation on that object
  • Q2: how to display valuable information when data binding fails

All the code can be found in this repo. All test code related to this topic is under package com.example.demo.validation.

The main data structure to be used:

@Data
public class Person {

  @Digits(integer = 200, fraction = 0, message = "code should be number and no larger than 200")
  private int ageInt;

  @Digits(integer = 200, fraction = 0, message = "code should be number and no larger than 200")
  private String ageString;
}

The project can be run with command:

  • gradlew clean bootRun

1 Distinguish Exceptions

It is possible to achieve this by ControllerAdvice. The most important thing is to find the concise exception class thrown, and in our case, it is org.springframework.http.converter.HttpMessageNotReadableException.

package com.example.demo.validation;

import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.stereotype.Component;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;

@Component
@ControllerAdvice
public class CustomExceptionHandlerResolver {

  private static final int COMMON_PARAMETER_ERROR_CODE = 42;

  /**
   * For errors from data binding, for example, try to attach "hello" to a int field. Treat this as http-level error,
   * so response with http code 400.
   * <p>
   * - Error messages are attached to message field of response body
   * - code field of response body is not important and also assigned with 400
   * <p>
   * Another option is totally use http concept and attach error message to an http head named error, thus no need to
   * initiate a DataContainer object.
   * <p>
   * Example text from exception.getMessage():
   * <p>
   * - JSON parse error: Cannot deserialize value of type `int` from String "1a": not a valid `int` value....
   */
  @ExceptionHandler
  @ResponseBody
  @ResponseStatus(HttpStatus.BAD_REQUEST)
  public DataContainer<?> handleBindException(
      HttpServletRequest request, HttpServletResponse response, HttpMessageNotReadableException exception) {
    System.out.println("In handleBindException");
    System.out.println(exception);
    return new DataContainer(400, exception.getMessage());
  }

  /**
   * For errors from regulation violation defined in annotations such as @NotBlank with @Valid aside the @RequestBody
   * object. Treat this as business error, so response with http code 200.
   * <p>
   * - Error messages defined in validation annotations are attached to message field of response body
   * - code field of response body is important and should be defined in the whole API system
   */
  @ExceptionHandler
  @ResponseBody
  @ResponseStatus(HttpStatus.OK)
  protected DataContainer handleMethodArgumentNotValidException(
      HttpServletRequest request, HttpServletResponse response, MethodArgumentNotValidException ex)
      throws IOException {
    System.out.println("In handleMethodArgumentNotValidException");
    List<FieldError> errors = ex.getBindingResult().getFieldErrors();
    String errorMessages = errors.stream()
        .map(FieldError::getDefaultMessage)
        .collect(Collectors.joining(";"));
    return new DataContainer(COMMON_PARAMETER_ERROR_CODE, errorMessages);
  }

}

2 Display Valuable Information for Data Binding Exception

The text got from HttpMessageNotReadableException is created by the spring framework and is kinda robotic. We can use customize json deserializer to make the message more readable. Jackson itself doesn't support customized information to be thrown in data binding fail yet.

Add a field to Person:

  @JsonDeserialize(using = MyIntDeserializer.class)
  private int ageStringWithCustomizeErrorMessage;
class MyIntDeserializer extends JsonDeserializer<Integer> {

  @Override
  public Integer deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException {
    String text = p.getText();
    if (text == null || text.equals("")) {
      return 0;
    }

    int result;
    try {
      result = Integer.parseInt(text);
    } catch (Exception ex) {
      throw new RuntimeException("ageStringWithCustomizeErrorMessage must be number");
    }

    if (result < 0 || result >= 200) {
      throw new RuntimeException("ageStringWithCustomizeErrorMessage must in (0, 200)");
    }

    return result;
  }
}

3 Test Step

3.1 Test Validation Fail

Request:

  • curl -X POST "http://localhost:8080/validationTest" -H "accept: /" -H "Content-Type: application/json" -d "{ "ageInt": "0", "ageString": "1a"}"

Response:

{
  "code": 42,
  "message": "code should be number and not larger than 200",
  "data": null
}

3.2 Test Data Binding Fail

Request:

  • curl -X POST "http://localhost:8080/validationTest" -H "accept: /" -H "Content-Type: application/json" -d "{ "ageInt": "1a", "ageString": "0"}"

Response:

{
  "code": 400,
  "message": "JSON parse error: Cannot deserialize value of type `int` from String \"1a\": not a valid `int` value; nested exception is com.fasterxml.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `int` from String \"1a\": not a valid `int` value\n at [Source: (PushbackInputStream); line: 2, column: 13] (through reference chain: com.example.demo.validation.Person[\"ageInt\"])",
  "data": null
}

3.3 Test Data Binding in Customized Deserializer

Request:

  • curl -X POST "http://localhost:8080/validationTest" -H "accept: /" -H "Content-Type: application/json" -d "{ "ageInt": 0, "ageString": "0", "ageStringWithCustomizeErrorMessage": "aa"}"

Response:

{
  "code": 400,
  "message": "JSON parse error: ageStringWithCustomizeErrorMessage must be number; nested exception is com.fasterxml.jackson.databind.JsonMappingException: ageStringWithCustomizeErrorMessage must be number (through reference chain: com.example.demo.validation.Person[\"ageStringWithCustomizeErrorMessage\"])",
  "data": null
}

Request:

  • curl -X POST "http://localhost:8080/validationTest" -H "accept: /" -H "Content-Type: application/json" -d "{ "ageInt": 0, "ageString": "0", "ageStringWithCustomizeErrorMessage": "-1"}"

Response:

{
  "code": 400,
  "message": "JSON parse error: ageStringWithCustomizeErrorMessage must in (0, 200); nested exception is com.fasterxml.jackson.databind.JsonMappingException: ageStringWithCustomizeErrorMessage must in (0, 200) (through reference chain: com.example.demo.validation.Person[\"ageStringWithCustomizeErrorMessage\"])",
  "data": null
}

It's still a little robotic, but with more concise information offered by code.

3.4 Tips

Open http://localhost:8080/swagger-ui/#/validation-test-controller/validationTestUsingPOST in browser and all requests can be made in web page easily.

Lebecca
  • 2,406
  • 15
  • 32
  • How can I implement this to my code to display `Age must be a number and not greater than 200.`? I guess, this is a generic answer which displays error messages based on validation-api. – smzapp Jul 30 '21 at 12:37
  • Your java project is just the backend part, I suggest you use `json` to communicate between the backend and frontend. And when your frontend js code gets the response, you can do whatever display things to the text information. In this demo, the error meesage is set in the `DataContainer` class, you can define this class with int `code` and string `message` field. You can test it in your postman and to see the response. – Lebecca Jul 30 '21 at 12:42
  • I see, you should define your regulation like `@Digits(fraction = 0, integer = 10, message ="Age must be a number and not greater than 200.")` – Lebecca Jul 30 '21 at 12:45
  • Is `DataContainer` and `CommonCode` java classes or classes I should create? It has an error. https://i.imgur.com/9RwsKMh.png – smzapp Jul 30 '21 at 12:54
  • Yes, you should create the class. It's just a snippet from my code and obeys my code style, not include all the things. – Lebecca Jul 30 '21 at 12:55
  • I updated the answer to make suggestion to API design. – Lebecca Jul 30 '21 at 13:00
  • Still doesn't work. https://i.imgur.com/zPRwVCF.png – smzapp Jul 30 '21 at 13:10
  • If you define age as int, the deserialize part happens before the check of the annotation you defined and is not under our control. If you define it as a string, the check can be in our control but is a little overhead. – Lebecca Jul 30 '21 at 13:13
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/235453/discussion-between-lebecca-and-smzapp). – Lebecca Jul 30 '21 at 13:27
0

How can I validate this using a custom validation?

Few suggestions:

  1. In Student-model type of age is int, Which isn't correct here. Either you know age (an exact number) Or you don't i.e. NULL. It can't be zero 0. As a thumb rule avoid primitive data types in models.
    So instead of int you should be using Integer here, Which can be null/0 or any-number.
  2. (I know it's a demo) Be very cautions while using @Entity over models which you are also using for external communication. Check this out Difference between Entity and DTO.

Solution: We will be using custom deserializer for Interger field inputs.

Note: I am using Lombok (for boilerplate code) here.

Create custom error response class:

@Data
public class ApiErrorResponse {
    private final Integer code;
    private final String message;
}

Create custom type-mismatch exception class:

Public class TypeMismatchException extends RuntimeException {
    public TypeMismatchException(String errorMessage) {
        super(errorMessage);
    }
}

Create exception handler:

@ControllerAdvice
class ApiExceptionHandler {

    @ExceptionHandler(TypeMismatchException.class)
    public ResponseEntity<ApiErrorResponse> handleTypeMismatchException(TypeMismatchException e) {
        ApiErrorResponse error = new ApiErrorResponse(HttpStatus.BAD_REQUEST.value(), e.getMessage());
        return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiErrorResponse> handleInValidArgumentException(MethodArgumentNotValidException e) {
        ApiErrorResponse error = new ApiErrorResponse(HttpStatus.BAD_REQUEST.value(), e.getFieldError().getDefaultMessage());
        return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
    }
}

Create custom deserializer (check JsonDeserializer)

public class CustomIntegerSerializer extends JsonDeserializer<Integer> {

    @Override
    public Integer deserialize(JsonParser jsonParser, DeserializationContext context) throws IOException {
        try {
            return jsonParser.readValuesAs(Integer.class).next();
        } catch (Exception e) {
            throw new TypeMismatchException(jsonParser.currentName() + " must be a number.");
        }
    }
}

Create Student model:

@Entity
@Data
@Table(name = "students")
public class Student {

    @Id
    @GeneratedValue
    private UUID id;

    @Column(name = "age")
    @Max(value = 200, message = "Age must not be greater than 200.")
    @JsonDeserialize(using = CustomIntegerSerializer.class)
    private Integer age;

    @Column(name = "marks")
    @Max(value = 100, message = "Marks must not be greater than 100.")
    private Integer marks;

    @Column(name = "role_number")
    @JsonDeserialize(using = CustomIntegerSerializer.class)
    private Integer roleNumber;

}

Note:

  • I am using 2 more Integer fields here to have more variety of validation cases.
  • Just for ease of understanding, I am also using @Entity here (though not recommended).
  • Added @JsonDeserialize on age and roleNumber. Just to show that this solution can work with multiple Integer fields.
  • Not using CustomIntegerSerializer for field marks. Just to show that our implementation isn't breaking default spring error handling of TypeMismatchException.

Request/Response:

  • Case: Input age is not a number enter image description here
  • Case: Input age is greater than 200 enter image description here
  • Case: Input marks is not a number (Will be handled by spring) enter image description here
  • Case: Input marks is greater than 100 enter image description here
  • Case: Input roleNumber is not a number enter image description here
  • Case: Valid inputs enter image description here
100rabh
  • 142
  • 9