udemy
강의를 참고하여 기본 골격을 만들고 학습한 스택들을 적용시켜 가며 구성
post apis
http method | url path | status code |
---|---|---|
GET | /api/v1/posts | 200 |
GET | /api/v1/posts/{id} | 200 |
POST | /api/v1/posts | 201 |
PUT | /api/v1/posts/{id} | 200 |
DELETE | /api/v1/posts/{id} | 200 |
GET | /api/v1/posts?pageSize=…. | 200 |
rest controller advice
GlobalControllerAdvice
WebRequest
: 예외가 발생한 리소스의 정보를HttpServletRequest
보다 더 쉽게 가져올 수 있게 해주는 메소드globalExceptionHandler()
: 개발자가 잡아내지 못한 에러가 발생했을 때 후 처리를 위한ExceptionHandler
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
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalControllerAdvice {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorDetails> resourceNotFoundExceptionHandler(
ResourceNotFoundException resourceNotFoundException, WebRequest webRequest
) {
ErrorDetails errorDetails = new ErrorDetails(resourceNotFoundException.getMessage(), webRequest.getDescription(false));
log.info("resourceNotFoundExceptionHandler controllerAdviceResponse = {}", errorDetails);
return new ResponseEntity<>(errorDetails, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(BlogApiException.class)
public ResponseEntity<ErrorDetails> blogApiExceptionHandler(
BlogApiException blogApiException, WebRequest webRequest
) {
ErrorDetails errorDetails = new ErrorDetails(blogApiException.getMessage(), webRequest.getDescription(false));
log.info("blogApiExceptionHandler controllerAdviceResponse = {}", errorDetails);
return new ResponseEntity<>(errorDetails, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorDetails> globalExceptionHandler(
Exception exception, WebRequest webRequest
) {
ErrorDetails errorDetails = new ErrorDetails(exception.getMessage(), webRequest.getDescription(false));
log.info("blogApiExceptionHandler controllerAdviceResponse = {}", errorDetails);
return new ResponseEntity<>(errorDetails, HttpStatus.INTERNAL_SERVER_ERROR);
}
@Setter
@Getter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public static class ErrorDetails {
private String localDateTime;
private String message;
private String details;
public ErrorDetails(String message, String details) {
this.localDateTime = LocalDateTime.now().toString();
this.message = message;
this.details = details;
}
}
}
Exception
ResourceNotFoundException
id
에 해당하는 리소스가 없을 때
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Getter
@ResponseStatus(value = HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {
private final String resourceName;
private final String fieldName;
private final long fieldValue;
public ResourceNotFoundException(String resourceName, String fieldName, long fieldValue) {
super(String.format("%s not found with %s : '%s'", resourceName, fieldName, fieldValue));
this.resourceName = resourceName;
this.fieldName = fieldName;
this.fieldValue = fieldValue;
}
}
BlogApiException
- 블로그에서 댓글과 게시글에 대한 검증이 실패 하였을 때
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Getter
@ResponseStatus(value = HttpStatus.BAD_REQUEST)
public class BlogApiException extends RuntimeException {
private HttpStatus status;
private String message;
public BlogApiException(HttpStatus status, String message) {
this.status = status;
this.message = message;
}
public BlogApiException(String message, HttpStatus status, String message1) {
super(message);
this.status = status;
this.message = message1;
}
}
post controller
페이징 기본 설정 값
1
2
3
4
5
6
public class AppConstants {
public static final String DEFAULT_PAGE_NUMBER = "0";
public static final String DEFAULT_PAGE_SIZE = "10";
public static final String DEFAULT_SORT_BY = "id";
public static final String DEFAULT_SORT_DIRECTION = "asc";
}
controller
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
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping(path = "/api/v1/posts")
public class PostController {
private final PostService postService;
@PostMapping
public ResponseEntity<PostDto> createPost(@RequestBody PostDto postDto) {
return new ResponseEntity<>(postService.createPost(postDto), HttpStatus.CREATED);
}
@GetMapping
public ResponseEntity<PostResponse> getAllPosts(
@RequestParam(value = "pageNo", defaultValue = DEFAULT_PAGE_NUMBER, required = false) int pageNo,
@RequestParam(value = "pageSize", defaultValue = DEFAULT_PAGE_SIZE, required = false) int pageSize,
@RequestParam(value = "sortBy", defaultValue = DEFAULT_SORT_BY, required = false) String sort,
@RequestParam(value = "sortDir", defaultValue = DEFAULT_SORT_DIRECTION, required = false) String sortDir
) {
return new ResponseEntity<>(postService.getAllPosts(pageNo, pageSize, sort, sortDir), HttpStatus.OK);
}
@GetMapping(path = "/{id}")
public ResponseEntity<PostDto> getPostById(@PathVariable("id") Long id) {
return new ResponseEntity<>(postService.getPostById(id), HttpStatus.OK);
}
@PutMapping(path = "/{id}")
public ResponseEntity<PostDto> updatePost(@RequestBody PostDto postDto, @PathVariable("id") Long id) {
return new ResponseEntity<>(postService.updatePost(postDto, id), HttpStatus.OK);
}
@DeleteMapping(path = "/{id}")
public ResponseEntity<PostDto> deletePost(@PathVariable("id") Long id) {
return new ResponseEntity<>(postService.deletePost(id), HttpStatus.OK);
}
}
spring data jpa
에서 페이징JpaRepository
를 상속받아 사용하면Pageable
객체를 인자로 받아 페이징 및 정렬을 쉽게 할 수 있다.
1
2
3
4
5
6
7
@NoRepositoryBean
public interface PagingAndSortingRepository<T, ID> extends Repository<T, ID> {
Iterable<T> findAll(Sort sort);
Page<T> findAll(Pageable pageable);
}
post service
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
@Slf4j
@Service
@RequiredArgsConstructor
public class PostServiceImpl implements PostService {
private final PostRepository postRepository;
@Override
public PostDto createPost(PostDto postDto) {
Post post = new Post(postDto.getTitle(), postDto.getDescription(), postDto.getContent());
Post save = postRepository.save(post);
PostDto postResponse = new PostDto(save.getPostId(), save.getTitle(), save.getDescription(), save.getContent());
log.info("postResponse = {}", postResponse);
return postResponse;
}
@Override
public PostResponse getAllPosts(int pageNo, int pageSize, String sortBy, String sortDir) {
// 솔트 조건이 여러개인 경우 어떻게? 필요 없나?
Sort sort =
sortDir.equalsIgnoreCase(Sort.Direction.ASC.name()) ?
Sort.by(sortBy).ascending() :
Sort.by(sortBy).descending();
PageRequest pageRequest = PageRequest.of(pageNo, pageSize, sort);
Page<Post> posts = postRepository.findAll(pageRequest);
log.info("posts = {}", posts);
List<Post> postList = posts.getContent();
log.info("postList = {}", postList);
List<PostDto> content = postList.stream().map(Post::toDto).collect(Collectors.toList());
log.info("postDtoList = {}", content);
PostResponse postResponse = new PostResponse();
postResponse.setContent(content);
postResponse.setPageNo(posts.getNumber());
postResponse.setPageSize(posts.getSize());
postResponse.setTotalElements(posts.getTotalElements());
postResponse.setTotalPages(posts.getTotalPages());
postResponse.setLast(posts.isLast());
return postResponse;
}
@Override
public PostDto getPostById(Long id) {
Post post = postRepository.findById(id).orElseThrow(() -> new ResourceNotFoundException("Post", "id", id));
PostDto result = post.toDto();
log.info("result = {}", result);
return result;
}
@Override
public PostDto updatePost(PostDto postDto, Long id) {
PostDto targetPost = getPostById(id);
targetPost.setTitle(postDto.getTitle());
targetPost.setDescription(postDto.getDescription());
targetPost.setContent(postDto.getContent());
PostDto result = createPost(targetPost);
log.info("result = {}", result);
return result;
}
@Override
public PostDto deletePost(Long id) {
PostDto findPost = getPostById(id);
postRepository.deleteById(findPost.getPostId());
return findPost;
}
}
post repository
1
2
3
public interface PostRepository extends JpaRepository<Post, Long> {
Post findPostByTitle(String title);
}
post entity, post dto
entity
orphanRemoval = true
: 엔티티 간의 관계에서 부모 엔티티가 더이상 자식 엔티티를 참조하지 않을 때 자식 엔티티를 자동으로 제거uniqueConstraints = {@UniqueConstraint(columnNames = {"title"})}
:unique
제약 조건을 클래스 레밸에서 설정 가시성이 좋다.
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
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "posts", uniqueConstraints = {@UniqueConstraint(columnNames = {"title"})})
public class Post {
@Id
@Column(name = "post_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long postId;
@Column(name = "title", nullable = false)
private String title;
@Column(name = "description", nullable = false)
private String description;
@Column(name = "content", nullable = false)
private String content;
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) //
private Set<Comment> comments = new HashSet<>();
public Post(String title, String description, String content) {
this.title = title;
this.description = description;
this.content = content;
}
public PostDto toDto() {
return new PostDto(this.postId, this.title, this.description, this.content);
}
}
dto
1
2
3
4
5
6
7
8
9
10
11
@Data
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class PostDto {
private Long postId;
private String title;
private String description;
private String content;
}
테스트 코드
post test setup
post
모듈 테스트에 필요한 의존성 및 의존성 설정@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
: 테스트 메소드에@Order()
어노테이션을 이용해서 순서를 지정할 수 있음. 테스트간에 독립성을 보장하지 못함으로 추후random
하게 진행될 수 있게 수정해야함protected JdbcTemplate jdbcTemplate
: 테스트 종료후 테스트 데이터를 비워내기 위한 설정.
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
@SpringBootTest
@AutoConfigureMockMvc
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public abstract class PostSetup {
@Autowired
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper;
@Autowired
protected JdbcTemplate jdbcTemplate;
@Autowired
protected PostRepository postRepository;
protected static final Long NOT_FOUND_ID = Long.MAX_VALUE - 2;
@BeforeEach
void beforeEach() {
jdbcTemplate.execute("DELETE FROM posts");
jdbcTemplate.execute("DELETE FROM comments");
}
protected String returnResourceNotFoundExceptionMessage(Long id) {
return String.format("%s not found with %s : '%s'", "Post", "id", id);
}
protected Long createHelper(PostDto postDto) throws Exception {
mockMvc.perform(
post("/api/v1/posts")
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(objectMapper.writeValueAsString(postDto))
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value(postDto.getTitle()))
.andExpect(jsonPath("$.description").value(postDto.getDescription()))
.andExpect(jsonPath("$.content").value(postDto.getContent()));
Post postByTitle = postRepository.findPostByTitle(postDto.getTitle());
return postByTitle.getPostId();
}
}
post controller test
- 통합 테스트를 진행하며
http status
값에 따라 응답받는 값들을 검증하는 방식으로 진행 jsonPath()
: 응답 받은Json
문자열 값의 검증을 편하게 해주는 기능
- 통합 테스트를 진행하며
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
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
@Transactional
class PostControllerTest extends PostSetup {
@Test
@Order(1)
void createPost() throws Exception {
PostDto postDto = new PostDto(0L, "title", "description", "content");
mockMvc.perform(
post("/api/v1/posts")
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(objectMapper.writeValueAsString(postDto))
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("title"))
.andExpect(jsonPath("$.description").value("description"))
.andExpect(jsonPath("$.content").value("content"));
}
@Test
@Order(2)
void getAllPosts() throws Exception {
PostDto postDto = new PostDto(0L, "title", "description", "content");
createHelper(postDto);
mockMvc.perform(
get("/api/v1/posts?pageNo={pageNo}&pageSize={pageSize}&sortBy={sortBy}&sortDir={sortDir}", 0, 10, "title", "desc")
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.pageNo").value(0))
.andExpect(jsonPath("$.pageSize").value(10))
.andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.totalPages").value(1))
.andExpect(jsonPath("$.last").value(true));
postDto.setTitle("title2");
createHelper(postDto);
mockMvc.perform(
get("/api/v1/posts?pageNo={pageNo}&pageSize={pageSize}&sortBy={sortBy}&sortDir={sortDir}", 0, 10, "title", "desc")
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.pageNo").value(0))
.andExpect(jsonPath("$.pageSize").value(10))
.andExpect(jsonPath("$.totalElements").value(2))
.andExpect(jsonPath("$.totalPages").value(1))
.andExpect(jsonPath("$.last").value(true))
.andDo(print());
}
@Test
@Order(3)
void getPostById() throws Exception {
PostDto postDto = new PostDto(0L, "title", "description", "content");
Long createId = createHelper(postDto);
mockMvc.perform(
get("/api/v1/posts/{id}", createId)
)
.andExpect(status().isOk());
mockMvc.perform(
get("/api/v1/posts/{id}", NOT_FOUND_ID)
)
.andExpect(jsonPath("$.message").value(returnResourceNotFoundExceptionMessage(NOT_FOUND_ID)))
.andExpect(status().isNotFound());
}
@Test
@Order(4)
void updatePost() throws Exception {
PostDto postDto = new PostDto(0L, "title", "description", "content");
Long createId = createHelper(postDto);
PostDto updatePostDto = new PostDto(0L, "updateTitle", "updateDescription", "updateContent");
mockMvc.perform(
put("/api/v1/posts/{id}", createId)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(objectMapper.writeValueAsString(updatePostDto))
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value(updatePostDto.getTitle()))
.andExpect(jsonPath("$.description").value(updatePostDto.getDescription()))
.andExpect(jsonPath("$.content").value(updatePostDto.getContent()));
mockMvc.perform(
put("/api/v1/posts/{id}", NOT_FOUND_ID)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(objectMapper.writeValueAsString(updatePostDto))
)
.andExpect(jsonPath("$.message").value(returnResourceNotFoundExceptionMessage(NOT_FOUND_ID)))
.andExpect(status().isNotFound());
}
@Test
@Order(5)
void deletePost() throws Exception {
PostDto postDto = new PostDto(0L, "title", "description", "content");
Long createId = createHelper(postDto);
mockMvc.perform(
delete("/api/v1/posts/{id}", createId)
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value(postDto.getTitle()))
.andExpect(jsonPath("$.description").value(postDto.getDescription()))
.andExpect(jsonPath("$.content").value(postDto.getContent()));
mockMvc.perform(
delete("/api/v1/posts/{id}", NOT_FOUND_ID)
)
.andExpect(jsonPath("$.message").value(returnResourceNotFoundExceptionMessage(NOT_FOUND_ID)))
.andExpect(status().isNotFound());
}
}