사이먼's 코딩노트
[SpringBoot] REST API (4) 본문
[REST API 적용]
- 저번 'REST API (3)' 포스팅에 이어서 REST API를 적용하기 위한 코드를 작성해봅시다.
- 게시물 등록을 위한 Article MVC를 설계한 다음, 이제 실제로 게시물 CRUD를 REST API로 적용해봅시다.
- 전체 코드와 패키지 구조는 깃허브 리포지터리 주소를 통해 참고 부탁드립니다.
- 리포지터리 URL 주소 : https://github.com/psm817/jwt_review
[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 |