Skip to content

Commit

Permalink
feat: s3와 cloudfront 연동해 파일 업로드 기능 추가
Browse files Browse the repository at this point in the history
  • Loading branch information
oliviarla committed Dec 30, 2023
1 parent 99e4350 commit 1aed5dd
Show file tree
Hide file tree
Showing 19 changed files with 229 additions and 85 deletions.
3 changes: 3 additions & 0 deletions perfume-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ dependencies {
testImplementation("org.springframework.restdocs:spring-restdocs-asciidoctor")
testImplementation("io.rest-assured:rest-assured:5.4.0")
testImplementation("org.springframework.security:spring-security-test:6.2.1")

implementation(platform("software.amazon.awssdk:bom:2.17.230"))
implementation("software.amazon.awssdk:s3:2.20.68")
}

tasks.jar {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package io.perfume.api.common.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;

@Configuration
public class S3Configuration {
@Value("${aws.s3.access-key}")
private String accessKey;

@Value("${aws.s3.private-key}")
private String privateKey;

@Bean
public S3Client amazonS3() {
AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, privateKey);

return S3Client.builder()
.region(Region.AP_NORTHEAST_2)
.credentialsProvider(StaticCredentialsProvider.create(credentials))
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,10 @@

import io.perfume.api.file.adapter.in.http.dto.UpdateFileResponseDto;
import io.perfume.api.file.application.port.in.dto.FileResult;
import io.perfume.api.file.application.service.FileService;
import java.io.IOException;
import io.perfume.api.file.application.service.UploadFileService;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.User;
Expand All @@ -23,46 +20,29 @@
@RequiredArgsConstructor
public class FileController {

private final FileService fileService;
private final UploadFileService uploadFileService;

@PostMapping("/v1/file")
public ResponseEntity<UpdateFileResponseDto> saveFile(
public UpdateFileResponseDto saveFile(
@AuthenticationPrincipal final User user, final MultipartFile file) {
final LocalDateTime uploadTime = LocalDateTime.now();
final long userId = parseUserId(user);
final FileResult result = fileService.uploadFile(getFileContent(file), userId, uploadTime);
final FileResult result = uploadFileService.uploadFile(file, userId, uploadTime);

return ResponseEntity.ok(mapToFileResponseDto(result));
return UpdateFileResponseDto.from(result);
}

@PostMapping("/v1/files")
public ResponseEntity<List<UpdateFileResponseDto>> saveFiles(
public List<UpdateFileResponseDto> saveFiles(
@AuthenticationPrincipal final User user, final List<MultipartFile> files) {
final LocalDateTime uploadTime = LocalDateTime.now();
final long userId = parseUserId(user);
final List<FileResult> results =
fileService.uploadFiles(userId, getFileContentAsList(files), uploadTime);
final List<FileResult> results = uploadFileService.uploadFiles(files, userId, uploadTime);

return ResponseEntity.ok(results.stream().map(this::mapToFileResponseDto).toList());
return results.stream().map(UpdateFileResponseDto::from).toList();
}

private long parseUserId(User user) {
return Long.parseLong(user.getUsername());
}

private byte[] getFileContent(MultipartFile file) {
try {
return file.getBytes();
} catch (IOException e) {
return null;
}
}

private List<byte[]> getFileContentAsList(List<MultipartFile> files) {
return files.stream().map(this::getFileContent).filter(Objects::nonNull).toList();
}

private UpdateFileResponseDto mapToFileResponseDto(FileResult result) {
return new UpdateFileResponseDto(result.id(), result.url());
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
package io.perfume.api.file.adapter.in.http.dto;

public record UpdateFileResponseDto(long id, String url) {}
import io.perfume.api.file.application.port.in.dto.FileResult;

public record UpdateFileResponseDto(long id, String url) {
public static UpdateFileResponseDto from(FileResult result) {
return new UpdateFileResponseDto(result.id(), result.url());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package io.perfume.api.file.adapter.out.persistence.s3;

import io.perfume.api.base.PersistenceAdapter;
import io.perfume.api.file.application.exception.SaveFileNotFoundException;
import io.perfume.api.file.application.port.out.S3Repository;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.multipart.MultipartFile;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectResponse;

@PersistenceAdapter
@RequiredArgsConstructor
public class S3PersistenceAdapter implements S3Repository {
@Value("${aws.s3.bucket}")
private String bucketName;

@Value("${aws.s3.cloudFrontPath}")
private String cloudFrontPath;

private final S3Client s3Client;

@Override
public String uploadFile(MultipartFile file) {
String key = file.getOriginalFilename();
PutObjectRequest putObjectRequest =
PutObjectRequest.builder()
.bucket(bucketName)
.key(key)
.contentType(file.getContentType())
.contentLength(file.getSize())
.build();

byte[] fileContent = getFileContent(file);

PutObjectResponse response =
s3Client.putObject(putObjectRequest, RequestBody.fromBytes(fileContent));

if (!response.sdkHttpResponse().statusText().orElse("FAIL").equals("OK")) {
throw new IllegalStateException("Failed to upload file to S3.");
}

return cloudFrontPath + file.getOriginalFilename();
}

private byte[] getFileContent(MultipartFile file) {
try {
return file.getBytes();
} catch (IOException e) {
throw new SaveFileNotFoundException(e.getMessage());
}
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
package io.perfume.api.file.application.exception;

public class SaveFileNotFoundException extends RuntimeException {}
import io.perfume.api.base.CustomHttpException;
import io.perfume.api.base.LogLevel;
import org.springframework.http.HttpStatus;

public class SaveFileNotFoundException extends CustomHttpException {

public SaveFileNotFoundException(String message) {
super(
HttpStatus.BAD_REQUEST,
"Failed to save file because " + message,
"failed to save empty file.",
LogLevel.INFO);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package io.perfume.api.file.application.port.in;

import io.perfume.api.file.application.port.in.dto.FileResult;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.web.multipart.MultipartFile;

public interface UploadFileUseCase {
FileResult uploadFile(MultipartFile file, final long userId, final LocalDateTime now);

List<FileResult> uploadFiles(
final List<MultipartFile> files, final long userId, final LocalDateTime now);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package io.perfume.api.file.application.port.out;

import org.springframework.web.multipart.MultipartFile;

public interface S3Repository {
String uploadFile(MultipartFile file);
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package io.perfume.api.file.application.service;

import io.perfume.api.file.application.port.in.UploadFileUseCase;
import io.perfume.api.file.application.port.in.dto.FileResult;
import io.perfume.api.file.application.port.out.FileRepository;
import io.perfume.api.file.application.port.out.S3Repository;
import io.perfume.api.file.domain.File;
import jakarta.transaction.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

@Service
@RequiredArgsConstructor
public class UploadFileService implements UploadFileUseCase {

private final FileRepository fileRepository;
private final S3Repository s3Repository;

@Transactional
public FileResult uploadFile(MultipartFile file, long userId, LocalDateTime now) {
String url = s3Repository.uploadFile(file);

final File uploadedFile = fileRepository.save(File.createFile(url, userId, now));

return new FileResult(uploadedFile.getId(), uploadedFile.getUrl());
}

public List<FileResult> uploadFiles(List<MultipartFile> files, long userId, LocalDateTime now) {
return files.parallelStream()
.map(
file -> {
String url = s3Repository.uploadFile(file);
final File uploadedFile = fileRepository.save(File.createFile(url, userId, now));
return new FileResult(uploadedFile.getId(), uploadedFile.getUrl());
})
.toList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
import io.perfume.api.user.application.port.in.dto.UserProfileResult;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import java.io.IOException;
import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
Expand Down Expand Up @@ -120,7 +119,7 @@ public void updateProfilePic(@AuthenticationPrincipal User user, final Multipart
var now = LocalDateTime.now();
checkAuthenticatedUser(user);
long userId = parseUserId(user);
updateProfilePicUseCase.update(userId, getFileContent(file), now);
updateProfilePicUseCase.update(userId, file, now);
}

@GetMapping("/users/{id}/reviews")
Expand All @@ -134,14 +133,6 @@ private long parseUserId(User user) {
return Long.parseLong(user.getUsername());
}

private byte[] getFileContent(MultipartFile file) {
try {
return file.getBytes();
} catch (IOException e) {
return null;
}
}

private void checkAuthenticatedUser(User user) {
if (user == null || user.getUsername() == null) {
throw new UserNotAuthenticatedException();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

import io.perfume.api.user.application.port.in.dto.UpdateProfilePicResult;
import java.time.LocalDateTime;
import org.springframework.web.multipart.MultipartFile;

public interface UpdateProfilePicUseCase {

UpdateProfilePicResult update(Long userId, byte[] imageFileContent, LocalDateTime now);
UpdateProfilePicResult update(Long userId, MultipartFile file, LocalDateTime now);
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package io.perfume.api.user.application.service;

import io.perfume.api.file.application.port.in.dto.FileResult;
import io.perfume.api.file.application.service.FileService;
import io.perfume.api.file.application.service.UploadFileService;
import io.perfume.api.user.application.exception.UserNotFoundException;
import io.perfume.api.user.application.port.in.UpdateProfilePicUseCase;
import io.perfume.api.user.application.port.in.dto.UpdateProfilePicResult;
Expand All @@ -11,28 +11,29 @@
import java.time.LocalDateTime;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

@Service
@Transactional
public class UpdateProfilePicService implements UpdateProfilePicUseCase {

private final UserQueryRepository userQueryRepository;
private final UserRepository userRepository;
private final FileService fileUploadService;
private final UploadFileService fileUploadService;

public UpdateProfilePicService(
UserQueryRepository userQueryRepository,
UserRepository userRepository,
FileService fileUploadService) {
UploadFileService fileUploadService) {
this.userQueryRepository = userQueryRepository;
this.userRepository = userRepository;
this.fileUploadService = fileUploadService;
}

@Override
@Transactional
public UpdateProfilePicResult update(Long userId, byte[] imageFileContent, LocalDateTime now) {
FileResult fileResult = fileUploadService.uploadFile(imageFileContent, userId, now);
public UpdateProfilePicResult update(Long userId, MultipartFile file, LocalDateTime now) {
FileResult fileResult = fileUploadService.uploadFile(file, userId, now);
User user =
userQueryRepository
.findUserById(userId)
Expand Down
7 changes: 7 additions & 0 deletions perfume-api/src/main/resources/application-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,10 @@ flyway:
validate-on-migrate: true
baseline-on-migrate: true
baseline-version: 1

aws:
s3:
access-key: ${ACCESS_KEY}
private-key: ${PRIVATE_KEY}
bucket: ${BUCKET_NAME}
cloudFrontPath: ${CLOUDFRONT_PATH}
Loading

0 comments on commit 1aed5dd

Please sign in to comment.