지난 시간 JPA까지는 이해가 잘 되었지만 이번 시간에 했던 등록/조회/수정 API 만들기 부분이 엄청 어렵게 느껴졌다. 만들 것도 많고 이해가 가지 않는 것도 많아서 해당 부분은 여러번 공부해보려고 한다. 다시 한번 공부하며 기록한다.
API를 만들기 위해서는 3개의 클래스가 필요한데
등록, 수정, 삭제 기능을 만들기 위해 각 파일에 코드를 작성해준다.
PostsApiController
@RequiredArgsConstructor
@RestController
public class PostsApiController {
private final PostsService postsSerivce;
@PostMapping("/api/v1/posts")
public Long save(@RequestBody PostsSaveRequestDto requestDto)
{
return postsSerivce.save(requestDto);
}
}
-> 스프링에서 Bean을 주입받는 방식이 다양하게 있는데
이 중 가장 권장하는 방식이 생성자로 주입받는 방식이다.
그렇다면 생성자를 하나씩 다 만들어줘야하는데
이 것을 롬복의 @RequiredArgsConstructor가 해결해준다.
PostsService
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
@Transactional
public Long save(PostsSaveRequestDto requestDto)
{
return postsRepository.save(requestDto.toEntity()).getId();
}
}
다음은 Controller와 Service에서 사용할 Dto 클래스를 만들어준다.
PostsSaveRequestDto
@Getter
@NoArgsConstructor
public class PostsSaveRequestDto {
private String title;
private String content;
private String author;
@Builder
public PostsSaveRequestDto(String title, String content, String author){
this.title = title;
this.content = content;
this.author = author;
}
public Posts toEntity(){
return Posts.builder()
.title(title)
.content(content)
.author(author)
.build();
}
}
꼭 Entity 클래스와 Controller에서 쓸 Dto를 분리해서 사용하기
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private PostsRepository postsRepository;
@After
public void tearDown() throws Exception{
postsRepository.deleteAll();
}
@Test
public void Posts_등록된다() throws Exception{
//given
String title = "title";
String content = "content";
PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
.title(title)
.content(content)
.author("author")
.build();
String url = "http://localhost:" + port + "/api/v1/posts";
//when
ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, requestDto, Long.class);
//then
assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(responseEntity.getBody()).isGreaterThan(0L);
List<Posts> all = postsRepository.findAll();
assertThat(all.get(0).getTitle()).isEqualTo(title);
assertThat(all.get(0).getContent()).isEqualTo(content);
}
}
-> HelloController를 테스트 했을 때와 달리 @WebMvcTest를 사용하지 않았는데 @WebMvcTest의 경우 JPA는 작동하지 않기에 이번에는 @SpringBootTest와 TestRestTemplate를 사용한다.
테스트를 진행하면 이렇게 잘 나오는 것을 확인할 수 있다.
PostsApiController
@PutMapping("/api/v1/posts/{id}")
public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto){
return postsSerivce.update(id, requestDto);
}
@GetMapping("/api/v1/posts/{id}")
public PostsReponseDto findById(@PathVariable Long id){
return postsSerivce.findById(id);
}
PostsResponseDto
@Getter
public class PostsReponseDto {
private Long id;
private String title;
private String content;
private String author;
public PostsReponseDto(Posts entity){
this.id = entity.getId();
this.title = entity.getTitle();
this.content = entity.getContent();
this.author = entity.getAuthor();
}
}
-> PostsResponseDto는 Entity의 필드 중 일부만 사용하므로 생성자로 Entity를 받아 필드에 값을 넣는다.
PostsUpdateRequestDto
@Getter
@NoArgsConstructor
public class PostsUpdateRequestDto {
private String title;
private String content;
@Builder
public PostsUpdateRequestDto(String title, String content){
this.title = title;
this.content = content;
}
}
Posts
public void update(String title, String content){
this.title = title;
this.content = content;
}
}
PostsService
@Transactional
public Long update(Long id, PostsUpdateRequestDto requestDto){
Posts posts = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 사용자가 없습니다. id=" + id));
posts.update(requestDto.getTitle(), requestDto.getContent());
return id;
}
public PostsReponseDto findById(Long id){
Posts entity = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 사용자가 없습니다. id=" + id));
return new PostsReponseDto(entity);
}
}
정상적으로 Update 쿼리를 수정하는지 테스트를 진행한다.
PostsApiControllerTest
@Test
public void Posts_수정된다() throws Exception{
//given
Posts savedPosts = postsRepository.save(Posts.builder()
.title("title")
.content("content")
.author("author")
.build());
Long updateId = savedPosts.getId();
String expectedTitle = "title2";
String expectedContent = "content2";
PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder()
.title(expectedTitle)
.content(expectedContent)
.build();
String url = "http://localhost:" + port + "/api/v1/posts/" + updateId;
HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto);
//when
ResponseEntity<Long> responseEntity = restTemplate.exchange(url, HttpMethod.PUT, requestEntity, Long.class);
//then
assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(responseEntity.getBody()).isGreaterThan(0L);
List<Posts> all = postsRepository.findAll();
assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
}
}
이 부분에서 많은 시행착오를 겪었다.
코드를 수정하기 전에 테스트를 진행해보니
Error while extracting response for type [class java.lang.Long] and content type [application/json;charset=UTF-8]; nested exception is org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize instance of `java.lang.Long` out of START_OBJECT token; nested exception is com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot deserialize instance of `java.lang.Long` out of START_OBJECT token
at [Source: (PushbackInputStream); line: 1, column: 1]
org.springframework.web.client.RestClientException: Error while extracting response for type [class java.lang.Long] and content type [application/json;charset=UTF-8]; nested exception is org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize instance of `java.lang.Long` out of START_OBJECT token; nested exception is com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot deserialize instance of `java.lang.Long` out of START_OBJECT token
at [Source: (PushbackInputStream); line: 1, column: 1]
이러한 에러가 나왔다.
해당 오류를 해결하기 위해 책의 저자분의 깃허브도 들어가보고 같은 책으로 공부를 하는 여러 사람들의 블로그를 찾아보았지만 5판 인쇄 전에 오타가 있어서 나타나는 오류였고 나랑은 상관 없는 말 뿐이었다.
그나마 조금 비슷하신 분이 있어서 확인해보니 함수쪽에 오타가 있어서 해당 오류가 나왔고 지금은 해결을 했다는 댓글을 확인했다.
1시간정도 코드를 자세히 보니 테스트를 위한 url을 선언하는 과정에서
오타가 있었던 것을 확인할 수 있었다.
-> v1으로 선언을 해야하는데 vi로 선언되어 있어서 나타나는 에러였다.
-> 해당 코드를 수정해보니 정상적으로 테스트 되는 것을 확인할 수 있었다.