Home Spring, restful api
Post
Cancel

Spring, restful api

  • udemy 강의를 참고하여 기본 골격을 만들고 학습한 스택들을 적용시켜 가며 구성


post apis

http methodurl pathstatus code
GET/api/v1/posts200
GET/api/v1/posts/{id}200
POST/api/v1/posts201
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 객체를 인자로 받아 페이징 및 정렬을 쉽게 할 수 있다.

image

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

  }
}

test_result

This post is licensed under CC BY 4.0 by the author.