REST API(6) Event 생성 API 구현 - 여러 Bad Request 처리하기
구현 Package 및 Class
입력 값이 없을 때, 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 응답이 나오는지 테스트해본다. 하지만…
결과 (실패)
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
를 리턴해준다.
결과 (성공)
입력값이 이상할 때, Bad Request 처리하기
이번에는 이상한 입력값이 들어왔을 때 Bad Request 처리하는 방법이다.
예를 들어, closeEnrollmentDateTime
,endEventDateTime
가 각각 beginEnrollmentDateTime
, beginEventDateTime
보다 빠르다던가, maxPrice
가 basePrice
보다 작은 경우이다.
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());
;
}
}
결과 (실패)
- 이 역시 이상한 입력값에 대한 검증 과정이 없기 때문에 성공
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
리턴
결과 (성공)
테스트 설명용 어노테이션 만들기
테스트 메소드명으로 테스트를 설명하는 것으로는 설명하는데 한계가 있다.
테스트 설명용 어노테이션을 만들어 이 테스트가 어떤 테스트인지를 설명할 수 있는 어노테이션을 만들 수 있다.
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
에 들어있다.
다음과 같이 디버그 포인트를 찍고 디버깅을 해보자.
위 와 같이 에러에 대한 정보를 확인할 수 있다.
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형태로 변환 할 수 없기 때문이다.
그렇다면, 왜 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()
를 오버라이딩 해준다.FieldError
와GlobalError
들을 Json Object를 만들어 담아준다.