포스트

REST API(5) Event 생성 API 구현 - 입력값 제한하기, 입력값 이외에 대한 오류 발생

구현 Package 및 Class

image


입력값 제한하기

우리는 이벤트를 등록하기에 앞서, Id 값은 자동으로 이벤트 등록시 자동으로 등록이 되야하고,
free 무료인지 아닌지 여부, offline 오프라인인지 아닌지에 대한 여부 또한, 직접 등록이 아닌
입력받은 데이터로 계산해서 등록이 되어야 하는 요소이다.
이러한 입력값을 제한하기 위해, 우리는 입력 해야하는 값만을 다루는 DTO를 만들 것이다.

EventDTO 생성

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

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

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

  • 여기서는 @Data를 사용해줘도 된다.

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
package me.hantomas.restapi.events;

import org.springframework.hateoas.MediaTypes;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;

import java.net.URI;

import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;

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

    public EventController(EventRepository eventRepository){
        this.eventRepository = eventRepository;
    }
    @PostMapping
    public ResponseEntity createEvent(@RequestBody EventDto eventDto){
        
        Event event = Event.builder() 
                .name(eventDto.getName())
                .description(eventDto.getDescription())
//              ...
                .build(); // 추가

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

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

  • EventDto를 객체로 사용하고 EventRepository로 DB에 저장하기 위해서는 EventDtoEvent객체로 변환 하는 과정이 필요하다. 그러기 위해서는 위 추가한 코드와 같이 데이터를 하나하나 변환해 주는 방법이 있지만, 이는 데이터가 많아질수록 변환해 줘야 하는 값도 많아짐을 의미한다.
  • 이를 한번에 가능하도록 하는 ModelMapper가 있다.

pom.xml 수정

1
2
3
4
5
<dependency>
    <groupId>org.modelmapper</groupId>
    <artifactId>modelmapper</artifactId>
    <version>2.3.1</version>
</dependency>
  • ModelMapper를 사용하기 위해 의존성을 추가해 준다.

RestApiApplication 수정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package me.hantomas.restapi;

import org.modelmapper.ModelMapper;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class RestApiApplication {

	public static void main(String[] args) {
		SpringApplication.run(RestApiApplication.class, args);
	}

	@Bean
	public ModelMapper modelMapper(){
		return new ModelMapper();
	}
}

  • RestApiApplicationModelMapper를 빈으로 등록해 준다.

ModelMapper를 사용한 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
package me.hantomas.restapi.events;

import org.modelmapper.ModelMapper;
import org.springframework.hateoas.MediaTypes;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;

import java.net.URI;

import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;

@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 EventDto eventDto){

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

  • ModelMapper를 생성자 주입 방식으로 주입해준다.
  • Event event = modelMapper.map(eventDto, Event.class); : eventDto의 값을 Event클래스에 인스턴스로 만들어 준다.

결과 (오류)

image
객체 변환도 잘 해줬는데 위와 같이 NullPointException 오류가 나고 말았다.
그 이유는,

1
2
3
4
5
6
7
8
9
10
@PostMapping
public ResponseEntity createEvent(@RequestBody EventDto eventDto){

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

EventController에서 우리가 save()에 전달한 객체는 createEvent() 메서드 내에서 새로 만들어준 객체다. 그래서 Mockito.when(eventRepository.save(event)).thenReturn(event);에서 Stubbing해준 event객체와 다르기 때문에 Mocking이 안되었고, 위와 같은 NullPointException 오류가 발생한 것이다.


오류 수정

이를 해결하기 위해서는 더 이상 슬라이스 테스트가 아니여야 한다.

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
package me.hantomas.restapi.events;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.hamcrest.Matchers;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.hateoas.MediaTypes;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;

import java.time.LocalDateTime;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@RunWith(SpringRunner.class)
@SpringBootTest // 추가
@AutoConfigureMockMvc // 추가
//@WebMvcTest
public class EventControllerTests {
    @Autowired
    MockMvc mockMvc;
    @Autowired
    ObjectMapper objectMapper;
//  @MockBean
//  EventRepository eventRepository
    @Test
    public void createEvent() throws Exception{
        Event event = Event.builder()
                .id(100) // 추가
                .name("Spring")
                .description("REST API Development With Spring")
                .beginEnrollmentDateTime(LocalDateTime.of(2024,02,19,14,21))
                .closeEnrollmentDateTime(LocalDateTime.of(2024,02,20,14,21))
                .beginEventDateTime(LocalDateTime.of(2024,02,21,14,21))
                .endEventDateTime(LocalDateTime.of(2024,02,22,14,21))
                .basePrice(100)
                .maxPrice(200)
                .limitOfEnrollment(100)
                .location("강남역 D2 스타트업 팩토리")
                .free(true) // 추가
                .offline(false) // 추가
                .eventStatus(EventStatus.PUBLISHED) // 추가
                .build();
//      Mockito.when(eventRepository.save(event)).thenReturn(event);  

        mockMvc.perform(post("/api/events/")
                        .contentType(MediaType.APPLICATION_JSON_UTF8)
                        .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_UTF8_VALUE))
                .andExpect(jsonPath("id").value(Matchers.not(100))) // 추가
                .andExpect(jsonPath("free").value(Matchers.not(true))) // 추가
                .andExpect(jsonPath("eventStatus").value(EventStatus.DRAFT.name())) // 추가
        ;
    }
}

  • 더이상 슬라이스 테스트를 진행하지 않기 때문에 @WebMvcTest를 삭제하고, @SpringBootTest@AutoConfigureMockMvc를 추가해 준다. @SpringBootTest는 Mocking을 한 DispatcherServlet을 만들도록 하는 기본값들이 내재되어 있기 때문에 MockMvc로 계속해서 테스트가 가능하다.
  • @SpringBootTest 를 사용하면 애플리케이션을 실행했을 때와 가장 근사한 형태로 테스트를 수행할 수 있다. 실제 웹 테스트를 구현할 때는 @SpringBootTest 사용하는데, 그렇지 않으면 Mocking해줘야 할 것들이 너무 많아지기 때문이다.
  • Mocking에 관련된 것들은 삭제해준다.

결과 (성공)

이렇게 하면 id(100) , free(true), offline(false),eventStatus(EventStatus.PUBLISHED)로 요청을 넣어 줬지만,
Dto는 이 데이터들을 다루지 않기 때문에 이 입력들을 무시하게된다.
따라서, .andExpect(jsonPath("id").value(Matchers.not(100))),.andExpect(jsonPath("free").value(Matchers.not(true))),.andExpect(jsonPath("eventStatus").value(EventStatus.DRAFT.name()))를 만족시키기 때문에 성공적인 응답값을 출력한다.

image
image


입력값 이외의 입력값들에 대한 오류 발생시키기

입력값을 제한하는 방법도 있지만, 입력값 이외의 입력에 대한 오류를 발생시키는 방법도 있다.

EventControllerTests 수정 (입력값 이외의 입력 Bad Request 오류 발생)

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
package me.hantomas.restapi.events;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.hamcrest.Matchers;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.hateoas.MediaTypes;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;

import java.time.LocalDateTime;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

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

    @Test
    public void createEvent() throws Exception{ // 수정
        EventDto event = EventDto.builder()
                .name("Spring")
                .description("REST API Development With Spring")
                .beginEnrollmentDateTime(LocalDateTime.of(2024,02,19,14,21))
                .closeEnrollmentDateTime(LocalDateTime.of(2024,02,20,14,21))
                .beginEventDateTime(LocalDateTime.of(2024,02,21,14,21))
                .endEventDateTime(LocalDateTime.of(2024,02,22,14,21))
                .basePrice(100)
                .maxPrice(200)
                .limitOfEnrollment(100)
                .location("강남역 D2 스타트업 팩토리")
                .build();

        mockMvc.perform(post("/api/events/")
                        .contentType(MediaType.APPLICATION_JSON_UTF8)
                        .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_UTF8_VALUE))
                .andExpect(jsonPath("id").value(Matchers.not(100)))
                .andExpect(jsonPath("free").value(Matchers.not(true)))
                .andExpect(jsonPath("eventStatus").value(EventStatus.DRAFT.name()))
        ;
    }

    @Test
    public void createEvent_Bad_Request() throws Exception{ // 추가
        Event event = Event.builder()
                .id(100)
                .name("Spring")
                .description("REST API Development With Spring")
                .beginEnrollmentDateTime(LocalDateTime.of(2024,02,19,14,21))
                .closeEnrollmentDateTime(LocalDateTime.of(2024,02,20,14,21))
                .beginEventDateTime(LocalDateTime.of(2024,02,21,14,21))
                .endEventDateTime(LocalDateTime.of(2024,02,22,14,21))
                .basePrice(100)
                .maxPrice(200)
                .limitOfEnrollment(100)
                .location("강남역 D2 스타트업 팩토리")
                .free(true)
                .offline(false)
                .eventStatus(EventStatus.PUBLISHED)
                .build();

        mockMvc.perform(post("/api/events/")
                        .contentType(MediaType.APPLICATION_JSON_UTF8)
                        .accept(MediaTypes.HAL_JSON)
                        .content(objectMapper.writeValueAsString(event))
                )
                .andDo(print())
                .andExpect(status().isBadRequest())

        ;
    }
}

  • createEvent()는 성공적인 방법에 대한 테스트로 바꿔준다.
    (Event객체와 그 프로퍼티들을 넘겨주는 방법에서 👉 EventDto객체와 그 프로퍼티들을 넘겨주는 방법)
  • createEvent_Bad_Request()이라는 Test를 생성하고, EventControllercreateEvent()메서드에 Event객체와 그 프로퍼티들을 넘겨 주도록 한다. 여기에는 id(100),free(true),offline(false),eventStatus(EventStatus.PUBLISHED)의 프로퍼티들이 포함되어 있다.
  • .andExpect(status().isBadRequest())를 통해 Bad Request 에러가 발생하는지 확인한다.

application.properties 설정 추가

application.properties에서 ObjectMapper를 커스터 마이징한다.

1
spring.jackson.deserialization.fail-on-unknown-properties=true
  • 위는 springboot가 제공하는 properties를 사용한 objectMapper 확장 기능(커스터마이징)이다.
  • ObjectJsonobject로 변환하는 과정(json문자열object로 변환하는 과정)을 deserialization이라고 부르고,
    object(객체를) json 문자열로 변환하는 과정을 serialization이라고 부른다.
  • spring.jackson.deserialization.fail-on-unknown-properties=truedeserialization할 때 unknown properties가 있으면 실패하라는 설정이다.
  • EventControllercreateEvent() 메서드에서는 EventDto를 객체(object)로, 그리고 그에 속하는 프로퍼티들(properties)을 사용하는데, 테스트가 id(100),free(true),offline(false),eventStatus(EventStatus.PUBLISHED) 프로퍼티들을 넘기고, EventDto는 이 프로퍼티들의 입력을 받지 않아, Error를 발생시킨다.

결과 (성공)

image
image


정리

입력 받을 값만 걸러서 받을 것인지, 입력값 이외에 다른 값들을 보내는 경우에는 에러를 발생하게 할건지는 선택이다.

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