Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.somemore.imageupload.converter;

import com.fasterxml.jackson.databind.ObjectMapper;
import java.lang.reflect.Type;
import org.springframework.http.MediaType;
import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter;
import org.springframework.stereotype.Component;

@Component
public class MultipartJackson2HttpMessageConverter extends AbstractJackson2HttpMessageConverter {

/**
* Converter for support http request with header Content-Type: multipart/form-data
*/
public MultipartJackson2HttpMessageConverter(ObjectMapper objectMapper) {
super(objectMapper, MediaType.APPLICATION_OCTET_STREAM);
}

@Override
public boolean canWrite(Class<?> clazz, MediaType mediaType) {
return false;
}

@Override
public boolean canWrite(Type type, Class<?> clazz, MediaType mediaType) {
return false;
}

@Override
protected boolean canWrite(MediaType mediaType) {
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.somemore.imageupload.usecase.ImageUploadUseCase;
import com.somemore.imageupload.util.ImageUploadUtils;
import com.somemore.imageupload.validator.ImageUploadValidator;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
Expand All @@ -30,8 +31,22 @@ public class ImageUploadService implements ImageUploadUseCase {
@Value("${cloud.aws.s3.base-url}")
private String baseUrl;

@Value("${default.image.url}")
private String defaultImageUrl;

public static String DEFAULT_IMAGE_URL;

@PostConstruct
private void init() {
DEFAULT_IMAGE_URL = defaultImageUrl;
}

@Override
public String uploadImage(ImageUploadRequestDto requestDto) {
if (imageUploadValidator.isEmptyFile(requestDto.imageFile())) {
return DEFAULT_IMAGE_URL;
}

imageUploadValidator.validateFileSize(requestDto.imageFile());
imageUploadValidator.validateFileType(requestDto.imageFile());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,6 @@ public class DefaultImageUploadValidator implements ImageUploadValidator {
private static final long MAX_FILE_SIZE = 8L * 1024 * 1024; // 8MB

public void validateFileSize(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new ImageUploadException(EMPTY_FILE.getMessage());
}

if (file.getSize() > MAX_FILE_SIZE) {
throw new ImageUploadException(FILE_SIZE_EXCEEDED.getMessage());
}
Expand All @@ -28,6 +24,11 @@ public void validateFileType(MultipartFile file) {
}
}

@Override
public boolean isEmptyFile(MultipartFile file) {
return file == null || file.isEmpty();
}

private boolean isAllowedImageType(String contentType) {
return contentType != null && (
contentType.equals("image/jpeg") ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ public interface ImageUploadValidator {

void validateFileSize(MultipartFile file);
void validateFileType(MultipartFile file);
boolean isEmptyFile(MultipartFile file);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package com.somemore.recruitboard.controller;


import static org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE;

import com.somemore.global.common.response.ApiResponse;
import com.somemore.imageupload.dto.ImageUploadRequestDto;
import com.somemore.imageupload.usecase.ImageUploadUseCase;
import com.somemore.recruitboard.dto.request.RecruitBoardCreateRequestDto;
import com.somemore.recruitboard.dto.request.RecruitBoardLocationUpdateRequestDto;
import com.somemore.recruitboard.dto.request.RecruitBoardStatusUpdateRequestDto;
import com.somemore.recruitboard.dto.request.RecruitBoardUpdateRequestDto;
import com.somemore.recruitboard.usecase.command.CreateRecruitBoardUseCase;
import com.somemore.recruitboard.usecase.command.DeleteRecruitBoardUseCase;
import com.somemore.recruitboard.usecase.command.UpdateRecruitBoardUseCase;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.time.LocalDateTime;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.annotation.Secured;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@Tag(name = "Recruit Board Command API", description = "봉사 활동 모집글 생성 수정 삭제 API")
@RequiredArgsConstructor
@RequestMapping("/api")
@RestController
public class RecruitBoardCommandApiController {

private final CreateRecruitBoardUseCase createRecruitBoardUseCase;
private final UpdateRecruitBoardUseCase updateRecruitBoardUseCase;
private final DeleteRecruitBoardUseCase deleteRecruitBoardUseCase;
private final ImageUploadUseCase imageUploadUseCase;

@Secured("ROLE_CENTER")
@Operation(summary = "봉사 활동 모집글 등록", description = "봉사 활동 모집글을 등록합니다.")
@PostMapping(value = "/recruit-board", consumes = MULTIPART_FORM_DATA_VALUE)
public ApiResponse<Long> createRecruitBoard(
@AuthenticationPrincipal String userId,
@Valid @RequestPart("data") RecruitBoardCreateRequestDto requestDto,
@RequestPart(value = "img_file", required = false) MultipartFile image
Comment on lines +50 to +52
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

참고하겠습니다!

) {

String imgUrl = imageUploadUseCase.uploadImage(new ImageUploadRequestDto(image));
return ApiResponse.ok(
201,
createRecruitBoardUseCase.createRecruitBoard(requestDto, getCenterId(userId),
imgUrl),
Comment on lines +58 to +59
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기서 id만 반환하나요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네 아이디만 반환합니다

"봉사 활동 모집글 등록 성공"
);
}

@Secured("ROLE_CENTER")
@Operation(summary = "봉사 활동 모집글 수정", description = "봉사 활동 모집글을 수정합니다.")
@PutMapping(value = "/recruit-board/{id}", consumes = MULTIPART_FORM_DATA_VALUE)
public ApiResponse<String> updateRecruitBoard(
@AuthenticationPrincipal String userId,
@PathVariable Long id,
@Valid @RequestPart("data") RecruitBoardUpdateRequestDto requestDto,
@RequestPart("img_file") MultipartFile image
) {
String imgUrl = imageUploadUseCase.uploadImage(new ImageUploadRequestDto(image));
updateRecruitBoardUseCase.updateRecruitBoard(requestDto, id, getCenterId(userId), imgUrl);

return ApiResponse.ok("봉사 활동 모집글 수정 성공");
}

@Secured("ROLE_CENTER")
@Operation(summary = "봉사 활동 모집글 위치 수정", description = "봉사 활동 모집글의 위치를 수정합니다.")
@PutMapping(value = "/recruit-board/{id}/location")
public ApiResponse<String> updateRecruitBoardLocation(
@AuthenticationPrincipal String userId,
@PathVariable Long id,
@Valid @RequestBody RecruitBoardLocationUpdateRequestDto requestDto
) {

updateRecruitBoardUseCase.updateRecruitBoardLocation(requestDto, id, getCenterId(userId));
return ApiResponse.ok("봉사 활동 모집글 위치 수정 성공");
}

@Secured("ROLE_CENTER")
@Operation(summary = "봉사 활동 모집글 상태 수정", description = "봉사 활동 모집글의 상태를 수정합니다.")
@PatchMapping(value = "/recruit-board/{id}")
public ApiResponse<String> updateRecruitBoardStatus(
@AuthenticationPrincipal String userId,
@PathVariable Long id,
@RequestBody RecruitBoardStatusUpdateRequestDto requestDto
) {
LocalDateTime now = LocalDateTime.now();
updateRecruitBoardUseCase.updateRecruitBoardStatus(requestDto.status(), id,
getCenterId(userId),
now);

return ApiResponse.ok("봉사 활동 모집글 상태 수정 성공");
}

@Secured("ROLE_CENTER")
@Operation(summary = "봉사 활동 모집글 삭제", description = "봉사 활동 모집글을 삭제합니다.")
@DeleteMapping(value = "/recruit-board/{id}")
public ApiResponse<String> deleteRecruitBoard(
@AuthenticationPrincipal String userId,
@PathVariable Long id
) {
deleteRecruitBoardUseCase.deleteRecruitBoard(getCenterId(userId), id);
return ApiResponse.ok("봉사 활동 모집글 삭제 성공");
}

private static UUID getCenterId(String userId) {
return UUID.fromString(userId);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
@RequiredArgsConstructor
@RequestMapping("/api")
@RestController
public class RecruitBoardQueryController {
public class RecruitBoardQueryApiController {

private final RecruitBoardQueryUseCase recruitBoardQueryUseCase;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.somemore.recruitboard.dto.request;

import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import com.somemore.recruitboard.domain.RecruitStatus;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;

@Builder
@JsonNaming(SnakeCaseStrategy.class)
@Schema(description = "봉사 활동 모집글 상태 수정 요청 DTO")
public record RecruitBoardStatusUpdateRequestDto(
@Schema(description = "변경할 봉사 활동 모집글의 상태", example = "CLOSED")
RecruitStatus status
) {

}
4 changes: 4 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,7 @@ server:
charset: UTF-8
enabled: true
force: true

default:
image:
url: ${DEFAULT_IMG_URL}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public SecurityContext createSecurityContext(WithMockCustomUser annotation) {
Authentication auth = new UsernamePasswordAuthenticationToken(
annotation.username(),
"password",
Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"))
Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + annotation.role()))
);

context.setAuthentication(auth);
Expand Down
1 change: 1 addition & 0 deletions src/test/java/com/somemore/WithMockCustomUser.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@
@WithSecurityContext(factory = CustomSecurityContextFactory.class)
public @interface WithMockCustomUser {
String username() default "123e4567-e89b-12d3-a456-426614174000";
String role() default "VOLUNTEER";
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.multipart.MultipartFile;
import software.amazon.awssdk.core.sync.RequestBody;
Expand All @@ -18,7 +19,9 @@
import java.io.IOException;
import java.io.InputStream;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.*;

class ImageUploadServiceTest extends IntegrationTestSupport {
Expand Down Expand Up @@ -76,4 +79,19 @@ void testUploadImage_failure() throws IOException {
// when, then
assertThrows(ImageUploadException.class, () -> imageUploadService.uploadImage(requestDto));
}

@DisplayName("이미지 파일이 없다면 기본 이미지 링크를 반환한다.")
@Test
void uploadImageWithEmptyFile() {
// given
MultipartFile emptyFile = new MockMultipartFile("file", new byte[0]);
given(imageUploadValidator.isEmptyFile(emptyFile)).willReturn(true);
ImageUploadRequestDto requestDto = new ImageUploadRequestDto(emptyFile);

// when
String imgUrl = imageUploadService.uploadImage(requestDto);

// then
assertThat(imgUrl).isEqualTo(ImageUploadService.DEFAULT_IMAGE_URL);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;

class DefaultImageUploadValidatorTest {
Expand All @@ -20,16 +21,16 @@ void setUp() {
}

@Test
@DisplayName("파일이 비어있으면 예외가 발생한다.")
@DisplayName("파일이 비어있는지 확인할 수 있다.")
void shouldThrowExceptionWhenFileIsEmpty() {
//given
MultipartFile emptyFile = new MockMultipartFile("file", new byte[0]);

//when
Throwable exception = assertThrows(ImageUploadException.class, () -> imageUploadValidator.validateFileSize(emptyFile));
boolean isEmpty = imageUploadValidator.isEmptyFile(emptyFile);

//then
assertEquals(ImageUploadException.class, exception.getClass());
assertThat(isEmpty).isTrue();
}

@Test
Expand Down
Loading