사이먼's 코딩노트

[SpringBoot] REST API (4) 본문

Java/SpringBoot

[SpringBoot] REST API (4)

simonpark817 2024. 6. 19. 08:48

[REST API 적용]

  • 저번 'REST API (3)' 포스팅에 이어서 REST API를 적용하기 위한 코드를 작성해봅시다.
  • 게시물 등록을 위한 Article MVC를 설계한 다음, 이제 실제로 게시물 CRUD를 REST API로 적용해봅시다.
  • 전체 코드와 패키지 구조는 깃허브 리포지터리 주소를 통해 참고 부탁드립니다.
  • 리포지터리 URL 주소 : https://github.com/psm817/jwt_review
 

GitHub - psm817/jwt_review

Contribute to psm817/jwt_review development by creating an account on GitHub.

github.com

 

[ArticleController 생성]

  • 게시물 등록, 조회, 수정, 삭제를 위해서 ArticleController.java 클래스를 새로 생성하고 기존의 SpringBoot에서 GET과 POST만을 이용하는 것이 아닌 REST API를 적용하여 더 많은 Http Method와 체계화된 주소 체계를 통해 정보를 안전하게 교환할 수 있도록 아래와 같이 코드를 작성해봅시다.
  • 기존과 다른 점은 어노테이션을 추가할 때 @Controller가 아닌 @RestController를 작성해야한다.
  • 작성된 코드의 return 값은 resultCode와 msg, data를 표시하기 위해서 타입을 모두 RsData로 지정하였다.
  • 조회를 뜻하는 articles와 article 메서드는 GET 방식을 통해 조회가 가능하기 때문에 매핑을 할 때 어노테이션으로 @GetMapping을 추가한다.
  • 등록(추가)을 뜻하는 write 메서드는 POST 방식을 통해 등록이 가능하기 때문에 매핑을 할 때 어노테이션으로 @PostMapping을 추가한다.
  • 수정을 뜻하는 modify 메서드는 PATCH 방식을 통해 수정이 가능하기 때문에 매핑을 할 때 어노테이션으로 @PatchMapping을 추가한다.
  • 삭제를 뜻하는 delete 메서드는 DELETE 방식을 통해 삭제가 가능하기 때문에 매핑을 할 때 어노테이션으로 @DeleteMapping을 추가한다.
  • 컨트롤러의 모든 작성이 끝나면 이제 실제로 해당 기능을 수행하는 서비스를 생성하여 각 메서드에게 역할을 부여해야한다.
@RestController
@RequiredArgsConstructor
@RequestMapping(value = "/api/v1/articles")
public class ArticleController {
    private final ArticleService articleService;
    private final MemberService memberService;

    @AllArgsConstructor
    @Getter
    public static class ArticlesResponse {
        private final List<Article> articles;
    }

    @GetMapping(value = "")
    @Operation(summary = "다건조회")
    public RsData<ArticlesResponse> articles(){
        List<Article> articles = articleService.findAll();

        return RsData.of(
                "S-1",
                "성공",
                new ArticlesResponse(articles)
        );
    }

    @AllArgsConstructor
    @Getter
    public static class ArticleResponse {
        private final Article article;
    }

    @GetMapping(value = "/{id}")
    @Operation(summary = "단건조회")
    public RsData<ArticleResponse> article(@PathVariable("id") Long id){
        return articleService.findById(id).map(article -> RsData.of(
                "S-1",
                "성공",
                new ArticleResponse(article)
        )).orElseGet(() -> RsData.of(
                "F-1",
                "%d번 게시물은 존재하지 않습니다.".formatted(id),
                null
        ));
    }

    @Data
    public static class WriteRequest {
        @NotBlank
        private String subject;

        @NotBlank
        private String content;
    }

    @AllArgsConstructor
    @Getter
    public static class WriteResponse {
        private final Article article;
    }

    @PostMapping(value = "")
    @Operation(summary = "등록", security = @SecurityRequirement(name = "bearerAuth"))
    public RsData<WriteResponse> write(
            @AuthenticationPrincipal User user,
            @Valid @RequestBody WriteRequest writeRequest
    ){
        Member member = memberService.findByUsername(user.getUsername()).orElseThrow();
        RsData<Article> writeRs = articleService.write(member, writeRequest.getSubject(), writeRequest.getContent());

        if ( writeRs.isFail()) return (RsData) writeRs;

        return RsData.of(
                writeRs.getResultCode(),
                writeRs.getMsg(),
                new WriteResponse(writeRs.getData())
        );
    }

    @Data
    public static class ModifyRequest {
        @NotBlank
        private String subject;

        @NotBlank
        private String content;
    }

    @AllArgsConstructor
    @Getter
    public static class ModifyResponse {
        private final Article article;
    }

    @PatchMapping(value = "/{id}", consumes = APPLICATION_JSON_VALUE)
    @Operation(summary = "수정", security = @SecurityRequirement(name = "bearerAuth"))
    public RsData<ModifyResponse> modify(
            @AuthenticationPrincipal User user,
            @Valid @RequestBody ModifyRequest modifyRequest,
            @PathVariable("id") Long id
    ){
        Member member = memberService.findByUsername(user.getUsername()).orElseThrow();

        Optional<Article> opArticle = articleService.findById(id);

        if (opArticle.isEmpty()) return RsData.of(
                "F-1",
                "%d번 게시물은 존재하지 않습니다.".formatted(id),
                null
        );

        RsData canModifyRs = articleService.canModify(member, opArticle.get());

        if ( canModifyRs.isFail() ) return canModifyRs;

        RsData<Article> modifyRs = articleService.modify(opArticle.get(), modifyRequest.getSubject(), modifyRequest.getContent());

        return RsData.of(
                modifyRs.getResultCode(),
                modifyRs.getMsg(),
                new ModifyResponse(modifyRs.getData())
        );
    }

    @AllArgsConstructor
    @Getter
    public static class DeleteResponse {
        private final Article article;
    }

    @DeleteMapping(value = "/{id}")
    @Operation(summary = "삭제", security = @SecurityRequirement(name = "bearerAuth"))
    public RsData<DeleteResponse> remove(
            @AuthenticationPrincipal User user,
            @PathVariable("id") Long id
    ){
        Member member = memberService.findByUsername(user.getUsername()).orElseThrow();

        Optional<Article> opArticle = articleService.findById(id);

        if (opArticle.isEmpty()) return RsData.of(
                "F-1",
                "%d번 게시물은 존재하지 않습니다.".formatted(id),
                null
        );

        RsData canDeleteRs = articleService.canDelete(member, opArticle.get());

        if ( canDeleteRs.isFail() ) return canDeleteRs;

        articleService.deleteById(id);

        return RsData.of(
                "S-5",
                "%d번 게시물이 삭제되었습니다.".formatted(id),
                null
        );
    }
}

 

[ArticleService 생성]

  • ArticleService.java 클래스도 새로 생성하여 아래와 같이 코드를 작성해봅시다.
  • ArticleRepository.java 에서도 코드 작성이 필요하지만, 현재 서비스에서 호출하는 리포지터리의 메서드는 이미 JpaRepository 내에 내장된 메서드이기 때문에 따로 추가 작성이 필요하진 않다.
@Service
@RequiredArgsConstructor
public class ArticleService {
    private final ArticleRepository articleRepository;
    public RsData<Article> write(Member author, String subject, String content) {
        Article article = Article.builder()
                .author(author)
                .subject(subject)
                .content(content)
                .build();

        articleRepository.save(article);

        return RsData.of(
                "S-3",
                "게시물이 생성 되었습니다.",
                article
        );
    }

    public List<Article> findAll() {
        return articleRepository.findAll();
    }

    public Optional<Article> findById(Long id) {
        return articleRepository.findById(id);
    }

    public RsData canModify(Member actor, Article article) {
        if (Objects.equals(actor.getId(), article.getAuthor().getId())) {
            return RsData.of(
                    "S-1",
                    "게시물을 수정할 수 있습니다."
            );
        }

        return RsData.of(
                "F-1",
                "게시물을 수정할 수 없습니다."
        );
    }

    public RsData<Article> modify(Article article, String subject, String content) {
        article.setSubject(subject);
        article.setContent(content);
        articleRepository.save(article);

        return RsData.of(
                "S-4",
                "%d번 게시물이 수정되었습니다.".formatted(article.getId()),
                article
        );
    }

    public RsData<Article> canDelete(Member actor, Article article) {
        if (Objects.equals(actor.getId(), article.getAuthor().getId())) {
            return RsData.of(
                    "S-1",
                    "게시물을 삭제할 수 있습니다."
            );
        }

        return RsData.of(
                "F-1",
                "게시물을 삭제할 수 없습니다."
        );
    }

    public void deleteById(Long id) {
        articleRepository.deleteById(id);
    }
}

 

[테스트하기]

  • 마지막으로 테스트에서 ArticleController.java 클래스를 생성하여 등록, 조회, 수정, 삭제 4가지 방식에 대해서 정상적으로 기능이 동작하는 지 테스트해봅시다.
  • 먼저 아래와 같이 코드를 작성해봅시다.
  • 테스트는 총 5가지로써 t1은 다중 게시물 조회, t2는 단일 게시물 조회, t3는 게시물 작성, t4는 게시물 수정, t5는 게시물 삭제를 뜻한다.
  • 코드를 작성할 때 주의할 점은 ArticleController.java에서 작성했던 것 처럼 HttpMethod를 알맞게 사용해야한다.
  • t1 다중 게시물 조회의 매핑 주소는 /api/v1/articles이며 get 방식으로 게시물을 조회한다.
  • t2 단일 게시물 조회의 매핑 주소는 /api/v1/articles/1이며 1번 게시물을 get 방식으로 조회한다.
  • t3 게시물 작성의 매핑 주소는 /api/v1/articles이며 post 방식으로 게시물을 작성하고, @WithUserDetails("user1") 어노테이션을 함께 작성함으로써 user1 사용자가 작성한다는 것을 명시한다.
  • t4 게시물 수정의 매핑 주소는 /api/v1/articles/2이며 patch 방식으로 게시물 2번을 수정하고, 게시물 2번의 작성자가 "admin"이기 때문에 @WithUserDetails("admin") 어노테이션을 함께 작성함으로써 admin이 본인의 게시물을 수정한다는 것을 명시한다.
  • t5 게시물 삭제의 매핑 주소는 /api/v1/articles/2 이며 delete 방식으로 게시물 2번을 삭제하고, 수정과 마찬가지로 admin의 게시물이기 때문에 @WithuserDetails("admin") 어노테이션을 함께 작성한다.
  • 모든 테스트가 정상적으로 통과가 된다면, ArticleService.java 클래스에서 작성했던 RsData 형태의 resultCode와 msg, data가 테스트 콘솔에 출력된다.
  • html 템플릿을 따로 만들어놓지 않았기 때문에 직접 DB를 통해 데이터의 CRUD를 보고 싶다면 Postman을 통해서 매핑 주소와 HttpMethod에 맞게 동작시켜보면 데이터가 변하는 것을 확인할 수 있다.
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
@ActiveProfiles("test")
public class ArticleControllerTest {
    @Autowired
    private MockMvc mvc;

    @Test
    @DisplayName("GET /articles")
    void t1() throws Exception {
        // When
        ResultActions resultActions = mvc
                .perform(
                        get("/api/v1/articles")
                )
                .andDo(print());

        // Then
        resultActions
                .andExpect(status().is2xxSuccessful())
                .andExpect(jsonPath("$.resultCode").value("S-1"))
                .andExpect(jsonPath("$.msg").exists())
                .andExpect(jsonPath("$.data.articles[0].id").exists());
    }


    @Test
    @DisplayName("GET /articles/1")
    void t2() throws Exception {
        // When
        ResultActions resultActions = mvc
                .perform(
                        get("/api/v1/articles/1")
                )
                .andDo(print());

        // Then
        resultActions
                .andExpect(status().is2xxSuccessful())
                .andExpect(jsonPath("$.resultCode").value("S-1"))
                .andExpect(jsonPath("$.msg").exists())
                .andExpect(jsonPath("$.data.article.id").value(1));
    }

    @Test
    @DisplayName("POST /articles/1")
    @WithUserDetails("user1")
    void t3() throws Exception {
        // When
        ResultActions resultActions = mvc
                .perform(
                        post("/api/v1/articles")
                                .content("""
                                        {
                                            "subject": "제목 new",
                                            "content": "내용 new"
                                        }
                                        """)
                                .contentType(new MediaType(MediaType.APPLICATION_JSON, StandardCharsets.UTF_8))
                )
                .andDo(print());

        // Then
        resultActions
                .andExpect(status().is2xxSuccessful())
                .andExpect(jsonPath("$.resultCode").value("S-3"))
                .andExpect(jsonPath("$.msg").exists())
                .andExpect(jsonPath("$.data.article").exists());
    }

    @Test
    @DisplayName("PATCH /articles/2")
    @WithUserDetails("admin")
    void t4() throws Exception {
        // When
        ResultActions resultActions = mvc
                .perform(
                        patch("/api/v1/articles/2")
                                .content("""
                                        {
                                            "subject": "제목 2222 !!!",
                                            "content": "내용 2222 !!!"
                                        }
                                        """)
                                .contentType(new MediaType(MediaType.APPLICATION_JSON, StandardCharsets.UTF_8))
                )
                .andDo(print());

        // Then
        resultActions
                .andExpect(status().is2xxSuccessful())
                .andExpect(jsonPath("$.resultCode").value("S-4"))
                .andExpect(jsonPath("$.msg").exists())
                .andExpect(jsonPath("$.data.article.id").value(2))
                .andExpect(jsonPath("$.data.article.subject").value("제목 2222 !!!"))
                .andExpect(jsonPath("$.data.article.content").value("내용 2222 !!!"));

    }

    @Test
    @DisplayName("DELETE /articles/2")
    @WithUserDetails("admin")
    void t5() throws Exception {
        // When
        ResultActions resultActions = mvc
                .perform(delete("/api/v1/articles/2"))
                .andDo(print());

        // Then
        resultActions
                .andExpect(status().is2xxSuccessful())
                .andExpect(handler().handlerType(ArticleController.class))
                .andExpect(handler().methodName("remove"))
                .andExpect(jsonPath("$.resultCode").value("S-5"))
                .andExpect(jsonPath("$.msg").exists());

    }
}

 

 

 

반응형

'Java > SpringBoot' 카테고리의 다른 글

[SpringBoot] 멀티 채팅방 (2)  (0) 2024.06.19
[SpringBoot] 멀티 채팅방 (1)  (2) 2024.06.19
[SpringBoot] REST API (3)  (0) 2024.06.16
[SpringBoot] REST API (2)  (0) 2024.06.16
[SpringBoot] REST API (1)  (0) 2024.06.16