REST API - 이벤트 생성 API 개발

박상훈·2022년 4월 27일
0
post-thumbnail

REST API 코드 : 깃헙

2-1.이벤트 API 테스트 클래스 생성, 2-2.201 응답 받기


간단한 HTTP API 를 생성하고 요청을 받은 경우 created(201) 상태와 내용을 응답 한다
이번 파트에서는 특별한 내용이 없어 객체와 메시지(메서드) 의 역할만 정리

테스트 작성할 때 강의에서 사용하는 미디어 타입을 아래와 같이 변경함
(Deprecated) MediaType.APPLICATION_JSON_UTF8 -> MediaType.APPLICATION_JSON

RequestMapping - produces : 반환 가능 타입, consumes : 받는 타입 제한
ResponseEntity : HttpStatus 상태 코드를 추가한 HttpEntity 의 확장
HttpEntity : headers(헤더), body(본문) 으로 구성된 HTTP 요청, 응답 Entity
Entity : 사람, 장소, 물건, 사건, 개념 등의 명사로 유용한 정보를 저장 및 관리하는 집합적인 것
linkTo : 아래 소스코드를 예시로 EventController 클래스에 주석이 달린 매핑 기반 WebMvcLinkBuilder 생성
WebMvcLinkBuilder : Spring MVC Controller 를 가리키는 Link 인스턴스 구축 빌더
methodOn : 메소드 레벨에 이벤트 핸들러가 적용되면 사용
slash : object 타입의 문자열을 URI 하위 리소스로 추가
toUri : 인스턴스에서 빌드한 링크 URI 생성

@Controller
@RequestMapping(value = "/api/events", produces = MediaTypes.HAL_JSON_VALUE)
public class EventController {
    @PostMapping
    public ResponseEntity<Event> createEvent(@RequestBody Event event) {
        //메소드 레벨에 이벤트 핸들러가 적용되는 경우 methodOn 사용 예시
        //URI createUri = linkTo(methodOn(EventController.class).createEvent()).slash("{id}").toUri();
        URI createUri = linkTo(EventController.class).slash("{id}").toUri();
        event.setId(10);
        return ResponseEntity.created(createUri).body(event);
    }
}

2-3.이벤트 Repository


스프링 4.3 부터 생성자가 1개, 주입 받으려는 객체가 이미 빈으로 등록 된 경우 @Autowired 생략 가능

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

    public EventController(EventRepository eventRepository) {
        this.eventRepository = eventRepository;
    }
}

테스트

@WebMvcTest 는 웹 계층 슬라이스 테스트이기 때문에 EventRepository 를 빈으로 등록할 수 없다
@MockBean 을 이용하여 EventRepository 를 MockBean 으로 등록하고 Mockito 를 사용하여
event 의 NullPointException 발생을 막을 수 있다
Mockito.when.thenReturn : 특정 메서드가 호출될 때 모의 객체가 특정 값을 반환하도록 할 때 사용

@WebMvcTest
public class EventControllerTests {
    @Autowired
    MockMvc mockMvc;

    @Autowired
    ObjectMapper objectMapper;

    @MockBean
    EventRepository eventRepository;

    @Test
    public void createEvent() throws Exception {
        //... Event 빌드 코드 생략

        event.setId(10);
        Mockito.when(eventRepository.save(event)).thenReturn(event);

        mockMvc.perform(post("/api/events/")
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaTypes.HAL_JSON)
                        .content(objectMapper.writeValueAsString(event)))
                .andDo(print())
                .andExpect(status().isCreated())
                .andExpect(jsonPath("id").exists())
                .andExpect(header().exists(HttpHeaders.LOCATION))
                .andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaTypes.HAL_JSON_VALUE));
    }
}

2-4.입력값 제한하기


ModelMapper 를 사용할 때 리플렉션으로 인한 성능 이슈가 걱정된다면
필드들을 직접 매핑해주는 것 또한 하나의 방법

ModelMapper maven 추가 및 IOC 컨테이너에 빈 등록
클라이언트 요청 내용을 EventDto 에 매핑하고 ModelMapper.map() 메시지를 사용하여 Event 에 매핑
id 와 같은 입력 받으면 안되는 필드를 EventDto 에서는 제거하여 값을 제한적으로 받는다
EventDto 클래스에서 @Data 를 사용하고 Event 클래스에 여러 lombok 어노테이션을 직접 명시하는
이유는 JPA 를 사용할 때 발생할 수 있는 순환참조를 막기 위함

Event.class

@Builder @AllArgsConstructor @NoArgsConstructor
@Getter @Setter @EqualsAndHashCode(of = "id")
@Entity
public class Event {
    @Id @GeneratedValue
    private Integer id;
    private String name;
    private String description;
    private LocalDateTime beginEnrollmentDateTime;
    private LocalDateTime closeEnrollmentDateTime;
    private LocalDateTime beginEventDateTime;
    private LocalDateTime endEventDateTime;
    private String location; //optional
    private int basePrice; //optional
    private int maxPrice; //optional
    private int limitOfEnrollment;
    private boolean offline;
    private boolean free;
    @Enumerated(EnumType.STRING)
    private EventStatus eventStatus = EventStatus.DRAFT;
}

EventDto.class

@Builder @AllArgsConstructor @NoArgsConstructor
@Data
public class EventDto {
    private String name;
    private String description;
    private LocalDateTime beginEnrollmentDateTime;
    private LocalDateTime closeEnrollmentDateTime;
    private LocalDateTime beginEventDateTime;
    private LocalDateTime endEventDateTime;
    private String location; //optional
    private int basePrice; //optional
    private int maxPrice; //optional
    private int limitOfEnrollment;
}

테스트

2-3 테스트 소스코드에 있던 repository, mockito 는 삭제되고 @WebMvcTest 는 아래와 같이 변경
이유는 요청, 응답에 사용하던 Event 객체를 EventDto 객체를 통해 매핑하여 사용하게 되며
mocking 대상이 많아지며 코드가 복잡, 난잡해진다
웹 계층 슬라이스 테스트를 전체 테스트로 변경하여 repository 를 직접 사용하는 테스트가 되므로
첫번째 줄의 내용과 같이 소스코드가 변경 삭제 된다
아래 테스트는 입력되면 안되는 값을 넣고 빌드하여 응답에 사용되는지 확인

@SpringBootTest
@AutoConfigureMockMvc
public class EventControllerTests {
    @Autowired
    MockMvc mockMvc;

    @Autowired
    ObjectMapper objectMapper;

    @Test
    public void createEvent() throws Exception {
        Event event = Event.builder()
            .id(100)
            .eventStatus(EventStatus.PUBLISHED)
            //... 이 외 정보 생략

        mockMvc.perform(post("/api/events/")
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaTypes.HAL_JSON)
                        .content(objectMapper.writeValueAsString(event)))
                .andDo(print())
                .andExpect(status().isCreated())
                .andExpect(jsonPath("id").exists())
                .andExpect(header().exists(HttpHeaders.LOCATION))
                .andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaTypes.HAL_JSON_VALUE))
                .andExpect(jsonPath("eventStatus").value(EventStatus.DRAFT.toString()));
    }
}

2-5.입력값 이외에 에러 발생


application.properties 추가
spring.jackson.deserialization.FAIL_ON_UNKNOWN_PROPERTIES=true
properties 를 추가하고 테스트를 실행하면 EventDto 에 없는 필드가 있는 경우 400 상태코드로 응답한다
2-4 에서 사용하는 값 무시하기, 이번 파트에서 사용하는 에러 응답 중 선택하여 사용하자

2-6.Bad Request 처리


@Validated, Errors 사용

@Builder @AllArgsConstructor @NoArgsConstructor
@Data
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; //optional
    @Min(0)
    private int basePrice; //optional
    @Min(0)
    private int maxPrice; //optional
    @Min(0)
    private int limitOfEnrollment;
}

@NotNull : null 허용 안함
@NotEmpty : null 허용 안함, 비어있으면 안됨(공백 허용)
@NotBlank : null 허용 안함, 하나 이상의 공백이 아닌 문자 포함, CharSequence를 허용

@PostMapping
public ResponseEntity<Event> createEvent(@RequestBody @Validated EventDto eventDto, Errors errors) {
    if (errors.hasErrors()) {
        return ResponseEntity.badRequest().build();
    }
    ... 생략
}

테스트

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

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

@Validated 가 작동하지 않아 201 상태코드를 리턴하여 테스트가 실패하였다 이유는
강의에서 사용한 버전 spring boot 2.1.x 나의 프로젝트에서 사용한 버전 2.6.7
spring boot 2.3.0 버전부터 Validation Starter는 더 이상 Web Starter에 포함되지 않습니다.
Web 및 WebFlux 스타터는 더 이상 기본적으로 유효성 검사 스타터에 의존하지 않습니다. spring-boot-starter-validation
애플리케이션에서 유효성 검사 기능을 사용하는 경우 빌드 파일 에 종속성을 수동으로 다시 추가해야 합니다.
그리하여 spring-boot-starter-validation 의존성 추가하여 문제 해결 자세한 참조

validator 클래스 생성

javax.validation 어노테이션을 통한 검증은 통과하지만 값 자체를 더 비교해야할 때
아래와 같은 validator 클래스를 생성하여 빈으로 등록 후 사용
아래 예제는 basePrice 는 maxPrice 를 넘을 수 없는데 사용자가 고의로 basePrice 를 더 크게 준 경우 체크하는 로직
이외에 다양한 로직을 추가하여 검증 가능

@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");
        }

        ... 생략
    }
}

사용 방법

빈으로 등록한 validator 객체-메시지를 호출하여 errors 에 검증 실패한 내용이 있는지 확인한다

@PostMapping
public ResponseEntity<Event> createEvent(@RequestBody @Validated EventDto eventDto, Errors errors) {
    if (errors.hasErrors()) {
        return ResponseEntity.badRequest().build();
    }

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

    ... 생략
}

2-6.Bad Request 응답 본문 만들기


Errors 를 ResponseEntity 본문으로 전달하려면 ObjectMapper - BeanSerializer 를 사용하여
json 으로 직렬화 후 전달해야 하는데 ObjectMapper 를 사용하기 위한 조건이 자바 빈 규칙을 준수해야 하며
Errors 는 자바 빈 규칙을 준수하지 못한다

Errors 를 직렬화 하기위해 클래스를 생성하고 ObjectMapper 에 등록하는 과정이 필요한데
Spring 이 지원하는 @JsonComponent 를 사용하여 간단히 해결할 수 있다

@JsonComponent
public class ErrorsSerializer extends JsonSerializer<Errors> {

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

        errors.getGlobalErrors().forEach(e -> {
            try {
                jsonGenerator.writeStartObject();
                jsonGenerator.writeStringField("objectName", e.getObjectName());
                jsonGenerator.writeStringField("code", e.getCode());
                jsonGenerator.writeStringField("defaultMessage", e.getDefaultMessage());
                jsonGenerator.writeEndObject();
            } catch (IOException e1) {
                e1.printStackTrace();
            }
        });
        jsonGenerator.writeEndArray();
    }
}

테스트

.andExpect(jsonPath("$[0].objectName").exists())
jsonPath 표현식으로 응답 배열 0번째에 objectName 필드가 존재를 예상한다는 의미

@Test
@DisplayName("입력 값이 잘못된 경우에 에러가 발생하는 테스트")
public void createEvent_bad_request_wrong_valid() throws Exception {
    EventDto eventDto = EventDto.builder()
            .basePrice(20000)
            .maxPrice(100)
			//... 생략

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

테스트 bad request json

Errors 에 입력한 값들을 ResponseEntity 본문으로 전달하여 클라이언트가 확인할 수 있다

[{"field":"basePrice","objectName":"eventDto","code":"wrongValue","defaultMessage":"BasePrice is wrong","rejectedValue":"20000"},{"field":"maxPrice","objectName":"eventDto","code":"wrongValue","defaultMessage":"MaxPrice is wrong","rejectedValue":"100"}

2-7.비즈니스 로직 적용


클라이언트의 요청 데이터를 확인하여 특정 필드들에 상태값 변경하고 검증하는 로직
특별한 내용 없으므로 확인은 깃헙에서

2-8 매개변수를 이용한 테스트


강의 영상에서 사용하는 JUnit 4

@Test
@Parameters({
	"csv ..."
})

or

@Parameters(method = "메서드 명 or 메서드 명을 prefix(parametersFor) + 메서드명 으로 생략 가능 ")
void testFree(int basePrice, int maxPrice, boolean isFree) {
    //Given
    Event event = Event.builder()
            .basePrice(basePrice)
            .maxPrice(maxPrice)
            .build();

    //When
    event.update();

    //Then
    assertThat(event.isFree()).isEqualTo(isFree);
}

Object[] testFree() {
    return new Object[] {
            new Object[] {0, 0, true},
            new Object[] {100, 0, false},
            new Object[] {0, 100, false}
    };
}

프로젝트 JUnit 5

@Test + @Parameters = @ParameterizedTest 하나로 가능
@methodSource 를 추가하고 메서드명을 직접 입력하거나
동일한 이름의 정적 메서드로 생성하면 MethodSource 로 인식
파일 호출, Enum 사용 가능 자세한 내용 2.15.3 @메소드소스

@ParameterizedTest
@CsvSource({
	"0, 0, true",
	"100, 0, false",
	"0, 100, false"
})

or

@MethodSource
void testFree(int basePrice, int maxPrice, boolean isFree) {
    //Given
    Event event = Event.builder()
            .basePrice(basePrice)
            .maxPrice(maxPrice)
            .build();

    //When
    event.update();

    //Then
    assertThat(event.isFree()).isEqualTo(isFree);
}

static Object[] testFree() {
    return new Object[] {
            new Object[] {0, 0, true},
            new Object[] {100, 0, false},
            new Object[] {0, 100, false}
    };
}
profile
엔지니어

0개의 댓글