Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ [STMT-187] 복수의 presigned url 발급 API 구현 #146

Merged
merged 9 commits into from
Jul 30, 2024
Merged
18 changes: 9 additions & 9 deletions src/docs/asciidoc/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -425,24 +425,24 @@ include::{snippets}/study-member-join/fail/not-exist-member/response-fields.adoc

===== 요청

include::{snippets}/presigned-url-generate/success/http-request.adoc[]
include::{snippets}/presigned-url-generate/success/request-headers.adoc[]
include::{snippets}/presigned-url-generate/success/request-fields.adoc[]
include::{snippets}/presigned-urls-generate/success/http-request.adoc[]
include::{snippets}/presigned-urls-generate/success/request-headers.adoc[]
include::{snippets}/presigned-urls-generate/success/request-fields.adoc[]

include::file-management-path.adoc[]

===== 응답 성공 (200)
include::{snippets}/presigned-url-generate/success/response-body.adoc[]
include::{snippets}/presigned-url-generate/success/response-fields.adoc[]
include::{snippets}/presigned-urls-generate/success/response-body.adoc[]
include::{snippets}/presigned-urls-generate/success/response-fields.adoc[]

===== 응답 실패 (400)
.파일이름이 유효하지 않은 경우
include::{snippets}/presigned-url-generate/fail/invalid-file-name/response-body.adoc[]
include::{snippets}/presigned-url-generate/fail/invalid-file-name/response-fields.adoc[]
include::{snippets}/presigned-urls-generate/fail/invalid-file-name/response-body.adoc[]
include::{snippets}/presigned-urls-generate/fail/invalid-file-name/response-fields.adoc[]

.파일의 확장자가 유효하지 않은 경우
include::{snippets}/presigned-url-generate/fail/invalid-file-extension/response-body.adoc[]
include::{snippets}/presigned-url-generate/fail/invalid-file-extension/response-fields.adoc[]
include::{snippets}/presigned-urls-generate/fail/invalid-file-extension/response-body.adoc[]
include::{snippets}/presigned-urls-generate/fail/invalid-file-extension/response-fields.adoc[]

== 스터디 활동 관리

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.stumeet.server.common.exception.handler;

import java.util.List;
import java.util.concurrent.CompletionException;
import java.util.stream.Collectors;

import com.fasterxml.jackson.databind.exc.InvalidFormatException;
Expand All @@ -9,6 +10,7 @@
import com.stumeet.server.common.exception.model.SecurityViolationException;
import com.stumeet.server.common.response.ErrorCode;
import com.stumeet.server.common.model.ApiResponse;
import com.stumeet.server.file.domain.exception.InvalidFileException;

import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
Expand Down Expand Up @@ -60,6 +62,26 @@ protected ResponseEntity<ApiResponse> handleBusinessException(final BusinessExce
.body(response);
}

@ExceptionHandler(CompletionException.class)
protected ResponseEntity<ApiResponse> handleCompletionException(final CompletionException e) {
log.error(ERROR_LOG_MESSAGE, e.getClass().getSimpleName(), e.getMessage());
log.debug(e.getMessage(), e);
e.printStackTrace();

ApiResponse response = createApiResponse(e);

return ResponseEntity.status(response.code())
.body(response);
}

private ApiResponse createApiResponse(final CompletionException e) {
if (e.getCause() instanceof InvalidFileException invalidFileException) {
return ApiResponse.fail(invalidFileException.getErrorCode());
} else {
return ApiResponse.fail(ErrorCode.ASYNC_ERROR);
}
}

@ExceptionHandler(Exception.class)
protected ResponseEntity<ApiResponse> handleException(final Exception e) {
log.error(ERROR_LOG_MESSAGE, e.getClass().getSimpleName(), e.getMessage());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;

public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ public enum ErrorCode {
FILE_IO_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "파일 입출력에 실패하였습니다."),
UPLOAD_FILE_FAIL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패하였습니다."),
NOT_IMPLEMENTED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "구현되지 않은 메서드를 사용했습니다."),

ASYNC_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "비동기 작업 중 에러가 발생했습니다.")
;

private final HttpStatus httpStatus;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import com.stumeet.server.common.annotation.WebAdapter;
import com.stumeet.server.common.model.ApiResponse;
import com.stumeet.server.common.response.SuccessCode;
import com.stumeet.server.file.adapter.in.response.PresignedUrlResponses;
import com.stumeet.server.file.application.port.in.PresignedUrlGenerateUseCase;
import com.stumeet.server.file.application.port.in.command.PresignedUrlCommand;
import com.stumeet.server.file.application.port.in.response.PresignedUrlResponse;
import com.stumeet.server.file.application.port.in.command.PresignedUrlCommands;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
Expand All @@ -14,17 +16,17 @@
import org.springframework.web.bind.annotation.RequestMapping;

@WebAdapter
@RequestMapping("/api/v1/presigned-url")
@RequestMapping("/api/v1/presigned-urls")
@RequiredArgsConstructor
public class PresignedUrlGenerateWebAdapter {

private final PresignedUrlGenerateUseCase presignedUrlGenerateUseCase;

@PostMapping
public ResponseEntity<ApiResponse<PresignedUrlResponse>> generatePresignedUrl(
@RequestBody PresignedUrlCommand request
public ResponseEntity<ApiResponse<PresignedUrlResponses>> generatePresignedUrls(
@Valid @RequestBody PresignedUrlCommands request
) {
PresignedUrlResponse response = presignedUrlGenerateUseCase.generatePresignedUrl(request);
PresignedUrlResponses response = presignedUrlGenerateUseCase.generatePresignedUrls(request);

return ResponseEntity.status(HttpStatus.OK)
.body(ApiResponse.success(SuccessCode.PRESIGNED_URL_SUCCESS, response));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.stumeet.server.file.application.port.in.response;
package com.stumeet.server.file.adapter.in.response;

import lombok.Builder;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.stumeet.server.file.adapter.in.response;

import java.util.List;

public record PresignedUrlResponses(
List<PresignedUrlResponse> presignedUrls
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@
import com.stumeet.server.file.application.port.out.FileCommandPort;
import com.stumeet.server.file.application.port.out.PresignedUrlGeneratePort;
import com.stumeet.server.file.domain.FileManagementPath;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

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.DeleteObjectsRequest;
Expand Down Expand Up @@ -107,8 +110,8 @@ private List<ObjectIdentifier> getObjectIdentifiers(String prefix) {
public FileUrl generatePresignedUrl(FileManagementPath path, String fileName) {
String key = FileUtil.generateKey(path.getPath(), fileName);

PresignedPutObjectRequest request = s3Presigner.presignPutObject(p ->
p.signatureDuration(Duration.ofSeconds(expiredTime))
PresignedPutObjectRequest request = s3Presigner.presignPutObject(
p -> p.signatureDuration(Duration.ofSeconds(expiredTime))
.putObjectRequest(pr -> pr.bucket(bucket).key(key))
);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
package com.stumeet.server.file.application.port.in;

import com.stumeet.server.file.application.port.in.command.PresignedUrlCommand;
import com.stumeet.server.file.application.port.in.response.PresignedUrlResponse;
import com.stumeet.server.file.adapter.in.response.PresignedUrlResponses;
import com.stumeet.server.file.application.port.in.command.PresignedUrlCommands;

public interface PresignedUrlGenerateUseCase {

PresignedUrlResponse generatePresignedUrl(PresignedUrlCommand request);
PresignedUrlResponses generatePresignedUrls(PresignedUrlCommands commands);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.stumeet.server.file.application.port.in.command;

import java.util.List;

import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

public record PresignedUrlCommands(
@NotNull(message = "Presigned URL을 요청할 이미지 정보를 입력해주세요.")
@Size(min = 1, max = 5, message = "요청 가능한 Presigned URL 개수는 최소 1개, 최대 5개 입니다.")
List<PresignedUrlCommand> requests
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.stumeet.server.file.application.service;

import java.util.concurrent.CompletableFuture;

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import com.stumeet.server.common.util.FileValidator;
import com.stumeet.server.file.adapter.in.response.PresignedUrlResponse;
import com.stumeet.server.file.application.port.dto.FileUrl;
import com.stumeet.server.file.application.port.in.command.PresignedUrlCommand;
import com.stumeet.server.file.application.port.out.PresignedUrlGeneratePort;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class PresignedUrlGenerateAsyncService {

private final PresignedUrlGeneratePort presignedUrlGeneratePort;

@Async
public CompletableFuture<PresignedUrlResponse> generatePresignedUrl(PresignedUrlCommand command) {
FileValidator.validateImageFile(command.fileName());

FileUrl fileUrl = presignedUrlGeneratePort.generatePresignedUrl(command.path(), command.fileName());

return CompletableFuture.completedFuture(
PresignedUrlResponse.builder()
.url(fileUrl.url())
.build());
}
}
Original file line number Diff line number Diff line change
@@ -1,29 +1,40 @@
package com.stumeet.server.file.application.service;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;

import com.stumeet.server.common.annotation.UseCase;
import com.stumeet.server.common.util.FileValidator;
import com.stumeet.server.file.application.port.dto.FileUrl;
import com.stumeet.server.file.adapter.in.response.PresignedUrlResponses;
import com.stumeet.server.file.application.port.in.PresignedUrlGenerateUseCase;
import com.stumeet.server.file.adapter.in.response.PresignedUrlResponse;
import com.stumeet.server.file.application.port.in.command.PresignedUrlCommand;
import com.stumeet.server.file.application.port.in.response.PresignedUrlResponse;
import com.stumeet.server.file.application.port.out.PresignedUrlGeneratePort;
import com.stumeet.server.file.application.port.in.command.PresignedUrlCommands;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@UseCase
@RequiredArgsConstructor
@Slf4j
public class PresignedUrlGenerateService implements PresignedUrlGenerateUseCase {

private final PresignedUrlGeneratePort presignedUrlGeneratePort;

private final PresignedUrlGenerateAsyncService presignedUrlGenerateAsyncService;

@Override
public PresignedUrlResponse generatePresignedUrl(PresignedUrlCommand command) {
FileValidator.validateImageFile(command.fileName());
public PresignedUrlResponses generatePresignedUrls(PresignedUrlCommands commands) {
List<CompletableFuture<PresignedUrlResponse>> futures = new ArrayList<>();

for (PresignedUrlCommand command : commands.requests()) {
CompletableFuture<PresignedUrlResponse> future =
presignedUrlGenerateAsyncService.generatePresignedUrl(command);
futures.add(future);
}

FileUrl fileUrl = presignedUrlGeneratePort.generatePresignedUrl(command.path(), command.fileName());
List<PresignedUrlResponse> responses = futures.stream()
.map(CompletableFuture::join)
.toList();

return PresignedUrlResponse.builder()
.url(fileUrl.url())
.build();
return new PresignedUrlResponses(responses);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.stumeet.server.file.adapter.in;

import com.stumeet.server.common.auth.model.AuthenticationHeader;
import com.stumeet.server.file.application.port.in.command.PresignedUrlCommand;
import com.stumeet.server.file.application.port.in.command.PresignedUrlCommands;
import com.stumeet.server.file.domain.exception.InvalidFileException;
import com.stumeet.server.helper.WithMockMember;
import com.stumeet.server.stub.FileStub;
Expand Down Expand Up @@ -29,46 +29,46 @@ class PresignedUrlGenerateWebAdapterTest extends ApiTest {
@DisplayName("PresignedUrl 생성")
class GeneratePresignedUrl {

private static final String PATH = "/api/v1/presigned-url";
private static final String PATH = "/api/v1/presigned-urls";

@Test
@WithMockMember
@DisplayName("[성공] 해당하는 파일에 대한 PresignedUrl을 생성한다.")
@DisplayName("[성공] 해당하는 파일 목록에 대한 PresignedUrl 목록을 생성한다.")
void successTest() throws Exception {
PresignedUrlCommand request = FileStub.getPresignedUrlCommand();
PresignedUrlCommands request = FileStub.getPresignedUrlCommands();

mockMvc.perform(post(PATH)
.header(AuthenticationHeader.ACCESS_TOKEN.getName(), TokenStub.getMockAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.content(toJson(request)))
.andExpect(status().isOk())
.andDo(document("presigned-url-generate/success",
.andDo(document("presigned-urls-generate/success",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
requestHeaders(
headerWithName(AuthenticationHeader.ACCESS_TOKEN.getName()).description("서버로부터 전달받은 액세스 토큰")
),
requestFields(
fieldWithPath("path").description("해당하는 파일의 도메인"),
fieldWithPath("fileName").description("파일 이름")
fieldWithPath("requests[].path").description("해당하는 파일의 도메인"),
fieldWithPath("requests[].fileName").description("파일 이름")
),
responseFields(
fieldWithPath("code").description("응답 코드"),
fieldWithPath("message").description("응답 메시지"),
fieldWithPath("data.url").description("Presigned url")
fieldWithPath("data.presignedUrls[].url").description("Presigned url")
)
));
}

@ParameterizedTest
@MethodSource("com.stumeet.server.stub.FileStub#getInvalidFileTestArguments")
@MethodSource("com.stumeet.server.stub.FileStub#getInvalidFilesTestArguments")
@WithMockMember
@DisplayName("[실패] 파일 이름이 유효하지 않은 경우 예외가 발생합니다.")
void invalidFileTest(String documentPath, PresignedUrlCommand invalidFileRequest, InvalidFileException e) throws Exception {
void invalidFileTest(String documentPath, PresignedUrlCommands invalidFileRequests, InvalidFileException e) throws Exception {
mockMvc.perform(post(PATH)
.header(AuthenticationHeader.ACCESS_TOKEN.getName(), TokenStub.getMockAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.content(toJson(invalidFileRequest)))
.content(toJson(invalidFileRequests)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(e.getErrorCode().getHttpStatusCode()))
.andExpect(jsonPath("$.message").value(e.getErrorCode().getMessage()))
Expand All @@ -79,8 +79,8 @@ void invalidFileTest(String documentPath, PresignedUrlCommand invalidFileRequest
headerWithName(AuthenticationHeader.ACCESS_TOKEN.getName()).description("서버로부터 전달받은 액세스 토큰")
),
requestFields(
fieldWithPath("path").description("해당하는 파일의 도메인"),
fieldWithPath("fileName").description("파일 이름")
fieldWithPath("requests[].path").description("해당하는 파일의 도메인"),
fieldWithPath("requests[].fileName").description("파일 이름")
),
responseFields(
fieldWithPath("code").description("응답 코드"),
Expand Down
Loading
Loading