Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion src/main/java/com/backend/server/ServerApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.scheduling.annotation.EnableScheduling;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,20 @@ public ApiResponse<String> getPresignedUrl(
String url = presignedUrlService.generatePresignedUrl(fileName);
return ApiResponse.success("presigned url 발급 성공", url);
}

@Operation(
summary = "s3에 파일 업로드용 presigned url 발급 API",
description = "presigned url 을 발급합니다. presigned url 은 5분만 유효합니다.<br/><br/>"
+ "아래는 curl 명령어로 파일을 업로드하는 예시입니다.<br/>"
+ "curl -X PUT \"발급받은 url\" --upload-file 파일 위치<br/><br/>"
+ "아래는 js fetch 명령어로 파일을 업로드하는 예시입니다.<br/>"
+ "fetch(발급받은 url, { method: 'PUT', body: 파일 객체 });<br/>"
+ "(파일 객체는 &lt;input type=\"file\"&gt; 등으로 얻은 File 타입 객체입니다.)"
)
@GetMapping("/presigned-url-v2")
public ApiResponse<String> getPresignedUrlV2(
@RequestParam String fileName) {
String url = presignedUrlService.generatePresignedUrl(fileName);
return ApiResponse.success("presigned url 발급 성공", url);
}
}
Original file line number Diff line number Diff line change
@@ -1,56 +1,24 @@
package com.backend.server.api.common.s3.service;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import com.backend.server.config.S3Config.S3Properties;
import java.net.URL;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.net.URL;
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.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;

@Service
@RequiredArgsConstructor
public class CommonPresignedUrlService {

@Value("${cloud.aws.s3.bucket}")
private String bucketName;

@Value("${cloud.aws.credentials.access-key}")
private String accessKey;

@Value("${cloud.aws.credentials.secret-key}")
private String secretKey;

@Value("${cloud.aws.region.static}")
private String region;

private S3Presigner s3Presigner;

@PostConstruct
public void init() {
AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey);

this.s3Presigner = S3Presigner.builder()
.region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider.create(credentials))
.build();
}

@PreDestroy
public void shutdown() {
if (s3Presigner != null) {
s3Presigner.close();
}
}
private final S3Presigner s3Presigner;
private final S3Properties s3Properties;

public String generatePresignedUrl(String fileName) {
PutObjectRequest objectRequest = PutObjectRequest.builder()
.bucket(bucketName)
.bucket(s3Properties.getBucket())
.key(fileName)
.build();

Expand Down
57 changes: 57 additions & 0 deletions src/main/java/com/backend/server/config/S3Config.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.backend.server.config;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
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;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;

@Configuration
@RequiredArgsConstructor
public class S3Config {

@Value("${cloud.aws.credentials.access-key:asdf}")
private String accessKey;

@Value("${cloud.aws.credentials.secret-key:asdf}")
private String secretKey;

private final S3Properties s3Properties;

@Bean
public AwsBasicCredentials credentials() {
return AwsBasicCredentials.create(accessKey, secretKey);
}

@Bean(destroyMethod = "close")
public S3Presigner getS3Presigner(AwsBasicCredentials credentials) {
return S3Presigner.builder()
.region(Region.of(s3Properties.region))
.credentialsProvider(StaticCredentialsProvider.create(credentials))
.build();
}

@Bean
public S3Client s3Client(AwsBasicCredentials credentials) {
return S3Client.builder()
.region(Region.of(s3Properties.region))
.credentialsProvider(StaticCredentialsProvider.create(credentials))
.build();
}

@Getter
@Component
public static class S3Properties {
@Value("${cloud.aws.s3.bucket:asdf}")
private String bucket;

@Value("${cloud.aws.region.static:asdf}")
private String region;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.backend.server.api.common.s3.controller;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.backend.server.config.ControllerTest;
import com.backend.server.util.S3ApiUtil;
import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.annotation.IfProfileValue;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;

@ControllerTest
@DisplayName("CommonPresignedUrlController")
class CommonPresignedUrlControllerTest {

@Autowired S3ApiUtil s3ApiUtil;
@Autowired MockMvc mockMvc;

@Nested
class Presigned_URL_API_는 {

@Test
@IfProfileValue(name = "spring.profiles.active", value = "integration-test")
void 파일_업로드가_가능한_URL을_응답한다() throws Exception {
//given
final String fileName = "asdf.txt";
final String fileContent = "Hello World!";

//when
ResultActions result = mockMvc.perform(get("/api/s3/presigned-url")
.param("fileName", fileName));

//then
result.andExpect(status().isOk());

String responseJson = result.andReturn().getResponse().getContentAsString();
String presignedUrl = JsonPath.parse(responseJson).read("$.data", String.class);

s3ApiUtil.upload(presignedUrl, fileContent);

try {
assertThat(s3ApiUtil.get(fileName))
.as("업로드한 파일 내용과 업로드된 파일 내용이 일치하는지 확인합니다.")
.isEqualTo(fileContent);
} finally {
s3ApiUtil.delete(fileName);
}
}
}
}
68 changes: 68 additions & 0 deletions src/test/java/com/backend/server/config/RestTemplateConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.backend.server.config;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.stream.Collectors;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.BufferingClientHttpRequestFactory;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;

@Configuration
public class RestTemplateConfig {

@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder
.connectTimeout(Duration.ofSeconds(5))
.readTimeout(Duration.ofSeconds(5))
.requestFactory(() ->
// LoggingInterceptor 에서 응답 객체를 사용하기 떄문에, 응답 객체를 재사용 가능하게 하는 설정입니다.
new BufferingClientHttpRequestFactory(new SimpleClientHttpRequestFactory()))
.additionalInterceptors(new LoggingInterceptor())
.build();
}

@Slf4j
static class LoggingInterceptor implements ClientHttpRequestInterceptor {

@Override
public @NonNull ClientHttpResponse intercept(
@NonNull HttpRequest request,
byte @NonNull [] body,
ClientHttpRequestExecution execution
) throws IOException {
logRequest(request, body);
ClientHttpResponse response = execution.execute(request, body);
logResponse(response);
return response;
}

private void logRequest(HttpRequest request, byte[] body) {
log.info("Request: {} {}", request.getMethod(), request.getURI());
log.info("Headers: {}", request.getHeaders());
log.info("Body: {}", new String(body, StandardCharsets.UTF_8));
}

private void logResponse(ClientHttpResponse response) throws IOException {
String responseBody = new BufferedReader(new InputStreamReader(response.getBody()))
.lines()
.collect(Collectors.joining("\n"));

log.info("Status code: {}", response.getStatusCode());
log.info("Headers: {}", response.getHeaders());
log.info("Body: {}", responseBody);
}
}
}
8 changes: 1 addition & 7 deletions src/test/java/com/backend/server/util/MockMvcUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,11 @@
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.jayway.jsonpath.JsonPath;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.test.web.servlet.ResultMatcher;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

@Component
public class MockMvcUtil {

private static final ObjectMapper mapper = new ObjectMapper()
Expand Down Expand Up @@ -54,9 +52,7 @@ public static MultiValueMap<String, String> convertToParams(Object dto) {
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();

JsonNode node = mapper.valueToTree(dto);
node.fields().forEachRemaining(entry -> {
String key = entry.getKey();
JsonNode value = entry.getValue();
node.forEachEntry((key, value) -> {
if (!value.isNull()) {
if (value.isArray()) {
for (JsonNode item : value)
Expand Down Expand Up @@ -90,6 +86,4 @@ public static MockHttpServletRequestBuilder deleteJson(String url, Object body)
.contentType(MediaType.APPLICATION_JSON)
.content(convertToJson(body));
}


}
63 changes: 63 additions & 0 deletions src/test/java/com/backend/server/util/S3ApiUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.backend.server.util;

import static org.assertj.core.api.Assertions.assertThat;

import com.backend.server.config.S3Config.S3Properties;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;

@Component
public class S3ApiUtil {

@Autowired RestTemplate restTemplate;
@Autowired S3Client s3Client;
@Autowired S3Properties s3Properties;

public void upload(String presignedUrl, String content) {
HttpHeaders headers = new HttpHeaders();
headers.setContentLength(content.getBytes(StandardCharsets.UTF_8).length);

ResponseEntity<String> response = restTemplate.exchange(
URI.create(presignedUrl),
HttpMethod.PUT,
new HttpEntity<>(content, headers),
String.class
);

assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}

public String get(String fileKey) {
final String url = String.format("https://%s.s3.%s.amazonaws.com/%s",
s3Properties.getBucket(), s3Properties.getRegion(), fileKey);

ResponseEntity<String> response = restTemplate.exchange(
url,
HttpMethod.GET,
new HttpEntity<>(new HttpHeaders()),
String.class
);

assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);

return response.getBody();
}

public void delete(String fileKey) {
DeleteObjectRequest request = DeleteObjectRequest.builder()
.bucket(s3Properties.getBucket())
.key(fileKey)
.build();
s3Client.deleteObject(request);
}
}