이번 시간에는 카테고리를 조회, 생성, 삭제하는 API를 만들기 위해 웹 계층 코드를 작성해보겠습니다.
controller.category 패키지에 CategoryController입니다.
package kukekyakya.kukemarket.controller.category;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import kukekyakya.kukemarket.dto.category.CategoryCreateRequest;
import kukekyakya.kukemarket.dto.response.Response;
import kukekyakya.kukemarket.service.category.CategoryService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
@Api(value = "Category Controller", tags = "Category")
@RestController
@RequiredArgsConstructor
public class CategoryController {
private final CategoryService categoryService;
@ApiOperation(value = "모든 카테고리 조회", notes = "모든 카테고리를 조회한다.")
@GetMapping("/api/categories")
@ResponseStatus(HttpStatus.OK)
public Response readAll() {
return Response.success(categoryService.readAll());
}
@ApiOperation(value = "카테고리 생성", notes = "카테고리를 생성한다.")
@PostMapping("/api/categories")
@ResponseStatus(HttpStatus.CREATED)
public Response create(@Valid @RequestBody CategoryCreateRequest req) {
categoryService.create(req);
return Response.success();
}
@ApiOperation(value = "카테고리 삭제", notes = "카테고리를 삭제한다.")
@DeleteMapping("/api/categories/{id}")
@ResponseStatus(HttpStatus.OK)
public Response delete(@ApiParam(value = "카테고리 id", required = true) @PathVariable Long id) {
categoryService.delete(id);
return Response.success();
}
}
특별한 내용은 없기에 자세한 설명은 생략하겠습니다.
모든 카테고리를 조회하고, 카테고리를 생성 및 삭제하는 API를 만들어주었습니다.
CategoryCreateRequest는 지난 시간에 자세히 살펴보고 테스트하는 시간을 가져봤습니다.
이제 해당 API에 대한 시큐리티 설정도 해주겠습니다.
카테고리 조회는 누구나 할 수 있지만, 카테고리 생성과 삭제는 관리자만 할 수 있습니다.
이에 알맞게 config.security.SecurityConfig의 configure를 다음과 같이 수정해줍니다.
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.httpBasic().disable()
.formLogin().disable()
.csrf().disable()
.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") // 1
.antMatchers(HttpMethod.DELETE, "/api/categories/**").hasRole("ADMIN") // 1
.anyRequest().hasAnyRole("ADMIN")
.and()
.exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler())
.and()
.exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.and()
.addFilterBefore(new JwtAuthenticationFilter(accessTokenHelper, userDetailsService), UsernamePasswordAuthenticationFilter.class);
}
1. 카테고리의 생성과 삭제에 대한 요청에 ADMIN 권한이 필요하도록 설정하였습니다. 인증된 사용자는 우리가 등록한 JWT 필터를 거치면서 시큐리티가 관리해주는 컨텍스트에 권한 및 인증 정보가 저장됩니다. 이를 통해 접근을 제어할 수 있을 것입니다.
이제 CategoryController를 테스트해보겠습니다.
이를 테스트하기 위해 세 개의 테스트 클래스(통합 테스트, 어드바이스 테스트, 요청 및 응답 테스트)를 작성할 것입니다.
이렇게 작성하는 이유와 필요성은 지난 시간에 설명하였으므로 생략하겠습니다.
test 디렉토리에 CategoryControllerTest를 작성해줍니다.
package kukekyakya.kukemarket.controller.category;
import ...
@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 입니다.
package kukekyakya.kukemarket.controller.category;
...
@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 입니다.
package kukekyakya.kukemarket.controller.category;
import ...
@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"));
}
}
정상적인 요청과 비정상적인 요청이 있었을 때, Spring Security가 사용자의 접근을 알맞게 인증 및 인가해주는지 테스트해주었습니다.
사용자가 인증되지 않거나 접근이 제한되었을 경우, 우리가 정의해둔 CustomAuthenticationEntryPoint와 CustomAccessDeniedHandler에 의해 3xx 상태 코드를 응답 받게 될 것입니다.
통합 테스트를 위해 작성해두었던 TestInitDB에 카테고리 초기화 코드를 추가해주었습니다.
package kukekyakya.kukemarket.init;
import ...
@Component
public class TestInitDB {
...
@Autowired CategoryRepository categoryRepository;
...
@Transactional
public void initDB() {
initRole();
initTestAdmin();
initTestMember();
initCategory();
}
...
private void initCategory() {
Category category1 = new Category("category1", null);
Category category2 = new Category("category2", category1);
categoryRepository.saveAll(List.of(category1, category2));
}
...
}
위와 같은 형태의 두 개의 카테고리를 생성해주었습니다.
이제 작성했던 모든 테스트를 다시 한 번 수행해봅시다.
작성된 모든 테스트가 정상적으로 수행되었습니다.
이제 서버를 구동하고 애플리케이션을 직접 테스트해보겠습니다.
데이터베이스 초기화를 위해 작성했던 InitDB에서 Member와 Role 뿐만 아니라 Category도 초기화해주도록 하겠습니다.
package kukekyakya.kukemarket;
import ...
import java.util.List;
import java.util.stream.Collectors;
@Component
@RequiredArgsConstructor
@Slf4j
@Profile("local")
public class InitDB {
...
private final CategoryRepository categoryRepository;
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void initDB() {
log.info("initialize database");
initRole();
initTestAdmin();
initTestMember();
initCategory();
}
...
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));
}
}
예시에서 활용해왔던 카테고리 형태를 테스트 데이터로 초기화해주었습니다.
이제 서버를 구동하고 포스트맨을 이용하여 직접 요청을 전송해보겠습니다.
먼저 관리자 계정으로 로그인하여 토큰을 발급 받았습니다.
액세스 토큰은 따로 기입해두고, 카테고리를 조회해보도록 하겠습니다.
카테고리 조회는 Spring Security에서 permitAll로 설정해두었기 때문에, 토큰을 함께 전송하지 않아도 됩니다.
응답 결과를 자세히 살펴보겠습니다.
{
"success": true,
"code": 0,
"result": {
"data": [
{
"id": 1,
"name": "category1",
"children": [
{
"id": 2,
"name": "category2",
"children": [
{
"id": 4,
"name": "category4",
"children": [
{
"id": 6,
"name": "category6",
"children": []
}
]
},
{
"id": 5,
"name": "category5",
"children": []
}
]
},
{
"id": 3,
"name": "category3",
"children": [
{
"id": 7,
"name": "category7",
"children": []
}
]
}
]
},
{
"id": 8,
"name": "category8",
"children": []
}
]
}
}
우리가 초기화했던 방식으로, 계층형 구조로 변환되어 정상적으로 응답되었습니다.
자신의 부모만 알고 있던 Category 엔티티가, 지난 시간에 작성했던 NestedConvertHelper를 통해서 자신의 자식들을 알고 있는 DTO로 변환된 것입니다.
루트 카테고리에 category9를, 5의 자식 카테고리에 category10을 추가해보겠습니다.
POST /api/categories 로 요청을 전송합니다.
토큰을 요청 헤더에 전송하지 않았기 때문에 401 상태 코드를 응답받게 되었습니다.
Authorization 헤더에 발급 받았던 액세스 토큰을 기입하여 다시 요청해봅니다.
category9와 category10 생성 요청이 정상적으로 수행되었습니다.
다시 카테고리를 조회해보겠습니다.
{
"success": true,
"code": 0,
"result": {
"data": [
{
"id": 1,
"name": "category1",
"children": [
{
"id": 2,
"name": "category2",
"children": [
{
"id": 4,
"name": "category4",
"children": [
{
"id": 6,
"name": "category6",
"children": []
}
]
},
{
"id": 5,
"name": "category5",
"children": [
{
"id": 10,
"name": "category10",
"children": []
}
]
}
]
},
{
"id": 3,
"name": "category3",
"children": [
{
"id": 7,
"name": "category7",
"children": []
}
]
}
]
},
{
"id": 8,
"name": "category8",
"children": []
},
{
"id": 9,
"name": "category9",
"children": []
}
]
}
}
루트 카테고리로 category9가, 5번의 하위 카테고리로 category10이 생성되었습니다.
이번에는 1번 카테고리를 제거한 뒤, 다시 조회해보겠습니다.
하위 카테고리는 모두 제거되기 때문에, 8번과 9번 카테고리만 남게될 것입니다.
DELETE /api/categories/1 요청을 전송하고, 다시 조회한 결과입니다.
{
"success": true,
"code": 0,
"result": {
"data": [
{
"id": 8,
"name": "category8",
"children": []
},
{
"id": 9,
"name": "category9",
"children": []
}
]
}
}
8번과 9번 카테고리만 남은 것을 확인할 수 있습니다.
이렇게 해서 계층형 카테고리를 구현하게 되었습니다.
자신의 부모만 알고 있는 엔티티를, 어떻게 해야 자식들을 알고 있는 계층형 구조로 변환할지에 대해서 논의하였고, 이를 위해 JPQL을 이용하여 새로운 쿼리를 작성하였습니다.
계층형 구조 변환 로직을 재사용할 수 있도록, 제너릭 타입을 사용하는 NestedConvertHelper 클래스를 정의하였습니다.
이를 통해 플랫한 구조의 엔티티 목록을 계층형 구조의 DTO 목록으로 변환하였고, 앞으로 작성될 계층형 대댓글에서도 이를 재사용할 수 있을 것입니다.
CategoryCreateRequest에서 null을 다루기 위해 Optional을 활용하며 학습 테스트도 작성해보았고, 이를 통해 간결한 코드를 작성할 수 있었습니다.
새롭게 작성된 API에 알맞게 Spring Security를 수정하였습니다.
모든 과정에는 테스트가 뒤따랐습니다.
다음 시간부터는 게시글 기능을 구현해보겠습니다.
* 질문 및 피드백은 환영입니다.
* 전체 소스코드에서는 여기에서 확인해볼 수 있습니다.
'Spring > 게시판 만들기' 카테고리의 다른 글
스프링부트 게시판 API 서버 만들기 (19) - Entity Graph로 LAZY 전략에서 N + 1 문제 해결 (0) | 2021.12.08 |
---|---|
스프링부트 게시판 API 서버 만들기 (18) - JPA 오해 바로잡기 (delete와 deleteById의 차이) (0) | 2021.12.08 |
스프링부트 게시판 API 서버 만들기 (16) - 게시판 - 계층형 카테고리 - 2 (0) | 2021.12.08 |
스프링부트 게시판 API 서버 만들기 (15) - 게시판 - 계층형 카테고리 - 1 (5) | 2021.12.08 |
스프링부트 게시판 API 서버 만들기 (14) - Swagger로 API 문서 만들기 (0) | 2021.12.07 |