포스트

REST API(6) Event 생성 API 구현 - 여러 Bad Request 처리하기

구현 Package 및 Class

image


입력 값이 없을 때, Bad Request 처리하기

요청 입력값이 없을 때에 대한 Bad Request를 처리하겠다.

EventControllerTests

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// import 생략

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class EventControllerTests {
    @Autowired
    MockMvc mockMvc;
    @Autowired
    ObjectMapper objectMapper;

    // 이 외 테스트 코드 생략  

    @Test
    public void createEvent_Bad_Request_Empty_Input() throws Exception {
        EventDto eventDto = EventDto.builder().build();

        this.mockMvc.perform(post("/api/events")
                        .contentType(MediaType.APPLICATION_JSON_UTF8)
                        .content(this.objectMapper.writeValueAsString(eventDto))
                        )
                .andExpect(status().isBadRequest());
    }
}

  • 위와 같은 테스트 코드를 작성해준다.
  • EventDto에 대한 객체를 Builder Pattern으로 입력값을 아무것도 넣어주지 않은채로 생성한다.
  • 이 입력값이 없는 객체를 넘겨주고 Bad Request 응답이 나오는지 테스트해본다. 하지만…

결과 (실패)

image

  • Bad Requset(400)를 기대했지만 성공 201응답이 나와 테스트가 실패한 것을 볼 수 있다.
  • 이유는 우리가 입력값이 없을 때에 대한 입력값의 검증(Validation) 과 검증 결과 발생한 Error에 대한 처리를 안했기 때문이다.

오류 수정

그러면 이제 입력값이 주어졌을 때, 그 입력을 검증하고, 검증시에 에러(입력값이 없다)가 있으면 그를 처리해보자.

EventDto

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// import 생략
import javax.validation.constraints.Min;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;

@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class EventDto {
    @NotEmpty // 추가
    private String name;
    @NotEmpty // 추가
    private String description;
    @NotNull // 추가
    private LocalDateTime beginEnrollmentDateTime;
    @NotNull // 추가
    private LocalDateTime closeEnrollmentDateTime;
    @NotNull // 추가
    private LocalDateTime beginEventDateTime;
    @NotNull // 추가
    private LocalDateTime endEventDateTime;
    private String location;
    @Min(0) // 추가
    private int basePrice;
    @Min(0) // 추가
    private int maxPrice;
    @Min(0) // 추가
    private int limitOfEnrollment;
}

  • 엔티티(Entity) 검증에 필요한 어노테이션을 작성해준다.

EventController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// import 생략
import org.springframework.validation.Errors;
import javax.validation.Valid;


@Controller
@RequestMapping(value = "/api/events", produces = MediaTypes.HAL_JSON_UTF8_VALUE)
public class EventController {
    private final EventRepository eventRepository;

    private final ModelMapper modelMapper;

    public EventController(EventRepository eventRepository, ModelMapper modelMapper){
        this.eventRepository = eventRepository;
        this.modelMapper = modelMapper;
    }
    @PostMapping
    public ResponseEntity createEvent(@RequestBody @Valid EventDto eventDto, Errors errors){ // 수정

        if (errors.hasErrors()){ // 추가
            return ResponseEntity.badRequest().build(); 
        }

        Event event = modelMapper.map(eventDto, Event.class);

        Event newEvent = this.eventRepository.save(event);

        URI createdUri = linkTo(EventController.class).slash(newEvent.getId()).toUri();
        return ResponseEntity.created(createdUri).body(newEvent);
    }
}

  • @Valid 어노테이션은 Request에 있는 값들(요청값들)을 엔티티에 Binding할 때 검증을 수행한다.
  • 해당 엔티티에 어노테이션(@NotEmpty, @NotNull, @Min(0) … )에 대한 값들을 참고해서 검증을 수행한다.
  • 검증을 수행한 결과를 Errors의 객체에 담아준다.
  • Errors의 객체인 errors에 값이 있으면, Bad Request를 리턴해준다.

결과 (성공)

image


입력값이 이상할 때, Bad Request 처리하기

이번에는 이상한 입력값이 들어왔을 때 Bad Request 처리하는 방법이다.
예를 들어, closeEnrollmentDateTime,endEventDateTime가 각각 beginEnrollmentDateTime, beginEventDateTime보다 빠르다던가, maxPricebasePrice보다 작은 경우이다.


EventControllerTests

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// import 생략


@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class EventControllerTests {
    @Autowired
    MockMvc mockMvc;
    @Autowired
    ObjectMapper objectMapper;

    // 이 외 테스트 코드 생략
    @Test
    public void createEvent_Bad_Request_Wrong_Input() throws Exception{
        EventDto eventDto = EventDto.builder()
                .name("Spring")
                .description("REST API Development With Spring")
                .beginEnrollmentDateTime(LocalDateTime.of(2024,02,22,14,21))
                .closeEnrollmentDateTime(LocalDateTime.of(2024,02,21,14,21))
                .beginEventDateTime(LocalDateTime.of(2024,02,20,14,21))
                .endEventDateTime(LocalDateTime.of(2024,02,19,14,21))
                .basePrice(10000)
                .maxPrice(200)
                .limitOfEnrollment(100)
                .location("강남역 D2 스타트업 팩토리")
                .build();

        this.mockMvc.perform(post("/api/events")
                        .contentType(MediaType.APPLICATION_JSON_UTF8)
                        .content(this.objectMapper.writeValueAsString(eventDto))
                )
                .andExpect(status().isBadRequest());
        ;
    }
}


결과 (실패)

image

  • 이 역시 이상한 입력값에 대한 검증 과정이 없기 때문에 성공 201이 출력된것을 확인 할 수 있다.

오류 수정

따라서, 입력값을 검증해주기 위한 EventValidator라는 클래스를 만들어, 이상한 입력값이 있는지 확인하는 로직을 만들어 검증을 수행할 것이다.


EventValidator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package me.hantomas.restapi.events;

import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;

import java.time.LocalDateTime;

@Component
public class EventValidator {
    public void validate(EventDto eventDto, Errors errors){
        if (eventDto.getBasePrice() > eventDto.getMaxPrice() && eventDto.getMaxPrice() > 0){
            errors.rejectValue("basePrice","wrongValue","BasePrice is wrong");
            errors.rejectValue("maxPrice","wrongValue","MaxPrice is wrong");
        }


        LocalDateTime endEventDateTime = eventDto.getEndEventDateTime();

        if (endEventDateTime.isBefore(eventDto.getBeginEventDateTime()) ||
        endEventDateTime.isBefore(eventDto.getCloseEnrollmentDateTime()) ||
        endEventDateTime.isBefore(eventDto.getBeginEnrollmentDateTime())) {
            errors.rejectValue("endEventDateTime","wrongValue","endEventDateTime is wrong");
        }
    }
}

  • @Component로 이 클래스를 빈으로 등록하여 사용함을 명시한다.
  • 입력값에 대한 조건식으로 Validator를 만들어준다. 검증시 에러는 Error의 객체에 담아준다.
    • eventDto.getBasePrice() > eventDto.getMaxPrice() && eventDto.getMaxPrice() > 0는 basePrice가 100이고 maxPrice가 0인 경우 무제한 경매(높은 금액을 낸 사람이 등록)이기 때문에 이러한 조건식을 사용한다.

EventController 수정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// import 생략

@Controller
@RequestMapping(value = "/api/events", produces = MediaTypes.HAL_JSON_UTF8_VALUE)
public class EventController {
    private final EventRepository eventRepository;

    private final ModelMapper modelMapper;

    private final EventValidator eventValidator; // 추가

    public EventController(EventRepository eventRepository, ModelMapper modelMapper, EventValidator eventValidator) { // 수정
        this.eventRepository = eventRepository; 
        this.modelMapper = modelMapper;
        this.eventValidator = eventValidator; // 추가
    }
    @PostMapping
    public ResponseEntity createEvent(@RequestBody @Valid EventDto eventDto, Errors errors){

        if (errors.hasErrors()){
            return ResponseEntity.badRequest().build();
        }

        eventValidator.validate(eventDto, errors); // 추가
        if (errors.hasErrors()){ // 추가
            return ResponseEntity.badRequest().build();
        }

        Event event = modelMapper.map(eventDto, Event.class);

        Event newEvent = this.eventRepository.save(event);

        URI createdUri = linkTo(EventController.class).slash(newEvent.getId()).toUri();
        return ResponseEntity.created(createdUri).body(newEvent);
    }
}

  • @Component로 등록했던 EventValidator를 생성자 주입 방식으로 주입해주고
  • 입력값 이외의 입력 또는 입력하지 않은 즉, 엔티티의 어노테이션에 위반하는 입력값이 있는지에 대한 검증을 거친 후
  • eventValidator.validate(eventDto, errors); 로 이상한 입력값에 대한 검증을 한번 더 하고.
  • 있으면 Bad Request 리턴

결과 (성공)

image


테스트 설명용 어노테이션 만들기

테스트 메소드명으로 테스트를 설명하는 것으로는 설명하는데 한계가 있다.
테스트 설명용 어노테이션을 만들어 이 테스트가 어떤 테스트인지를 설명할 수 있는 어노테이션을 만들 수 있다.

TestDespcription @interface 만들기

1
2
3
4
5
6
7
8
9
10
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface TestDescription {
    String value();
}
  • @Target(ElementType.METHOD) : 만드는 어노테이션의 타깃을 설정한다. 이 어노테이션은 메소드에 사용할 것이다.
  • @Retention(RetentionPolicy.SOURCE) : 어노테이션을 붙인 코드를 얼마나 오래 가져갈 것인지를 의미한다.
    • RetentionPolicy.SOURCE,RetentionPolicy.CLASS ,RetentionPolicy.RUNTIME 가 있다.
  • String value(); : 설명은 String이다.

EventControllerTests에 어노테이션 적용하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import me.hantomas.restapi.common.TestDescription; // 추가


@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class EventControllerTests {
    @Autowired
    MockMvc mockMvc;
    @Autowired
    ObjectMapper objectMapper;

    @Test
    @TestDescription("정상적으로 이벤트를 생성하는 테스트") // 추가
    public void createEvent() throws Exception{
        // 생략
    }
    @Test
    @TestDescription("입력 받을 수 없는 값을 사용한 경우에 에러가 발생하는 테스트") // 추가
    public void createEvent_Bad_Request() throws Exception{
        // 생략
    }
    @Test
    @TestDescription("입력 값이 비어있는 경우에 에러가 발생하는 테스트") // 추가
    public void createEvent_Bad_Request_Empty_Input() throws Exception {
        // 생략
    }
    @Test
    @TestDescription("입력값이 잘못된 경우에 에러가 발생하는 테스트") // 추가
    public void createEvent_Bad_Request_Wrong_Input() throws Exception{
        // 생략
    }
}

  • 주석을 달아 설명하는 방법도 있지만, 이런 어노테이션을 사용하는 방법도 있다.

Bad Request 응답 본문에 메세지 출력

Bad Request 응답 본문에 에러에 대한 정보를 출력해보자.

EventControllerTests 수정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// import 생략

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class EventControllerTests {
    @Autowired
    MockMvc mockMvc;
    @Autowired
    ObjectMapper objectMapper;

    // 이 외 테스트 코드 생략

    @Test
    @TestDescription("입력값이 잘못된 경우에 에러가 발생하는 테스트")
    public void createEvent_Bad_Request_Wrong_Input() throws Exception{
        EventDto eventDto = EventDto.builder()
                .name("Spring")
                .description("REST API Development With Spring")
                .beginEnrollmentDateTime(LocalDateTime.of(2024,02,22,14,21))
                .closeEnrollmentDateTime(LocalDateTime.of(2024,02,21,14,21))
                .beginEventDateTime(LocalDateTime.of(2024,02,20,14,21))
                .endEventDateTime(LocalDateTime.of(2024,02,19,14,21))
                .basePrice(10000)
                .maxPrice(200)
                .limitOfEnrollment(100)
                .location("강남역 D2 스타트업 팩토리")
                .build();

        this.mockMvc.perform(post("/api/events")
                        .contentType(MediaType.APPLICATION_JSON_UTF8)
                        .content(this.objectMapper.writeValueAsString(eventDto))
                )
                .andDo(print()) // 추가
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$[0].objectName").exists()) // 추가
                .andExpect(jsonPath("$[0].defaultMessage").exists()) // 추가
                .andExpect(jsonPath("$[0].code").exists()) // 추가 
        ;
    }
}

에러에 대한 정보는 모두 Error의 객체 errors에 들어있다.
다음과 같이 디버그 포인트를 찍고 디버깅을 해보자.
image
image
위 와 같이 에러에 대한 정보를 확인할 수 있다.


EventController

그럼 errors객체를 body(errors)처럼 Body에 담아 출력하면 되지 않을까?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// import 문 생략

@Controller
@RequestMapping(value = "/api/events", produces = MediaTypes.HAL_JSON_UTF8_VALUE)
public class EventController {
    private final EventRepository eventRepository;

    private final ModelMapper modelMapper;

    private final EventValidator eventValidator;

    public EventController(EventRepository eventRepository, ModelMapper modelMapper, EventValidator eventValidator) {
        this.eventRepository = eventRepository;
        this.modelMapper = modelMapper;
        this.eventValidator = eventValidator;
    }
    @PostMapping
    public ResponseEntity createEvent(@RequestBody @Valid EventDto eventDto, Errors errors){

        if (errors.hasErrors()){
            return ResponseEntity.badRequest().body(errors); // 수정
        }

        eventValidator.validate(eventDto, errors);
        if (errors.hasErrors()){
            return ResponseEntity.badRequest().body(errors); // 수정
        }

        Event event = modelMapper.map(eventDto, Event.class);

        Event newEvent = this.eventRepository.save(event);

        URI createdUri = linkTo(EventController.class).slash(newEvent.getId()).toUri();
        return ResponseEntity.created(createdUri).body(newEvent);
    }
}

그에 대한 정답은 No 이다. 이유는 errors객체는 Json형태로 변환 할 수 없기 때문이다. image

그렇다면, 왜 event 객체는 Json형태로 변환이 되는데, errors객체는 그렇지 못할까?
그 이유는 event객체는 자바 빈 스펙을 준수한 객체이므로 ObjectMapper에 등록되어있는 여러가지 Serializer중 BeanSerializer를 통해서 Json형태로 변환이 가능하지만, errors객체는 자바 빈 스펙을 준수한 객체가 아니므로 불가능하다. 따라서,이를 해결하기 위해서는 커스터마이즈된 Serializer가 필요하다. 우리는 errors를 Serialize할 ErrorSerializer 클래스를 만들어 줄 것이다.


EventValidator

우선, EventValidator코드를 다시 한번 보겠다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package me.hantomas.restapi.events;

import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;

import java.time.LocalDateTime;

@Component
public class EventValidator {
    public void validate(EventDto eventDto, Errors errors){
        if (eventDto.getBasePrice() > eventDto.getMaxPrice() && eventDto.getMaxPrice() > 0){
            //errors.rejectValue("basePrice","wrongValue","BasePrice is wrong");
            //errors.rejectValue("maxPrice","wrongValue","MaxPrice is wrong");
            errors.reject("wrongPrices", "Value of prices are wrong"); // 수정
        }


        LocalDateTime endEventDateTime = eventDto.getEndEventDateTime();

        if (endEventDateTime.isBefore(eventDto.getBeginEventDateTime()) ||
        endEventDateTime.isBefore(eventDto.getCloseEnrollmentDateTime()) ||
        endEventDateTime.isBefore(eventDto.getBeginEnrollmentDateTime())) {
            errors.rejectValue("endEventDateTime","wrongValue","endEventDateTime is wrong");
        }
    }
}

에러에는 각 값에 대한 에러인 FieldError와 여러가지 값들의 조합해서 발생한 에러인 GlobalError가 있다.
위 코드에서 보면, rejectValue()를 사용하면 FieldError로 들어가게 된다.
그냥 reject()를 사용하면, GlobalError로 들어가게 된다.


ErrorSerializer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package me.hantomas.restapi.common;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import org.springframework.boot.jackson.JsonComponent;
import org.springframework.validation.Errors;

import java.io.IOException;
@JsonComponent
public class ErrorSerializer extends JsonSerializer<Errors> {

    @Override
    public void serialize(Errors errors, JsonGenerator gen, SerializerProvider serializerProvider) throws IOException {
        gen.writeStartArray();
        errors.getFieldErrors().forEach(e ->{
            try {
                gen.writeStartObject();
                gen.writeStringField("field", e.getField());
                gen.writeStringField("objectName", e.getObjectName());
                gen.writeStringField("code", e.getCode());
                gen.writeStringField("defaultMessage", e.getDefaultMessage());
                Object rejectedValue = e.getRejectedValue();
                if (rejectedValue != null){
                    gen.writeStringField("rejectedValue", rejectedValue.toString());
                }
                gen.writeEndObject();
            } catch (IOException ex) {
                ex.printStackTrace();
            }
        });
        errors.getGlobalErrors().forEach(e ->{
        try{
            gen.writeStartObject();
            gen.writeStringField("objectName", e.getObjectName());
            gen.writeStringField("coe", e.getCode());
            gen.writeStringField("defailMessage", e.getDefaultMessage());
            gen.writeEndObject();
        } catch (IOException ex) {
            ex.printStackTrace();
        }
        });
        gen.writeEndArray();
    }
}

  • @JsonComponent: 해당 Serializer를 ObjectMapper에 등록해준다. SpringBoot가 제공하는 기능.
  • Errors의 객체들을 Serialize 해줄 extends JsonSerializer<Errors>를 상속 받는다.
  • serialize()를 오버라이딩 해준다.
  • FieldErrorGlobalError들을 Json Object를 만들어 담아준다.

결과 (성공)

image
위와 같이 400상태 뿐만 아니라 에러 메세지도 확인해 볼 수 있다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.