Spring boot 게시판 API 서버 제작 (15) - 게시판 - 카테고리 - 웹 계층 구현
이번에는 Category API 처리를 위한 웹 계층을 구현해보겠습니다.
Category Controller
@RestController
@RequiredArgsConstructor
public class CategoryController {
private final CategoryService categoryService;
@GetMapping("/api/categories")
@ResponseStatus(HttpStatus.OK)
public Response readAll(){ // 모든 카테고리 조회
return Response.success(categoryService.readAll());
}
@PostMapping("/api/categories")
@ResponseStatus(HttpStatus.CREATED)
//@Valid: 요청받은 데이터 유효성 검사
//@RequestBody : Http 요청의 body 부분
public Response create(@Valid @RequestBody CategoryCreateRequest req) {
categoryService.create(req);
return Response.success();
}
@DeleteMapping("/api/categories/{id}")
@ResponseStatus(HttpStatus.OK)
//@PathVariable : 경로에 포함된 카테고리 ID 추출
public Response delete(@PathVariable Long id){
categoryService.delete(id);
return Response.success();
}
}
코드에 대한 설명은 코드 내부 주석 처리를 참고해주세요.
Security Config 설정
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.httpBasic().disable() // HTTP Basic 인증을 사용하지
.formLogin().disable() // Form 로그인을 사용하지 않음
.csrf().disable() // CSRF 공격으로부터 보호하지 않음
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션을 사용하지 않음
.and()
.authorizeRequests() // 요청에 대한 인가를 구성
.antMatchers(HttpMethod.POST, "/api/sign-in", "/api/sign-up", "/api/refresh-token").permitAll()
.antMatchers(HttpMethod.GET, "/api/**").permitAll()
.antMatchers(HttpMethod.DELETE, "/api/members/{id}/**").access("@memberGuard.check(#id)")
.antMatchers(HttpMethod.POST, "/api/categories/**").hasRole("ADMIN")
.antMatchers(HttpMethod.DELETE, "/api/categories/**").hasRole("ADMIN")
.anyRequest().hasAnyRole("ADMIN")
.and()
.exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler()) // 엑세스 거부 예외
.and()
.exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.and()
.addFilterBefore( // JwtAuthenticationFilter 를 UsernamePasswordAuthenticationFilter 앞에 등록
new JwtAuthenticationFilter(accessTokenHelper, userDetailsService) //JWT 토큰 기반 인증
, UsernamePasswordAuthenticationFilter.class
);
}
POST, DELETE /api/categories/** 요청은 ADMIN 권한에서만 가능합니다. ADMIN 권한을 확인은 JwtAuthenticationFilter을 거쳐서 컨테스트에 저장된 권한 및 인증정보를 비교를 통해 이루어집니다.
CategoryController TEST
테스트 내용은 이전과 동일하니 설명 생략합니다.
CategoryControllerTest.java
@ExtendWith(MockitoExtension.class)
class CategoryControllerTest {
@InjectMocks
CategoryController categoryController;
@Mock
CategoryService categoryService;
MockMvc mockMvc;
ObjectMapper objectMapper = new ObjectMapper();
@BeforeEach
void beforeEach() {
mockMvc = MockMvcBuilders.standaloneSetup(categoryController).build();
}
@Test
void readAllTest() throws Exception {
// given, when, then
mockMvc.perform(get("/api/categories"))
.andExpect(status().isOk());
verify(categoryService).readAll();
}
@Test
void createTest() throws Exception {
// given
CategoryCreateRequest req = createCategoryCreateRequest();
// when, then
mockMvc.perform(
post("/api/categories")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isCreated());
verify(categoryService).create(req);
}
@Test
void deleteTest() throws Exception {
// given
Long id = 1L;
// when, then
mockMvc.perform(
delete("/api/categories/{id}", id))
.andExpect(status().isOk());
verify(categoryService).delete(id);
}
}
CategoryControllerAdviceTest.java
@ExtendWith(MockitoExtension.class)
class CategoryControllerAdviceTest {
@InjectMocks CategoryController categoryController;
@Mock CategoryService categoryService;
MockMvc mockMvc;
@BeforeEach
void beforeEach() {
mockMvc = MockMvcBuilders.standaloneSetup(categoryController).setControllerAdvice(new ExceptionAdvice()).build();
}
@Test
void readAllTest() throws Exception {
// given
given(categoryService.readAll()).willThrow(CannotConvertNestedStructureException.class);
// when, then
mockMvc.perform(get("/api/categories"))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.code").value(-1011));
}
@Test
void deleteTest() throws Exception {
// given
doThrow(CategoryNotFoundException.class).when(categoryService).delete(anyLong());
// when, then
mockMvc.perform(delete("/api/categories/{id}", 1L))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(-1010));
}
}
CategoryControllerIntegrationTest.java
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles(value = "test")
@Transactional
public class CategoryControllerIntegrationTest {
@Autowired WebApplicationContext context;
@Autowired MockMvc mockMvc;
@Autowired TestInitDB initDB;
@Autowired CategoryRepository categoryRepository;
@Autowired MemberRepository memberRepository;
@Autowired SignService signService;
ObjectMapper objectMapper = new ObjectMapper();
@BeforeEach
void beforeEach() {
mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build();
initDB.initDB();
}
@Test
void readAllTest() throws Exception {
// given, when, then
mockMvc.perform(
get("/api/categories"))
.andExpect(status().isOk());
}
@Test
void createTest() throws Exception {
// given
CategoryCreateRequest req = createCategoryCreateRequest();
SignInResponse adminSignInRes = signService.signIn(createSignInRequest(initDB.getAdminEmail(), initDB.getPassword()));
int beforeSize = categoryRepository.findAll().size();
// when, then
mockMvc.perform(
post("/api/categories")
.header("Authorization", adminSignInRes.getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isCreated());
List<Category> result = categoryRepository.findAll();
assertThat(result.size()).isEqualTo(beforeSize + 1);
}
@Test
void createUnauthorizedByNoneTokenTest() throws Exception {
// given
CategoryCreateRequest req = createCategoryCreateRequest();
// when, then
mockMvc.perform(
post("/api/categories")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/exception/entry-point"));
}
@Test
void createAccessDeniedByNormalMemberTest() throws Exception {
// given
CategoryCreateRequest req = createCategoryCreateRequest();
SignInResponse normalMemberSignInRes = signService.signIn(createSignInRequest(initDB.getMember1Email(), initDB.getPassword()));
// when, then
mockMvc.perform(
post("/api/categories")
.header("Authorization", normalMemberSignInRes.getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/exception/access-denied"));
}
@Test
void deleteTest() throws Exception {
// given
Long id = categoryRepository.findAll().get(0).getId();
SignInResponse adminSignInRes = signService.signIn(createSignInRequest(initDB.getAdminEmail(), initDB.getPassword()));
// when, then
mockMvc.perform(delete("/api/categories/{id}", id)
.header("Authorization", adminSignInRes.getAccessToken()))
.andExpect(status().isOk());
List<Category> result = categoryRepository.findAll();
assertThat(result.size()).isEqualTo(0);
}
@Test
void deleteUnauthorizedByNoneTokenTest() throws Exception {
// given
Long id = categoryRepository.findAll().get(0).getId();
// when, then
mockMvc.perform(delete("/api/categories/{id}", id))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/exception/entry-point"));
}
@Test
void deleteAccessDeniedByNormalMemberTest() throws Exception {
// given
Long id = categoryRepository.findAll().get(0).getId();
SignInResponse normalMemberSignInRes = signService.signIn(createSignInRequest(initDB.getMember1Email(), initDB.getPassword()));
// when, then
mockMvc.perform(delete("/api/categories/{id}", id)
.header("Authorization", normalMemberSignInRes.getAccessToken()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/exception/access-denied"));
}
}
TestInitDB.java
@Component //bean으로 등록
public class TestInitDB {
@Autowired RoleRepository roleRepository;
@Autowired MemberRepository memberRepository;
@Autowired PasswordEncoder passwordEncoder;
@Autowired CategoryRepository categoryRepository;
private String adminEmail = "admin@admin.com";
private String member1Email = "member1@member.com";
private String member2Email = "member2@member.com";
private String password = "123456a!";
@Transactional
public void initDB() {
initRole(); // RoleType enum의 모든 값을 role에 저장
initTestAdmin(); //관리자 계정 추가
initTestMember(); // 멤버 계정 추가
initCategory();
}
private void initRole() {
roleRepository.saveAll(
List.of(RoleType.values()).stream().map(roleType -> new Role(roleType)).collect(Collectors.toList())
);
}
private void initTestAdmin() {
memberRepository.save(
new Member(adminEmail, passwordEncoder.encode(password), "admin", "admin",
List.of(roleRepository.findByRoleType(RoleType.ROLE_NORMAL).orElseThrow(RoleNotFoundException::new),
roleRepository.findByRoleType(RoleType.ROLE_ADMIN).orElseThrow(RoleNotFoundException::new)))
);
}
private void initTestMember() {
memberRepository.saveAll(
List.of(
new Member(member1Email, passwordEncoder.encode(password), "member1", "member1",
List.of(roleRepository.findByRoleType(RoleType.ROLE_NORMAL).orElseThrow(RoleNotFoundException::new))),
new Member(member2Email, passwordEncoder.encode(password), "member2", "member2",
List.of(roleRepository.findByRoleType(RoleType.ROLE_NORMAL).orElseThrow(RoleNotFoundException::new))))
);
}
private void initCategory() {
Category category1 = new Category("category1", null);
Category category2 = new Category("category2", category1);
categoryRepository.saveAll(List.of(category1, category2));
}
public String getAdminEmail() {
return adminEmail;
}
public String getMember1Email() {
return member1Email;
}
public String getMember2Email() {
return member2Email;
}
public String getPassword() {
return password;
}
}
API 테스트
API테스트를 하기전에 Test를 위해 TestInitDB에 Category를 추가한 것처럼 InitDB에도 똑같이 추가해주겠습니다.
InitDB
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void initDB(){
log.info("initialize database");
initRole();
initTestAdmin();
initTestMember();
initCategory();
}
private void initRole(){
roleRepository.saveAll( // 모든 Role_Type role에 저장
List.of(RoleType.values()).stream().map(roleType -> new Role(roleType)).collect(Collectors.toList())
);
}
private void initTestAdmin() {
memberRepository.save(
new Member("admin@admin.com", passwordEncoder.encode("123456a!"), "admin", "admin",
List.of(roleRepository.findByRoleType(RoleType.ROLE_NORMAL).orElseThrow(RoleNotFoundException::new),
roleRepository.findByRoleType(RoleType.ROLE_ADMIN).orElseThrow(RoleNotFoundException::new)))
);
}
private void initTestMember() {
memberRepository.saveAll(
List.of(
new Member("member1@member.com", passwordEncoder.encode("123456a!"), "member1", "member1",
List.of(roleRepository.findByRoleType(RoleType.ROLE_NORMAL).orElseThrow(RoleNotFoundException::new))),
new Member("member2@member.com", passwordEncoder.encode("123456a!"), "member2", "member2",
List.of(roleRepository.findByRoleType(RoleType.ROLE_NORMAL).orElseThrow(RoleNotFoundException::new))))
);
}
private void initCategory() {
Category c1 = categoryRepository.save(new Category("category1", null));
Category c2 = categoryRepository.save(new Category("category2", c1));
Category c3 = categoryRepository.save(new Category("category3", c1));
Category c4 = categoryRepository.save(new Category("category4", c2));
Category c5 = categoryRepository.save(new Category("category5", c2));
Category c6 = categoryRepository.save(new Category("category6", c4));
Category c7 = categoryRepository.save(new Category("category7", c3));
Category c8 = categoryRepository.save(new Category("category8", null));
}
}
이제 모든 준비가 끝났으니 Postman을 통해 API 요청을 해보겠습니다.
sign-in
로그인을 하게되면 access token과 refresh token을 발급받는데, 관리자 계정으로 로그인하여 관리자 access token을 발급 받습니다.
[GET] /api/categories
먼저 GET /api/categories 요청을 하여 category들을 모두 읽습니다. GET요청에는 access token 이 필요 없습니다.
InitDB에서 저장한 category와 일치하는 것을 확인할 수 있습니다.
[POST] /api/categories
이번에는 category9를 추가해 보겠습니다.
POST /api/categories 요청을 통해 category를 추가하는데 postman에서 설정해줘야 할 것들이 있습니다.
1. 먼저 로그인을 하고 나서 받은 access token을 Authorization에 넣습니다.
2.body 태그에서 category 로직에 맞춰 내용을 작성합니다.
send를 누르면 다음과 같이 success 화면을 볼 수 있습니다.
3.결과 확인
다시 [GET] /api/categories 요청을 하면 category9가 추가된 것을 확인할 수 있습니다.
궁금한신점이나 잘못된 부분이 있으면 자유롭게 댓글 달아주세요.