Skip to content

Commit

Permalink
feat: S3 이미지 업로드 기능을 구현한다. (#151)
Browse files Browse the repository at this point in the history
* feat: 이미지를 업로드한다.

* refactor: 이미지 관련 파일을 global로 옮긴다.

* feat: 이미지 업로드 테스트를 작성한다.

* feat: s3 yml에 추가한다.

* fix: submodule 문제를 해결한다.

* feat: S3ServiceTest에 예외 상황을 추가한다.

* feat: UploadImagesResponse 생성할 때는 정적 페토리 메서드를 사용한다.

* feat: 이미지 파일 개수를 1이상 5이하로 제한한다.
  • Loading branch information
funnysunny08 authored Nov 9, 2023
1 parent 49f750b commit c12c945
Show file tree
Hide file tree
Showing 11 changed files with 355 additions and 1 deletion.
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
35 changes: 35 additions & 0 deletions src/main/java/com/clova/anifriends/global/config/S3Config.java
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();
}
}
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())));
}
}
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 src/main/java/com/clova/anifriends/global/image/S3Service.java
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("."));
}
}
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
) {

}
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);
}
}
2 changes: 1 addition & 1 deletion src/main/resources/backend-config
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();

Expand Down
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 src/test/java/com/clova/anifriends/global/image/S3ServiceTest.java
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);
}
}
}

0 comments on commit c12c945

Please sign in to comment.