-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* feat: 이미지를 업로드한다. * refactor: 이미지 관련 파일을 global로 옮긴다. * feat: 이미지 업로드 테스트를 작성한다. * feat: s3 yml에 추가한다. * fix: submodule 문제를 해결한다. * feat: S3ServiceTest에 예외 상황을 추가한다. * feat: UploadImagesResponse 생성할 때는 정적 페토리 메서드를 사용한다. * feat: 이미지 파일 개수를 1이상 5이하로 제한한다.
- Loading branch information
1 parent
49f750b
commit c12c945
Showing
11 changed files
with
355 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
35 changes: 35 additions & 0 deletions
35
src/main/java/com/clova/anifriends/global/config/S3Config.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(); | ||
} | ||
} |
26 changes: 26 additions & 0 deletions
26
src/main/java/com/clova/anifriends/global/image/ImageController.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<UploadImagesResponse> uploadImages( | ||
@ModelAttribute @Valid UploadImagesRequest uploadImagesRequest | ||
) { | ||
return ResponseEntity.ok( | ||
UploadImagesResponse.from(s3Service.uploadImages(uploadImagesRequest.images()))); | ||
} | ||
} |
12 changes: 12 additions & 0 deletions
12
src/main/java/com/clova/anifriends/global/image/S3BadRequestException.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
} | ||
} |
70 changes: 70 additions & 0 deletions
70
src/main/java/com/clova/anifriends/global/image/S3Service.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<String> uploadImages(List<MultipartFile> multipartFileList) { | ||
ObjectMetadata objectMetadata = new ObjectMetadata(); | ||
List<String> 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<String> 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(".")); | ||
} | ||
} |
14 changes: 14 additions & 0 deletions
14
src/main/java/com/clova/anifriends/global/image/UploadImagesRequest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<MultipartFile> images | ||
) { | ||
|
||
} |
11 changes: 11 additions & 0 deletions
11
src/main/java/com/clova/anifriends/global/image/UploadImagesResponse.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
package com.clova.anifriends.global.image; | ||
|
||
import java.util.List; | ||
|
||
public record UploadImagesResponse( | ||
List<String> imageUrls | ||
) { | ||
public static UploadImagesResponse from(List<String> imageUrls) { | ||
return new UploadImagesResponse(imageUrls); | ||
} | ||
} |
Submodule backend-config
updated
from df0c8d to cc1a30
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
55 changes: 55 additions & 0 deletions
55
src/test/java/com/clova/anifriends/global/image/ImageControllerTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<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()) | ||
); | ||
|
||
// 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 목록") | ||
) | ||
)); | ||
} | ||
} |
123 changes: 123 additions & 0 deletions
123
src/test/java/com/clova/anifriends/global/image/S3ServiceTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<String> 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<String> 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<String> 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<String> 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); | ||
} | ||
} | ||
} |