From 8676f3e54681e0c108d78ea860de770255dfb21b Mon Sep 17 00:00:00 2001 From: Bokyeom <79684339+k-kbk@users.noreply.github.com> Date: Tue, 6 Feb 2024 23:17:45 +0900 Subject: [PATCH] =?UTF-8?q?[feature]=20s3=20presignedUrl=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yml | 6 ++ .gitignore | 2 + Dockerfile | 6 ++ build.gradle.kts | 6 +- gradle.properties | 6 +- .../mjucow/eatda/common/config/S3Config.kt | 29 +++++++++ .../eatda/domain/s3/dto/PresignedUrlDto.kt | 14 +++++ .../eatda/domain/s3/service/S3Service.kt | 59 +++++++++++++++++++ .../presentation/s3/S3ApiPresentation.kt | 16 +++++ .../eatda/presentation/s3/S3Controller.kt | 33 +++++++++++ src/main/resources/application.yml | 7 +++ src/test/resources/application.yml | 38 ++++++------ src/test/resources/logback-test.xml | 18 +++--- 13 files changed, 208 insertions(+), 32 deletions(-) create mode 100644 src/main/kotlin/com/mjucow/eatda/common/config/S3Config.kt create mode 100644 src/main/kotlin/com/mjucow/eatda/domain/s3/dto/PresignedUrlDto.kt create mode 100644 src/main/kotlin/com/mjucow/eatda/domain/s3/service/S3Service.kt create mode 100644 src/main/kotlin/com/mjucow/eatda/presentation/s3/S3ApiPresentation.kt create mode 100644 src/main/kotlin/com/mjucow/eatda/presentation/s3/S3Controller.kt diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b489492..d474b53 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,10 +30,16 @@ jobs: # Gradle test를 실행 - name: Test with Gradle run: ./gradlew clean testCoverage --no-daemon + env: + S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} + S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} + S3_BUCKET: ${{ secrets.S3_TEST_BUCKET }} # Report upload - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v3 + with: + flags: '!**/s3/*' env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} file: ./build/reports/jacoco/test/jacocoTestReport.xml diff --git a/.gitignore b/.gitignore index c8089ad..28d3a3d 100644 --- a/.gitignore +++ b/.gitignore @@ -175,3 +175,5 @@ gradle-app.setting *.hprof redis/ + +.run diff --git a/Dockerfile b/Dockerfile index e5bf806..ceec6d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,9 @@ ARG PROFILE=prod ARG DB_URL ARG DB_USERNAME ARG DB_PASSWORD +ARG S3_ACCESS_KEY +ARG S3_SECRET_KEY +ARG S3_BUCKET COPY ${JAR_FILE} app.jar @@ -15,5 +18,8 @@ ENV PROFILE=${PROFILE} ENV DB_URL=${DB_URL} ENV DB_USERNAME=${DB_USERNAME} ENV DB_PASSWORD=${DB_PASSWORD} +ENV S3_ACCESS_KEY=${S3_ACCESS_KEY} +ENV S3_SECRET_KEY=${S3_SECRET_KEY} +ENV S3_BUCKET=${S3_BUCKET} ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=${PROFILE}", "-Djava.security.egd=file:/dev/./urandom", "/app.jar"] diff --git a/build.gradle.kts b/build.gradle.kts index e51cca3..2d9b395 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -63,6 +63,9 @@ dependencies { jooqGenerator("org.jooq:jooq-meta-extensions-liquibase") jooqGenerator("org.liquibase:liquibase-core") + // aws + implementation("software.amazon.awssdk:s3:2.22.12") + // test testImplementation("org.testcontainers:postgresql") testImplementation("org.testcontainers:testcontainers:$testContainerVersion") @@ -194,7 +197,8 @@ tasks.jacocoTestCoverageVerification { "*.dto.*", "com.mjucow.eatda.jooq.*", "*.Companion", - "*.popularstore.*" // FIXME: redis 이슈 해결 후 제거'[ + "*.s3.*", + "*.popularstore.*" // FIXME: redis 이슈 해결 후 제거 ) } } diff --git a/gradle.properties b/gradle.properties index 232fd22..9ca4c36 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,11 +4,11 @@ applicationVersion=0.1.8-RELEASE ### Project configs ### projectGroup="com.mjucow" -### Project depdency versions ### +### Project dependency versions ### kotlinVersion=1.9.10 javaVersion=17 -### Plugin depdency versions ### +### Plugin dependency versions ### asciidoctorConvertVersion=3.3.2 ktlintVersion=11.6.0 jacocoVersion=0.8.9 @@ -17,7 +17,7 @@ jacocoVersion=0.8.9 springBootVersion=3.1.6 springDependencyManagementVersion=1.1.3 -### DB depedency versions ### +### DB dependency versions ### jooqPluginVersion=8.2 jooqVersion="3.18.4" diff --git a/src/main/kotlin/com/mjucow/eatda/common/config/S3Config.kt b/src/main/kotlin/com/mjucow/eatda/common/config/S3Config.kt new file mode 100644 index 0000000..9273db2 --- /dev/null +++ b/src/main/kotlin/com/mjucow/eatda/common/config/S3Config.kt @@ -0,0 +1,29 @@ +package com.mjucow.eatda.common.config + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.s3.presigner.S3Presigner + +@Configuration +@Profile("prod") +class S3Config( + @Value("\${aws.s3.credentials.access-key}") + private val accessKey: String, + @Value("\${aws.s3.credentials.secret-key}") + private val secretKey: String, +) { + + @Bean + fun s3Presigner(): S3Presigner { + val credential = AwsBasicCredentials.create(accessKey, secretKey) + + return S3Presigner.builder() + .region(Region.AP_NORTHEAST_2) + .credentialsProvider { credential } + .build() + } +} diff --git a/src/main/kotlin/com/mjucow/eatda/domain/s3/dto/PresignedUrlDto.kt b/src/main/kotlin/com/mjucow/eatda/domain/s3/dto/PresignedUrlDto.kt new file mode 100644 index 0000000..867c897 --- /dev/null +++ b/src/main/kotlin/com/mjucow/eatda/domain/s3/dto/PresignedUrlDto.kt @@ -0,0 +1,14 @@ +package com.mjucow.eatda.domain.s3.dto + +import java.net.URL + +data class PresignedUrlDto( + val url: URL, +) { + + companion object { + fun from(url: URL): PresignedUrlDto { + return PresignedUrlDto(url) + } + } +} diff --git a/src/main/kotlin/com/mjucow/eatda/domain/s3/service/S3Service.kt b/src/main/kotlin/com/mjucow/eatda/domain/s3/service/S3Service.kt new file mode 100644 index 0000000..9829835 --- /dev/null +++ b/src/main/kotlin/com/mjucow/eatda/domain/s3/service/S3Service.kt @@ -0,0 +1,59 @@ +package com.mjucow.eatda.domain.s3.service + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Profile +import org.springframework.stereotype.Service +import software.amazon.awssdk.services.s3.model.GetObjectRequest +import software.amazon.awssdk.services.s3.model.PutObjectRequest +import software.amazon.awssdk.services.s3.presigner.S3Presigner +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest +import java.net.URL +import java.time.Duration + +@Service +@Profile("prod") +class S3Service( + private val s3Presigner: S3Presigner, + @Value("\${aws.s3.bucket}") + private val bucket: String, +) { + + fun createPutPresignedUrl(key: String, contentType: String): URL { + val putObjectRequest = PutObjectRequest + .builder() + .bucket(bucket) + .key(key) + .contentType(contentType) + .build() + + val presignRequest = PutObjectPresignRequest + .builder() + .signatureDuration(Duration.ofMinutes(UPLOAD_DURATION_MINUTES)) + .putObjectRequest(putObjectRequest) + .build() + + return s3Presigner.presignPutObject(presignRequest).url() + } + + fun createGetPresignedUrl(key: String): URL { + val getObjectRequest = GetObjectRequest + .builder() + .bucket(bucket) + .key(key) + .build() + + val presignRequest = GetObjectPresignRequest + .builder() + .signatureDuration(Duration.ofHours(DOWNLOAD_DURATION_HOURS)) + .getObjectRequest(getObjectRequest) + .build() + + return s3Presigner.presignGetObject(presignRequest).url() + } + + companion object { + const val UPLOAD_DURATION_MINUTES = 3L + const val DOWNLOAD_DURATION_HOURS = 24L + } +} diff --git a/src/main/kotlin/com/mjucow/eatda/presentation/s3/S3ApiPresentation.kt b/src/main/kotlin/com/mjucow/eatda/presentation/s3/S3ApiPresentation.kt new file mode 100644 index 0000000..f38cee1 --- /dev/null +++ b/src/main/kotlin/com/mjucow/eatda/presentation/s3/S3ApiPresentation.kt @@ -0,0 +1,16 @@ +package com.mjucow.eatda.presentation.s3 + +import com.mjucow.eatda.domain.s3.dto.PresignedUrlDto +import com.mjucow.eatda.presentation.common.ApiResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag + +@Tag(name = "S3 API", description = "S3 관련 API") +interface S3ApiPresentation { + + @Operation( + summary = "이미지 업로드용 presigned URL 발급", + description = "*key: 버킷의 폴더 경로" + ) + fun getPutPresignedUrl(key: String, contentType: String): ApiResponse +} diff --git a/src/main/kotlin/com/mjucow/eatda/presentation/s3/S3Controller.kt b/src/main/kotlin/com/mjucow/eatda/presentation/s3/S3Controller.kt new file mode 100644 index 0000000..3f2172d --- /dev/null +++ b/src/main/kotlin/com/mjucow/eatda/presentation/s3/S3Controller.kt @@ -0,0 +1,33 @@ +package com.mjucow.eatda.presentation.s3 + +import com.mjucow.eatda.domain.s3.dto.PresignedUrlDto +import com.mjucow.eatda.domain.s3.service.S3Service +import com.mjucow.eatda.presentation.common.ApiResponse +import org.springframework.context.annotation.Profile +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RequestMapping("/api/v1/s3") +@RestController +@Profile("prod") +class S3Controller( + private val s3Service: S3Service, +) : S3ApiPresentation { + + @GetMapping("/presigned-url") + override fun getPutPresignedUrl( + @RequestParam key: String, + @RequestParam contentType: String, + ): ApiResponse { + return ApiResponse.success( + PresignedUrlDto( + s3Service.createPutPresignedUrl( + key = key, + contentType = contentType + ) + ) + ) + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 53076c2..1b116f7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -100,3 +100,10 @@ spring: jooq: sql-dialect: postgres + +aws: + s3: + credentials: + access-key: ${S3_ACCESS_KEY} + secret-key: ${S3_SECRET_KEY} + bucket: ${S3_BUCKET} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 387c7c0..bd53901 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -1,24 +1,24 @@ spring: - jpa: - hibernate: - ddl-auto: validate - properties: - hibernate: - format_sql: true - show_sql: true - database-platform: org.hibernate.dialect.PostgreSQLDialect - database: postgresql - liquibase: - change-log: classpath:/db/changelog-master.yml - enabled: true + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + format_sql: true + show_sql: true + database-platform: org.hibernate.dialect.PostgreSQLDialect + database: postgresql + liquibase: + change-log: classpath:/db/changelog-master.yml + enabled: true - datasource: - driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver - url: jdbc:tc:postgresql:15.4-alpine://test-database + datasource: + driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver + url: jdbc:tc:postgresql:15.4-alpine://test-database - data: - redis: - host: 127.0.0.1 - port: 6379 + data: + redis: + host: 127.0.0.1 + port: 6379 logging.config: classpath:logback-test.xml diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml index e7983ea..e48abb6 100644 --- a/src/test/resources/logback-test.xml +++ b/src/test/resources/logback-test.xml @@ -1,20 +1,20 @@ - + + + + + + ch.qos.logback.classic.encoder.PatternLayoutEncoder by default --> %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + - - - - - - -