이번에는 게시글 생성 기능을 구현해 보겠습니다.
서비스 로직부터 API생성까지 진행하겠습니다.
DTO
먼저 서비스 로직을 요청할 때 필요한 요청클래스 PostCreateRequest와 반환클래스 PostCreateResponse를 만들어 보겠습니다.
PostCreateRequest
@ApiModel(value = "게시글 생성 요청")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PostCreateRequest {
@ApiModelProperty(value = "게시글 제목", notes = "게시글 제목을 입력해주세요.", required = true, example = "my title")
@NotBlank(message = "게시글 제목을 입력해주세요")
private String title;
@ApiModelProperty(value = "게시글 본문", notes = "게시글 본문을 입력해주세요", required = true, example = "my content")
@NotBlank(message = "게시글 본문을 입력해주세요.")
private String content;
@ApiModelProperty(hidden = true)
@Null // AOP 를 이용하여 token 에 저장된 사용자 ID를 PostCreateRequest 에 직접 주입
private Long memberId;
@ApiModelProperty(value = "카테고리 아이디", notes = "카테고리 아이디를 입력해주세요", required = true, example = "3")
@NotNull(message = "카테고리 아이디를 입력해주세요.")
@PositiveOrZero(message = "올바른 카테고리 아이디를 입력해주세요.")
private Long categoryId;
@ApiModelProperty(value = "이미지", notes = "이미지를 첨부해주세요.")
private List<MultipartFile> images = new ArrayList<>();
public static Post toEntity(PostCreateRequest req, MemberRepository memberRepository, CategoryRepository categoryRepository){
return new Post(
req.title,
req.content,
memberRepository.findById(req.memberId).orElseThrow(MemberNotFoundException::new),
categoryRepository.findById(req.categoryId).orElseThrow(CategoryNotFoundException::new),
req.images.stream().map(i -> new Image(i.getOriginalFilename())).collect(Collectors.toList())
);
}
}
- memberId : @Null 어노테이션을 사용하여 해당 데이터는 클라이어트로부터 받지 않도록 합니다. 대신에 AOP를 이용하여 token에 저장된 memberId를 PostCreateRequest에 직접 주입하겠습니다.
PostCreateResponse
@Data
@AllArgsConstructor
public class PostCreateResponse {
private Long id;
}
Validation 유효성 검사
PostCreateRequest에 대한 유효성 검사를 진행하겠습니다. (설명은 생략)
PostCreateRequestValidationTest
class PostCreateRequestValidationTest {
Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
@Test
public void validateTest(){
//given
PostCreateRequest req = createPostCreateRequestWithMemberId(null);
//when
Set<ConstraintViolation<PostCreateRequest>> validate = validator.validate(req);
//then
assertThat(validate).isEmpty();
}
@Test
public void invalidateByEmptyTitleTest(){
//given
String invalidateValue = null;
PostCreateRequest req = createPostCreateRequestWithTitle(invalidateValue);
//when
Set<ConstraintViolation<PostCreateRequest>> validate = validator.validate(req);
//then
assertThat(validate.size()).isEqualTo(1);
assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidateValue);
}
@Test
public void invalidateByBlankTitleTest(){
//given
String invalidateValue = " ";
PostCreateRequest req = createPostCreateRequestWithTitle(invalidateValue);
//when
Set<ConstraintViolation<PostCreateRequest>> validate = validator.validate(req);
//then
assertThat(validate.size()).isEqualTo(1);
assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidateValue);
}
@Test
public void invalidateByEmptyContentTest(){
//given
String invalidateValue = null;
PostCreateRequest req = createPostCreateRequestWithContent(invalidateValue);
//when
Set<ConstraintViolation<PostCreateRequest>> validate = validator.validate(req);
//then
assertThat(validate.size()).isEqualTo(1);
assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidateValue);
}
@Test
public void invalidateByBlankContentTest(){
//given
String invalidateValue = " ";
PostCreateRequest req = createPostCreateRequestWithContent(invalidateValue);
//when
Set<ConstraintViolation<PostCreateRequest>> validate = validator.validate(req);
//then
assertThat(validate.size()).isEqualTo(1);
assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidateValue);
}
@Test
void invalidateByNotNullMemberIdTest() {
// given
Long invalidValue = 1L;
PostCreateRequest req = createPostCreateRequestWithMemberId(invalidValue);
// when
Set<ConstraintViolation<PostCreateRequest>> validate = validator.validate(req);
// then
assertThat(validate).isNotEmpty();
}
@Test
void invalidateByNullCategoryIdTest() {
// given
Long invalidValue = null;
PostCreateRequest req = createPostCreateRequestWithCategoryId(invalidValue);
// when
Set<ConstraintViolation<PostCreateRequest>> validate = validator.validate(req);
// then
assertThat(validate).isNotEmpty();
assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
}
@Test
void invalidateByNegativeCategoryIdTest() {
// given
Long invalidValue = -1L;
PostCreateRequest req = createPostCreateRequestWithCategoryId(invalidValue);
// when
Set<ConstraintViolation<PostCreateRequest>> validate = validator.validate(req);
// then
assertThat(validate).isNotEmpty();
assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
}
}
PostCreateRequestFactory
public class PostCreateRequestFactory {
public static PostCreateRequest createPostCreateRequest(){
return new PostCreateRequest("title", "content", 1L, 1L, List.of(
new MockMultipartFile("test1", "test1.PNG", MediaType.IMAGE_PNG_VALUE,"test1".getBytes()),
new MockMultipartFile("test2", "test2.PNG", MediaType.IMAGE_PNG_VALUE,"test2".getBytes()),
new MockMultipartFile("test3", "test3.PNG", MediaType.IMAGE_PNG_VALUE,"test3".getBytes())
));
}
public static PostCreateRequest createPostCreateRequest(String title, String content, Long memberId, Long categoryId, List<MultipartFile> images) {
return new PostCreateRequest(title, content, memberId, categoryId, images);
}
public static PostCreateRequest createPostCreateRequestWithTitle(String title){
return new PostCreateRequest(title, "content", null, 1L, List.of());
}
public static PostCreateRequest createPostCreateRequestWithContent(String content){
return new PostCreateRequest("title", content, null, 1L, List.of());
}
public static PostCreateRequest createPostCreateRequestWithMemberId(Long memberId){
return new PostCreateRequest("title", "content", memberId, 1L, List.of());
}
public static PostCreateRequest createPostCreateRequestWithCategoryId(Long categoryId){
return new PostCreateRequest("title", "content", 1L, categoryId, List.of());
}
public static PostCreateRequest createPostCreateRequestWithImages(List<MultipartFile> images){
return new PostCreateRequest("title", "content", 1L, 1L, images);
}
}
File Service 로직
이번에는 파일을 업로드와 삭제할 수 있는 FileService를 생성하겠습니다. (게시글 생성시에 이미지 처리를 위한 서비스)
FileService
FileService는 interface로 구현하고 실제 구현체는 따로 생성하여 실제 파일 업로드 및 삭제를 진행하겠습니다.
public interface FileService {
void upload(MultipartFile file, String filename);
void delete(String filename);
}
LocalFileService
이번 프로젝트에서는 로컬에 저장하도록 하겠습니다.
@Service
public class LocalFileService implements FileService{
@Value("${upload.image.location}")
private String location; // 파일을 업로드 할 위치
@PostConstruct
void postConstruct(){ // 디렉토리 생성
File dir = new File(location);
if(!dir.exists()){
dir.mkdir();
}
}
@Override
public void upload(MultipartFile file, String filename) { // 업로드
try {
file.transferTo(new File(location + filename));
} catch (IOException e) {
System.out.println("e = " + e);
throw new RuntimeException(e);
}
}
@Override
public void delete(String filename) {
}
}
LocalFileService Test
class LocalFileServiceTest {
LocalFileService localFileService = new LocalFileService();
String testLocation = new File("src/test/resources/static").getAbsolutePath() + "/"; //저장할 위치
@BeforeEach
void beforeEach() throws IOException {
ReflectionTestUtils.setField(localFileService, "location", testLocation); //location 필드 값을 직접 설정
FileUtils.cleanDirectory(new File(testLocation));
}
@Test
public void uploadTest(){
//given
MultipartFile file = new MockMultipartFile("myFile", "myFile.txt", MediaType.TEXT_PLAIN_VALUE, "test".getBytes());
String filename = "testFile.txt";
//when
localFileService.upload(file, filename);
//then
Assertions.assertThat(isExists(testLocation+filename)).isTrue();
}
boolean isExists(String filePath) {
return new File(filePath).exists();
}
}
Application.yml 설정
Application.yml
spring:
datasource:
url: jdbc:h2:tcp://localhost/~/wc/board
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
show_sql: true
# format_sql: true
default_batch_fetch_size: 100
profiles:
active: local // <- application-local.yml 활성화
include: secret
servlet.multipart.max-file-size: 5MB //<- multipart 설정
servlet.multipart.max-request-size: 5MB //<- multipart 설정
Application-local.yml
upload:
image:
location: C:\Users\jaeye\wc\spring_board_api\image
Application-test.yml
테스트용으로 하나 더 생성합니다.
upload:
image:
location: test-location
WebConfig 설정
application-local.yml 값을 설정하였으니 파일 업로드 및 접근할 수 있는 값을 가져오도록 webConfig를 설정하겠습니다.
@EnableWebMvc
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Value("${upload.image.location}")
private String location;
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/image/**") // /imgae/** 패턴의 요청 처리
.addResourceLocations("file:" + location) // 정적 리소스를 찾을 위치
.setCacheControl(CacheControl.maxAge(Duration.ofHours(1L)).cachePublic());
}
}
PostService
이제 모든 게시글 생성을 위한 준비가 끝났으니 게시글 생성 로직을 구현해보겠습니다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PostService {
private final PostRepository postRepository;
private final MemberRepository memberRepository;
private final CategoryRepository categoryRepository;
private final FileService fileService;
@Transactional
public PostCreateResponse create(PostCreateRequest req){
Post post = postRepository.save(
PostCreateRequest.toEntity(
req,
memberRepository,
categoryRepository
)
);
uploadImages(post.getImages(), req.getImages());
return new PostCreateResponse(post.getId());
}
private void uploadImages(List<Image> images, List<MultipartFile> filenames) {
IntStream.range(0, images.size()).forEach(i -> fileService.upload(filenames.get(i), images.get(i).getUniqueName()));
}
}
요청을 위한 PosrCreateRequest를 Post Entity로 변환 후 uploadImages를 통해 이미지 업로드를 수행합니다.
PostController
이제 Post Controller를 작성해보겠습니다.
@Api(value = "Post Controller", tags = "Post")
@RestController
@RequiredArgsConstructor
public class PostController {
private final PostService postService;
@ApiOperation(value = "게시글 생성", notes = "게시글을 생성한다.")
@PostMapping("/api/posts")
@ResponseStatus(HttpStatus.CREATED)
@AssignMemberId // 뒤에서 설명
public Response create(@Valid @ModelAttribute PostCreateRequest req){
return Response.success(postService.create(req));
}
}
SecurityConfig 추가
.authorizeRequests() // 요청에 대한 인가를 구성
.antMatchers(HttpMethod.POST, "/api/sign-in", "/api/sign-up", "/api/refresh-token").permitAll()
.antMatchers(HttpMethod.GET, "/api/**").permitAll()
.antMatchers(HttpMethod.GET, "/image/**").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")
.antMatchers(HttpMethod.POST, "/api/posts").authenticated() // <- 추가
.anyRequest().hasAnyRole("ADMIN")
.and()
이제 컨트롤러 요청 PostCreateRequest에 memberId를 직접 넣어 보겠습니다.
AOP
AssignMemberId
@Retention(value = RetentionPolicy.RUNTIME)
@Target(value = ElementType.METHOD)
public @interface AssignMemberId {
}
RUNTIME에 어노테이션이 유지될 수 있도록 설정하고, 메소트 타입에 선언할 수 있도록 설정합니다.
AssignMemberIdAspect
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class AssignMemberIdAspect {
private final AuthHelper authHelper;
@Before("@annotation(farm.board.aop.AssignMemberId)")
public void assignMemberId(JoinPoint joinPoint) {
Arrays.stream(joinPoint.getArgs())
.forEach(arg -> getMethod(arg.getClass(), "setMemberId")
.ifPresent(setMemberId -> invokeMethod(arg, setMemberId, AuthHelper.extractMemberId())));
}
private Optional<Method> getMethod(Class<?> clazz, String methodName) {
try {
return Optional.of(clazz.getMethod(methodName, Long.class));
} catch (NoSuchMethodException e) {
return Optional.empty();
}
}
private void invokeMethod(Object obj, Method method, Object... args) {
try {
method.invoke(obj, args);
} catch (ReflectiveOperationException e) {
throw new RuntimeException(e);
}
}
}
- Aspect : AssignMemberIdAspect가 Aspect임을 나타냅니다.
- @Before("@annotation(farm.board.aop.AssignMemberId)") : AssignMemberId 어노테이션이 붙은 메소드에만 어드바이스가 적용되도록 설정합니다.
- JoinPoint : 현재 실행되는 메소드에 대한 정보를 담고 있스빈다.
- ifPresent() : Optional 객체가 값이 있는 경우에만 람다 표현식을 실행합니다.
- invokeMethod() : 주어진 객체의 주어진 메소드를 실행합니다.
PostController
다시 Postcontroller로 돌아와서 MemberId 설정 부분을 보겠습니다.
@Api(value = "Post Controller", tags = "Post")
@RestController
@RequiredArgsConstructor
public class PostController {
private final PostService postService;
@ApiOperation(value = "게시글 생성", notes = "게시글을 생성한다.")
@PostMapping("/api/posts")
@ResponseStatus(HttpStatus.CREATED)
@AssignMemberId // <- 추가
public Response create(@Valid @ModelAttribute PostCreateRequest req){
return Response.success(postService.create(req));
}
}
PostController 테스트
이제 PostController 테스트를 진행하겠습니다. (설명 생략)
PostControllerTest
@ExtendWith(MockitoExtension.class)
class PostControllerTest {
@InjectMocks PostController postController;
@Mock PostService postService;
MockMvc mockMvc;
@BeforeEach
void beforeEach(){
mockMvc = MockMvcBuilders.standaloneSetup(postController).build();
}
@Test
public void createTest() throws Exception {
//given
ArgumentCaptor<PostCreateRequest> postCreateRequestArgumentCaptor = ArgumentCaptor.forClass(PostCreateRequest.class);
List<MultipartFile> imageFiles = List.of(
new MockMultipartFile("test1", "test1.PNG", MediaType.IMAGE_PNG_VALUE, "test1".getBytes()),
new MockMultipartFile("test2", "test2.PNG", MediaType.IMAGE_PNG_VALUE, "test2".getBytes())
);
PostCreateRequest req = createPostCreateRequestWithImages(imageFiles);
//when //then
mockMvc.perform(
multipart("/api/posts")
.file("images", imageFiles.get(0).getBytes())
.file("images", imageFiles.get(1).getBytes())
.param("title", req.getTitle())
.param("content", req.getContent())
.param("categoryId", String.valueOf(req.getCategoryId()))
.with(requestPostProcessor -> {
requestPostProcessor.setMethod("POST");
return requestPostProcessor;
})
.contentType(MediaType.MULTIPART_FORM_DATA))
.andExpect(status().isCreated());
verify(postService).create(postCreateRequestArgumentCaptor.capture());
PostCreateRequest captureRequest = postCreateRequestArgumentCaptor.getValue();
Assertions.assertThat(captureRequest.getImages().size()).isEqualTo(2);
}
}
PostControllerIntegrationTest
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles(value = "test")
@Transactional
class PostControllerIntegrationTest {
@Autowired WebApplicationContext context;
@Autowired MockMvc mockMvc;
@Autowired TestInitDB initDB;
@Autowired CategoryRepository categoryRepository;
@Autowired MemberRepository memberRepository;
@Autowired PostRepository postRepository;
@Autowired SignService signService;
Member member;
Category category;
@BeforeEach
void beforeEach() {
mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build();
initDB.initDB();
member = memberRepository.findByEmail(initDB.getMember1Email()).orElseThrow(MemberNotFoundException::new);
category = categoryRepository.findAll().get(0);
}
@Test
void createTest() throws Exception {
// given
SignInResponse signInRes = signService.signIn(createSignInRequest(member.getEmail(), initDB.getPassword()));
PostCreateRequest req = createPostCreateRequest("title", "content", member.getId(), category.getId(), List.of());
// when, then
mockMvc.perform(
multipart("/api/posts")
.param("title", req.getTitle())
.param("content", req.getContent())
.param("categoryId", String.valueOf(req.getCategoryId()))
.with(requestPostProcessor -> {
requestPostProcessor.setMethod("POST");
return requestPostProcessor;
})
.contentType(MediaType.MULTIPART_FORM_DATA)
.header("Authorization", signInRes.getAccessToken()))
.andExpect(status().isCreated());
Post post = postRepository.findAll().get(0);
assertThat(post.getTitle()).isEqualTo("title");
assertThat(post.getContent()).isEqualTo("content");
assertThat(post.getMember().getId()).isEqualTo(member.getId());
}
@Test
void createUnauthorizedByNoneTokenTest() throws Exception {
// given
PostCreateRequest req = createPostCreateRequest("title", "content", member.getId(), category.getId(), List.of());
// when, then
mockMvc.perform(
multipart("/api/posts")
.param("title", req.getTitle())
.param("content", req.getContent())
.param("categoryId", String.valueOf(req.getCategoryId()))
.with(requestPostProcessor -> {
requestPostProcessor.setMethod("POST");
return requestPostProcessor;
})
.contentType(MediaType.MULTIPART_FORM_DATA))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/exception/entry-point"));
}
}
궁금한신점이나 잘못된 부분이 있으면 자유롭게 댓글 달아주세요.
github : https://github.com/jaeyeon423/spring_board_api
'spring > 게시판 api' 카테고리의 다른 글
Spring boot 게시판 API 서버 제작 (21) - 게시글 - 삭제 (0) | 2023.03.05 |
---|---|
Spring boot 게시판 API 서버 제작 (20) - 게시글 - 조회 (0) | 2023.03.04 |
Spring boot 게시판 API 서버 제작 (18) - 게시글 - Entity 설계 (0) | 2023.02.28 |
Spring boot 게시판 API 서버 제작 (17) - Member <- Entity Graph 적용 (0) | 2023.02.27 |
Spring boot 게시판 API 서버 제작 (16) - 중간 정리 & 카테고리 API (0) | 2023.02.27 |