ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 3. 스프링 Custom Validation - 좀 더 친절한 Error Response
    스프링개발자/203 - Validation 2020. 8. 10. 04:44

    [배경]

    Validation Error(4XX)에서는 가능한 많은 정보를 제공해주면, client입장에서 감동을 받는다

    client request의 어떤 필드 값이 잘못되었는지, 어떻게 되어야 하는지 등등 

    다음의 트위터 developer docs을 참고해보자

    https://developer.twitter.com/en/docs/basics/response-codes

     

    Response codes

    The standard Twitter API returns HTTP status codes in addition to JSON-based error codes and messages. HTTP status codes The Twitter API attempts to return appropriate HTTP status codes for every request. Code Text Description 200 OK Success! 304 Not Modif

    developer.twitter.com


    1. 에러메세지와 에러코드

    트위터에서 발췌

    우리는 message와 code이외에 value를 넣도록 한다

     

    2. ValidationException을 정의하자

    RunTimeException와 비슷한, 하지만 우리만의 Validation Response Object를 return하기 위한 예외를 정의하자

    @ResponseStatus를 이용하여 HttpStatus 400에러가 나오면 ValidationException이 발생되도록 하자

    tracking 하는 값으로는 이전 글에서 만든 ValidationErrorResponse를 쓴다

    상황에 맞게 몇가지 constructor를 만든다.

    package me.ndPrince.routeOptimization.exception;
    
    import lombok.Getter;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.http.HttpStatus;
    import org.springframework.web.bind.annotation.ResponseStatus;
    
    import java.util.List;
    
    @ResponseStatus(code = HttpStatus.BAD_REQUEST)
    @Getter
    @Slf4j
    public class ValidationException extends RuntimeException {
    
        private List<ValidationErrorResponse> validationErrors;
    
        public ValidationException(String message) {
            super(message);
        }
    
        public ValidationException(List<ValidationErrorResponse> errors, String requestId){
            this.validationErrors = errors;
            log.warn("The following request {} has validation exceptions", requestId);
        }
    }
    

    3. ValidationAdvisor를 만들자

    앞의 글에서 만든 ControllerAdvice 어노테이션을 쓰는 클래스를 만들자

    package me.ndPrince.routeOptimization.exception;
    
    import org.springframework.http.HttpStatus;
    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;
    
    @ControllerAdvice
    public class ValidationExceptionHandler {
    
        @ResponseStatus(HttpStatus.BAD_REQUEST)
        @ExceptionHandler(ValidationException.class)
        @ResponseBody
        public ValidationErrorResponse handleValidationExceptions(ValidationException e) {
            return ValidationErrorResponse.builder()
                    .build();
        }
    }
    

     두가지의 다른점은,

    1) @ExceptionHandler 어노테이션을 이용하여 ValidationException클래스를 ExceptionHandler로 받는 다는 점

    2) RunTimeException의 하나인 ValidationException을 argument로 받는다는 점

     

    4. ValidationErrorResponse에 살을 붙이자

    이전 글에서 만든 VadliationErrorResponse는 위와 같이 단순하다.

    글 도입부의 배경에서 보았던 트위터 예제처럼, 우리도 ErrorMessage와 ErrorCode를 넣자

     

    결과물은 다음과 같다.

    package me.ndPrince.routeOptimization.exception;
    
    import lombok.Builder;
    import lombok.Data;
    import me.ndPrince.routeOptimization.model.ValidationError; 
    // 이전 글에서는 스프링에서 제공하는 ValidationErrors를 썼고 여기는 우리가 만든 ValidationError이다
    
    import java.util.List;
    
    @Builder
    @Data
    public class ValidationErrorResponse {
        private String timestamp;
        private Integer status;
        private List<ValidationError> validationErrorsList;
    }
    

    ValidationError 컨테이너 클래스에 넣고 싶은 만큼 정보를 넣자.

    package me.ndPrince.routeOptimization.model;
    
    import lombok.AllArgsConstructor;
    import lombok.Getter;
    
    @Getter
    @AllArgsConstructor
    public class ValidationError {
    
        private final Type type;
        private final String field;
        private final String message;
        private final Integer errorCode;
    
        public enum Type{
            SPRING_VALIDATION,
            BAD_RIDE,
            BAD_VEHICLE
        }
    }
    

    5. ValidationError를 실제로 불러오는 로직을 구현한다 (총 3가지 클래스를 구현한다)

    먼저는 DispatchController를 다음과 같이 바꾼다.

    Service를 하나 추가해서 Service에 Validate 로직이 있다. 리턴 로직은 아직 손대지 않았다.

    package me.ndPrince.routeOptimization;
    
    import me.ndPrince.routeOptimization.model.DispatchRequest;
    import me.ndPrince.routeOptimization.model.DispatchResponse;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RequestBody;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import javax.validation.Valid;
    
    @RestController
    @RequestMapping(value = "/dispatch")
    public class DispatchController {
    
        @Autowired
        private DispatchService dispatchService;
    
        @PostMapping
        public DispatchResponse postDispatchRequest(@Valid @RequestBody DispatchRequest request){
            dispatchService.validate(request);
            return new DispatchResponse();
        }
    }
    

    DispatchService 클래스는 다음과 같다

    package me.ndPrince.routeOptimization;
    
    import me.ndPrince.routeOptimization.exception.ValidationException;
    import me.ndPrince.routeOptimization.model.DispatchRequest;
    import me.ndPrince.routeOptimization.model.ValidationError;
    import org.springframework.stereotype.Service;
    
    import java.util.ArrayList;
    import java.util.List;
    import java.util.stream.Collectors;
    import java.util.stream.Stream;
    
    @Service
    public class DispatchService {
        public void validate(DispatchRequest request) {
    
            List<ValidationError> errorList = new ArrayList<>();
    
            request.getRides().stream()
                    .flatMap(r -> Stream.of(r.getPickup(), r.getDropOff()))
                    .collect(Collectors.toSet())
                    .forEach(location -> validateLocations(location, errorList));
    
            if(!errorList.isEmpty()){
                throw new ValidationException(errorList, request.getId());
            }
        }
    
        public void validateLocations(Location location, List<ValidationError> errors){
            Double longitude = location.getLongitude();
            Double latitude = location.getLatitude();
    
            boolean isLonValid = (-180d <= longitude) && ( longitude <= 180d);
            boolean isLatValid = (-90d <= latitude) && ( latitude <= 90d);
    
            if(!isLatValid) errors.add(new ValidationError(ValidationError.Type.BAD_RIDE, "Location", "Invalid Lat Location. Check your Lat", 1));
            if(!isLonValid) errors.add(new ValidationError(ValidationError.Type.BAD_RIDE, "Location", "Invalid Lon Location. Check your Lon", 1));
        }
    }
    

     

    일단은 Ride에 있는 pickup 과 dropOff Location의 coordinate만 검사하는 로직이다. 향후에 추가 가능하도록 코드를 깔끔하게 짰다. 향후에 stream관련해서 포스팅을 해야겠다.

     

    그리고 마지막으로 DispatchRequest에 id값을 추가했다.

    package me.ndPrince.routeOptimization.model;
    
    import lombok.Builder;
    import lombok.Data;
    
    import javax.validation.Valid;
    import javax.validation.constraints.NotNull;
    import java.util.List;
    
    @Data
    @Builder
    public class DispatchRequest {
    
        @Builder.Default
        @NotNull
        protected String id;
    
        @NotNull(message = "this field must not be null")
        @Valid
        protected List<RideRequest> rides;
    
        protected List<VehicleRequest> vehicles;
    }
    

    6. Postman 검증

    다음의 json request를 보내보자

    {
        "id": "1",
        "rides": [
            {
                "rideId": "1",
                "pickup": {
                    "latitude":  0.9,
                    "longitude": 0.2
                },
                "dropOff": {
                    "latitude": 0.1,
                    "longitude": 0.5
                },
                "capacity": 5
            }
        ]
    }

    json request에 에러가 있는 값을 전달한다

    {
        "id": "1",
        "rides": [
            {
                "rideId": "1",
                "pickup": {
                    "latitude": -999.9,
                    "longitude": 0.2
                },
                "dropOff": {
                    "latitude": 0.1,
                    "longitude": 535.5
                },
                "capacity": 5
            }
        ]
    }

     

    최대한 많은 정보를 담은 친절한 Error Response가 탄생되었다~

    심심하신 분들은 coordiate 에러가 pickup에서 오는 것인지, dropOff에서 오는 것인지 구분하는 로직을 구현해보자.

     

    소스코드

    https://github.com/2ndPrince/routeOptimization/tree/spring_validation_3

     

    5편으로 계획했던 스프링개발자103 Validation 시리즈가 생각보다 일찍 끝났다. 3,4,5편을 여기에 한꺼번에 넣다보니, 생각보다 복잡하지 않아서이다. 여튼 Validation 시리즈 끝!

     

    ----------------------

    [내용 추가]

    validationErrorsList에 value를 넣지 않았다.

    value가 있으면, client 입장에서는 request의 무엇이 잘못되었는지 직관적으로 알 수 있다. 다음과 같이 수정한다.

    String value로 하는 것이 어떤 데이터 타입의 값도 쉽게 String으로 변환가능해서 편하다.

    public void validateLocations(Location location, List<ValidationError> errors){
            Double longitude = location.getLongitude();
            Double latitude = location.getLatitude();
    
            boolean isLonValid = (-180d <= longitude) && ( longitude <= 180d);
            boolean isLatValid = (-90d <= latitude) && ( latitude <= 90d);
    
            if(!isLatValid) errors.add(new ValidationError(
            ValidationError.Type.BAD_RIDE, "Location", 
            "Invalid Lat Location. Check your Lat", 1, latitude.toString()));
            
            if(!isLonValid) errors.add(new ValidationError(
            ValidationError.Type.BAD_RIDE, "Location", 
            "Invalid Lon Location. Check your Lon", 1, longitude.toString()));
        }

    validate method또한 위처럼 수정해준뒤, postman확인

    value값이 보인다. 추가 끝.

    https://github.com/2ndPrince/routeOptimization/tree/spring_validation_3_value_added

Designed by Tistory.