From c12c945e8d63304517d3f9085639c7a4e44fea3e Mon Sep 17 00:00:00 2001 From: Seonheui Jeon <88873302+funnysunny08@users.noreply.github.com> Date: Fri, 10 Nov 2023 00:49:34 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20S3=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=ED=95=9C=EB=8B=A4.=20=20(#151)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 이미지를 업로드한다. * refactor: 이미지 관련 파일을 global로 옮긴다. * feat: 이미지 업로드 테스트를 작성한다. * feat: s3 yml에 추가한다. * fix: submodule 문제를 해결한다. * feat: S3ServiceTest에 예외 상황을 추가한다. * feat: UploadImagesResponse 생성할 때는 정적 페토리 메서드를 사용한다. * feat: 이미지 파일 개수를 1이상 5이하로 제한한다. --- build.gradle | 4 + .../anifriends/global/config/S3Config.java | 35 +++++ .../global/image/ImageController.java | 26 ++++ .../global/image/S3BadRequestException.java | 12 ++ .../anifriends/global/image/S3Service.java | 70 ++++++++++ .../global/image/UploadImagesRequest.java | 14 ++ .../global/image/UploadImagesResponse.java | 11 ++ src/main/resources/backend-config | 2 +- .../anifriends/base/BaseControllerTest.java | 4 + .../global/image/ImageControllerTest.java | 55 ++++++++ .../global/image/S3ServiceTest.java | 123 ++++++++++++++++++ 11 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/clova/anifriends/global/config/S3Config.java create mode 100644 src/main/java/com/clova/anifriends/global/image/ImageController.java create mode 100644 src/main/java/com/clova/anifriends/global/image/S3BadRequestException.java create mode 100644 src/main/java/com/clova/anifriends/global/image/S3Service.java create mode 100644 src/main/java/com/clova/anifriends/global/image/UploadImagesRequest.java create mode 100644 src/main/java/com/clova/anifriends/global/image/UploadImagesResponse.java create mode 100644 src/test/java/com/clova/anifriends/global/image/ImageControllerTest.java create mode 100644 src/test/java/com/clova/anifriends/global/image/S3ServiceTest.java diff --git a/build.gradle b/build.gradle index 2cb717157..12cdf5c63 100644 --- a/build.gradle +++ b/build.gradle @@ -82,6 +82,10 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testImplementation 'org.awaitility:awaitility' + + // S3 AWS + implementation group: 'org.springframework.cloud', name: 'spring-cloud-starter-aws', version: '2.2.6.RELEASE' + testImplementation 'io.findify:s3mock_2.13:0.2.6' } tasks.named('test') { diff --git a/src/main/java/com/clova/anifriends/global/config/S3Config.java b/src/main/java/com/clova/anifriends/global/config/S3Config.java new file mode 100644 index 000000000..1f7e7fccd --- /dev/null +++ b/src/main/java/com/clova/anifriends/global/config/S3Config.java @@ -0,0 +1,35 @@ +package com.clova.anifriends.global.config; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class S3Config { + + @Value("${cloud.aws.credentials.accessKey}") + private String accessKey; + + @Value("${cloud.aws.credentials.secretKey}") + private String secretKey; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public AmazonS3 amazonS3Client() { + AWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + return AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) + .build(); + } +} diff --git a/src/main/java/com/clova/anifriends/global/image/ImageController.java b/src/main/java/com/clova/anifriends/global/image/ImageController.java new file mode 100644 index 000000000..03587b5bb --- /dev/null +++ b/src/main/java/com/clova/anifriends/global/image/ImageController.java @@ -0,0 +1,26 @@ +package com.clova.anifriends.global.image; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/images") +public class ImageController { + + private final S3Service s3Service; + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity uploadImages( + @ModelAttribute @Valid UploadImagesRequest uploadImagesRequest + ) { + return ResponseEntity.ok( + UploadImagesResponse.from(s3Service.uploadImages(uploadImagesRequest.images()))); + } +} diff --git a/src/main/java/com/clova/anifriends/global/image/S3BadRequestException.java b/src/main/java/com/clova/anifriends/global/image/S3BadRequestException.java new file mode 100644 index 000000000..06b5c7f5d --- /dev/null +++ b/src/main/java/com/clova/anifriends/global/image/S3BadRequestException.java @@ -0,0 +1,12 @@ +package com.clova.anifriends.global.image; + +import static com.clova.anifriends.global.exception.ErrorCode.BAD_REQUEST; + +import com.clova.anifriends.global.exception.BadRequestException; + +public class S3BadRequestException extends BadRequestException { + + public S3BadRequestException(String message) { + super(BAD_REQUEST, message); + } +} diff --git a/src/main/java/com/clova/anifriends/global/image/S3Service.java b/src/main/java/com/clova/anifriends/global/image/S3Service.java new file mode 100644 index 000000000..84d9eb073 --- /dev/null +++ b/src/main/java/com/clova/anifriends/global/image/S3Service.java @@ -0,0 +1,70 @@ +package com.clova.anifriends.global.image; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.CannedAccessControlList; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +public class S3Service { + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + private final AmazonS3 amazonS3; + private static final String FOLDER = "images"; + + public List uploadImages(List multipartFileList) { + ObjectMetadata objectMetadata = new ObjectMetadata(); + List list = new ArrayList<>(); + + for (MultipartFile multipartFile : multipartFileList) { + String fileName = createFileName(multipartFile.getOriginalFilename()); + objectMetadata.setContentLength(multipartFile.getSize()); + objectMetadata.setContentType(multipartFile.getContentType()); + + try (InputStream inputStream = multipartFile.getInputStream()) { + amazonS3.putObject( + new PutObjectRequest(bucket + "/" + FOLDER, fileName, inputStream, + objectMetadata) + .withCannedAcl(CannedAccessControlList.PublicRead)); + list.add(amazonS3.getUrl(bucket + "/" + FOLDER, fileName).toString()); + } catch (IOException e) { + throw new S3BadRequestException("S3에 이미지를 업로드하는데 실패했습니다."); + } + } + return list; + } + + private String createFileName(String fileName) { + return UUID.randomUUID().toString().concat(getFileExtension(fileName)); + } + + private String getFileExtension(String fileName) { + if (fileName.length() == 0) { + throw new S3BadRequestException("잘못된 파일입니다."); + } + ArrayList fileValidate = new ArrayList<>(); + fileValidate.add(".jpg"); + fileValidate.add(".jpeg"); + fileValidate.add(".png"); + fileValidate.add(".JPG"); + fileValidate.add(".JPEG"); + fileValidate.add(".PNG"); + String idxFileName = fileName.substring(fileName.lastIndexOf(".")); + if (!fileValidate.contains(idxFileName)) { + throw new S3BadRequestException("잘못된 파일 형식입니다."); + } + return fileName.substring(fileName.lastIndexOf(".")); + } +} diff --git a/src/main/java/com/clova/anifriends/global/image/UploadImagesRequest.java b/src/main/java/com/clova/anifriends/global/image/UploadImagesRequest.java new file mode 100644 index 000000000..cc0f10199 --- /dev/null +++ b/src/main/java/com/clova/anifriends/global/image/UploadImagesRequest.java @@ -0,0 +1,14 @@ +package com.clova.anifriends.global.image; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; +import org.springframework.web.multipart.MultipartFile; + +public record UploadImagesRequest( + @NotNull(message = "이미지는 필수 입력 항목입니다.") + @Size(min = 1, max = 5, message = "이미지는 1개 이상 5개 이하로 선택하세요.") + List images +) { + +} diff --git a/src/main/java/com/clova/anifriends/global/image/UploadImagesResponse.java b/src/main/java/com/clova/anifriends/global/image/UploadImagesResponse.java new file mode 100644 index 000000000..47913a5e7 --- /dev/null +++ b/src/main/java/com/clova/anifriends/global/image/UploadImagesResponse.java @@ -0,0 +1,11 @@ +package com.clova.anifriends.global.image; + +import java.util.List; + +public record UploadImagesResponse( + List imageUrls +) { + public static UploadImagesResponse from(List imageUrls) { + return new UploadImagesResponse(imageUrls); + } +} diff --git a/src/main/resources/backend-config b/src/main/resources/backend-config index df0c8d6fa..cc1a30e86 160000 --- a/src/main/resources/backend-config +++ b/src/main/resources/backend-config @@ -1 +1 @@ -Subproject commit df0c8d6fa515549e616414b499743ae1f89ac865 +Subproject commit cc1a30e86ed18b48e145ecbeeb4a4ea1315d5542 diff --git a/src/test/java/com/clova/anifriends/base/BaseControllerTest.java b/src/test/java/com/clova/anifriends/base/BaseControllerTest.java index ab2f5af2c..d15afa63e 100644 --- a/src/test/java/com/clova/anifriends/base/BaseControllerTest.java +++ b/src/test/java/com/clova/anifriends/base/BaseControllerTest.java @@ -21,6 +21,7 @@ import com.clova.anifriends.domain.volunteer.service.VolunteerService; import com.clova.anifriends.global.config.SecurityConfig; import com.clova.anifriends.global.config.WebMvcConfig; +import com.clova.anifriends.global.image.S3Service; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.Properties; import org.junit.jupiter.api.BeforeAll; @@ -100,6 +101,9 @@ public JwtAuthenticationProvider jwtAuthenticationProvider(JwtProvider jwtProvid @MockBean protected ReviewService reviewService; + @MockBean + protected S3Service s3Service; + protected final String volunteerAccessToken = AuthFixture.volunteerAccessToken(); protected String shelterAccessToken = AuthFixture.shelterAccessToken(); diff --git a/src/test/java/com/clova/anifriends/global/image/ImageControllerTest.java b/src/test/java/com/clova/anifriends/global/image/ImageControllerTest.java new file mode 100644 index 000000000..a8df8687b --- /dev/null +++ b/src/test/java/com/clova/anifriends/global/image/ImageControllerTest.java @@ -0,0 +1,55 @@ +package com.clova.anifriends.global.image; + +import static java.sql.JDBCType.ARRAY; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.clova.anifriends.base.BaseControllerTest; +import com.clova.anifriends.docs.format.DocumentationFormatGenerator; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.web.multipart.MultipartFile; + +class ImageControllerTest extends BaseControllerTest { + + @Test + @DisplayName("이미지 업로드 API 호출 시") + void uploadImages() throws Exception { + // given + List 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()) + ); + + // when + ResultActions resultActions = mockMvc.perform( + multipart("/api/images") + .file("images", imageFiles.get(0).getBytes()) + .file("images", imageFiles.get(1).getBytes()) + .with(requestPostProcessor -> { + requestPostProcessor.setMethod("POST"); + return requestPostProcessor; + }) + .contentType(MediaType.MULTIPART_FORM_DATA)); + + // then + resultActions.andExpect(status().isOk()) + .andDo(restDocs.document( + requestParts( + partWithName("images").description("이미지 파일") + .attributes(DocumentationFormatGenerator.getConstraint("이미지 파일은 1 이상 5이하")) + ), + responseFields( + fieldWithPath("imageUrls").type(ARRAY).description("이미지 URL 목록") + ) + )); + } +} diff --git a/src/test/java/com/clova/anifriends/global/image/S3ServiceTest.java b/src/test/java/com/clova/anifriends/global/image/S3ServiceTest.java new file mode 100644 index 000000000..d7a2f1d98 --- /dev/null +++ b/src/test/java/com/clova/anifriends/global/image/S3ServiceTest.java @@ -0,0 +1,123 @@ +package com.clova.anifriends.global.image; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import com.amazonaws.services.s3.AmazonS3; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +class S3ServiceTest { + + @Mock + private AmazonS3 amazonS3; + + private S3Service s3Service; + + @BeforeEach + void setUp() { + amazonS3 = Mockito.mock(AmazonS3.class); + s3Service = new S3Service(amazonS3); + ReflectionTestUtils.setField(s3Service, "bucket", "bucket-name"); + } + + @Nested + @DisplayName("uploadImages 메서드 실행 시") + class UploadImagesTest { + + @Test + @DisplayName("성공") + void testUploadImages() { + // given + MockMultipartFile file1 = new MockMultipartFile("file1", "test1.jpg", "image/jpeg", "file content".getBytes()); + MockMultipartFile file2 = new MockMultipartFile("file2", "test2.jpg", "image/jpeg", "file content".getBytes()); + List expectedUrls = Arrays.asList( + "https://example.com/bucket-name/images/random-uuid.jpg", + "https://example.com/bucket-name/images/random-uuid.jpg" + ); + + when(amazonS3.putObject(any())).thenReturn(null); + + when(amazonS3.getUrl(any(), any())) + .thenAnswer(invocation -> { + String bucketName = invocation.getArgument(0); + String fileName = invocation.getArgument(1); + return new java.net.URL("https", "example.com", "/" + bucketName + "/" + fileName); + }); + + // when + List uploadedUrls = s3Service.uploadImages(Arrays.asList(file1, file2)); + + // then + assertThat(uploadedUrls.size()).isEqualTo(expectedUrls.size()); + } + + @Test + @DisplayName("예외(S3BadRequestException): 파일이 확장자가 잘못된 경우") + void throwExceptionWhenFileExtensionIsWrong() { + // given + MockMultipartFile file1 = new MockMultipartFile("file1", "test1.abcd", "image/jpeg", "file content".getBytes()); + MockMultipartFile file2 = new MockMultipartFile("file2", "test2.sdf", "image/jpeg", "file content".getBytes()); + List expectedUrls = Arrays.asList( + "https://example.com/bucket-name/images/random-uuid.jpg", + "https://example.com/bucket-name/images/random-uuid.jpg" + ); + + when(amazonS3.putObject(any())).thenReturn(null); + + when(amazonS3.getUrl(any(), any())) + .thenAnswer(invocation -> { + String bucketName = invocation.getArgument(0); + String fileName = invocation.getArgument(1); + return new java.net.URL("https", "example.com", "/" + bucketName + "/" + fileName); + }); + + // when + Exception exception = catchException( + () -> s3Service.uploadImages(Arrays.asList(file1, file2)) + ); + + // then + assertThat(exception).isInstanceOf(S3BadRequestException.class); + } + + @Test + @DisplayName("예외(S3BadRequestException): 파일 이름 길이가 0인 경우") + void throwExceptionWhenFileNameLengthIsZero() { + // given + MockMultipartFile file1 = new MockMultipartFile("file1", "", "image/jpeg", "file content".getBytes()); + MockMultipartFile file2 = new MockMultipartFile("file2", "", "image/jpeg", "file content".getBytes()); + List expectedUrls = Arrays.asList( + "https://example.com/bucket-name/images/random-uuid.jpg", + "https://example.com/bucket-name/images/random-uuid.jpg" + ); + + when(amazonS3.putObject(any())).thenReturn(null); + + when(amazonS3.getUrl(any(), any())) + .thenAnswer(invocation -> { + String bucketName = invocation.getArgument(0); + String fileName = invocation.getArgument(1); + return new java.net.URL("https", "example.com", "/" + bucketName + "/" + fileName); + }); + + // when + Exception exception = catchException( + () -> s3Service.uploadImages(Arrays.asList(file1, file2)) + ); + + // then + assertThat(exception).isInstanceOf(S3BadRequestException.class); + } + } +}