diff --git a/core/network/src/main/java/com/twix/network/UploadHttpClientProvider.kt b/core/network/src/main/java/com/twix/network/UploadHttpClientProvider.kt new file mode 100644 index 00000000..2c518bf1 --- /dev/null +++ b/core/network/src/main/java/com/twix/network/UploadHttpClientProvider.kt @@ -0,0 +1,16 @@ +package com.twix.network + +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.HttpTimeout + +object UploadHttpClientProvider { + fun create(): HttpClient = + HttpClient(OkHttp) { + install(HttpTimeout) { + requestTimeoutMillis = 120_000 + connectTimeoutMillis = 30_000 + socketTimeoutMillis = 120_000 + } + } +} diff --git a/core/network/src/main/java/com/twix/network/di/ApiServiceModule.kt b/core/network/src/main/java/com/twix/network/di/ApiServiceModule.kt index 254a0387..fad38938 100644 --- a/core/network/src/main/java/com/twix/network/di/ApiServiceModule.kt +++ b/core/network/src/main/java/com/twix/network/di/ApiServiceModule.kt @@ -3,9 +3,11 @@ package com.twix.network.di import com.twix.network.service.AuthService import com.twix.network.service.GoalService import com.twix.network.service.OnboardingService +import com.twix.network.service.PhotoLogService import com.twix.network.service.createAuthService import com.twix.network.service.createGoalService import com.twix.network.service.createOnboardingService +import com.twix.network.service.createPhotoLogService import de.jensklingenberg.ktorfit.Ktorfit import org.koin.dsl.module @@ -20,4 +22,7 @@ internal val apiServiceModule = single { get().createGoalService() } + single { + get().createPhotoLogService() + } } diff --git a/core/network/src/main/java/com/twix/network/di/HttpClientModule.kt b/core/network/src/main/java/com/twix/network/di/HttpClientModule.kt index ee8e7417..92463da4 100644 --- a/core/network/src/main/java/com/twix/network/di/HttpClientModule.kt +++ b/core/network/src/main/java/com/twix/network/di/HttpClientModule.kt @@ -2,8 +2,11 @@ package com.twix.network.di import com.twix.network.BuildConfig import com.twix.network.HttpClientProvider +import com.twix.network.UploadHttpClientProvider +import com.twix.network.upload.PresignedUploader import de.jensklingenberg.ktorfit.Ktorfit import io.ktor.client.HttpClient +import org.koin.core.qualifier.named import org.koin.dsl.module internal val httpClientModule = @@ -25,4 +28,8 @@ internal val httpClientModule = .httpClient(get()) .build() } + + single(named("uploadClient")) { UploadHttpClientProvider.create() } + + single { PresignedUploader(get(named("uploadClient"))) } } diff --git a/core/network/src/main/java/com/twix/network/execute/UploadCall.kt b/core/network/src/main/java/com/twix/network/execute/UploadCall.kt new file mode 100644 index 00000000..345b9c19 --- /dev/null +++ b/core/network/src/main/java/com/twix/network/execute/UploadCall.kt @@ -0,0 +1,37 @@ +package com.twix.network.execute + +import com.twix.result.AppError +import com.twix.result.AppResult +import io.ktor.client.plugins.ResponseException +import io.ktor.client.statement.bodyAsText +import kotlinx.io.IOException +import kotlinx.serialization.SerializationException +import java.net.SocketTimeoutException +import kotlin.coroutines.cancellation.CancellationException + +suspend inline fun safeUploadCall(crossinline call: suspend () -> T): AppResult = + try { + AppResult.Success(call()) + } catch (e: CancellationException) { + throw e + } catch (e: ResponseException) { + val status = e.response.status.value + val raw = runCatching { e.response.bodyAsText() }.getOrNull() + + AppResult.Error( + AppError.Http( + status = status, + code = null, + message = null, + rawBody = raw, + ), + ) + } catch (e: SocketTimeoutException) { + AppResult.Error(AppError.Timeout(e)) + } catch (e: IOException) { + AppResult.Error(AppError.Network(e)) + } catch (e: SerializationException) { + AppResult.Error(AppError.Serialization(e)) + } catch (e: Throwable) { + AppResult.Error(AppError.Unknown(e)) + } diff --git a/core/network/src/main/java/com/twix/network/model/response/photo/mapper/PhotoMapper.kt b/core/network/src/main/java/com/twix/network/model/response/photo/mapper/PhotoMapper.kt new file mode 100644 index 00000000..88d1d6d9 --- /dev/null +++ b/core/network/src/main/java/com/twix/network/model/response/photo/mapper/PhotoMapper.kt @@ -0,0 +1,10 @@ +package com.twix.network.model.response.photo.mapper + +import com.twix.domain.model.photo.PhotoLogUploadInfo +import com.twix.network.model.response.photo.model.PhotoLogUploadUrlResponse + +fun PhotoLogUploadUrlResponse.toDomain(): PhotoLogUploadInfo = + PhotoLogUploadInfo( + uploadUrl = this.uploadUrl, + fileName = this.fileName, + ) diff --git a/core/network/src/main/java/com/twix/network/model/response/photo/model/PhotoLogUploadUrlResponse.kt b/core/network/src/main/java/com/twix/network/model/response/photo/model/PhotoLogUploadUrlResponse.kt new file mode 100644 index 00000000..6c3763a7 --- /dev/null +++ b/core/network/src/main/java/com/twix/network/model/response/photo/model/PhotoLogUploadUrlResponse.kt @@ -0,0 +1,10 @@ +package com.twix.network.model.response.photo.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PhotoLogUploadUrlResponse( + @SerialName("uploadUrl") val uploadUrl: String, + @SerialName("fileName") val fileName: String, +) diff --git a/core/network/src/main/java/com/twix/network/service/PhotoLogService.kt b/core/network/src/main/java/com/twix/network/service/PhotoLogService.kt new file mode 100644 index 00000000..fbb2a5e3 --- /dev/null +++ b/core/network/src/main/java/com/twix/network/service/PhotoLogService.kt @@ -0,0 +1,12 @@ +package com.twix.network.service + +import com.twix.network.model.response.photo.model.PhotoLogUploadUrlResponse +import de.jensklingenberg.ktorfit.http.GET +import de.jensklingenberg.ktorfit.http.Query + +interface PhotoLogService { + @GET("api/v1/photologs/upload-url") + suspend fun getUploadUrl( + @Query("goalId") goalId: Long, + ): PhotoLogUploadUrlResponse +} diff --git a/core/network/src/main/java/com/twix/network/upload/PresignedUploader.kt b/core/network/src/main/java/com/twix/network/upload/PresignedUploader.kt new file mode 100644 index 00000000..42d4690f --- /dev/null +++ b/core/network/src/main/java/com/twix/network/upload/PresignedUploader.kt @@ -0,0 +1,31 @@ +package com.twix.network.upload + +import com.twix.network.execute.safeUploadCall +import com.twix.result.AppResult +import io.ktor.client.HttpClient +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess + +class PresignedUploader( + private val client: HttpClient, +) { + suspend fun upload( + uploadUrl: String, + bytes: ByteArray, + contentType: String = "image/jpeg", + ): AppResult = + safeUploadCall { + val response = + client.put(uploadUrl) { + contentType(ContentType.parse(contentType)) + setBody(bytes) + } + + if (!response.status.isSuccess()) { + throw IllegalStateException("S3 upload failed: ${response.status.value}") + } + } +} diff --git a/data/src/main/java/com/twix/data/di/RepositoryModule.kt b/data/src/main/java/com/twix/data/di/RepositoryModule.kt index 6fb9e27c..feda5909 100644 --- a/data/src/main/java/com/twix/data/di/RepositoryModule.kt +++ b/data/src/main/java/com/twix/data/di/RepositoryModule.kt @@ -3,9 +3,11 @@ package com.twix.data.di import com.twix.data.repository.DefaultAuthRepository import com.twix.data.repository.DefaultGoalRepository import com.twix.data.repository.DefaultOnboardingRepository +import com.twix.data.repository.DefaultPhotoLogRepository import com.twix.domain.repository.AuthRepository import com.twix.domain.repository.GoalRepository import com.twix.domain.repository.OnBoardingRepository +import com.twix.domain.repository.PhotoLogRepository import org.koin.dsl.module internal val repositoryModule = @@ -19,4 +21,7 @@ internal val repositoryModule = single { DefaultAuthRepository(get(), get()) } + single { + DefaultPhotoLogRepository(get(), get()) + } } diff --git a/data/src/main/java/com/twix/data/repository/DefaultPhotoLogRepository.kt b/data/src/main/java/com/twix/data/repository/DefaultPhotoLogRepository.kt new file mode 100644 index 00000000..faafae5f --- /dev/null +++ b/data/src/main/java/com/twix/data/repository/DefaultPhotoLogRepository.kt @@ -0,0 +1,42 @@ +package com.twix.data.repository + +import com.twix.domain.model.photo.PhotoLogUploadInfo +import com.twix.domain.repository.PhotoLogRepository +import com.twix.network.execute.safeApiCall +import com.twix.network.model.response.photo.mapper.toDomain +import com.twix.network.service.PhotoLogService +import com.twix.network.upload.PresignedUploader +import com.twix.result.AppResult + +class DefaultPhotoLogRepository( + private val service: PhotoLogService, + private val uploader: PresignedUploader, +) : PhotoLogRepository { + override suspend fun getUploadUrl(goalId: Long): AppResult = safeApiCall { service.getUploadUrl(goalId).toDomain() } + + override suspend fun uploadPhotoLogImage( + goalId: Long, + bytes: ByteArray, + contentType: String, + ): AppResult { + // 서버에서 presigned url 발급 + val infoResult = getUploadUrl(goalId) + val info = + when (infoResult) { + is AppResult.Success -> infoResult.data + is AppResult.Error -> return infoResult + } + + // S3로 직접 업로드 + val uploadResult = + uploader.upload( + uploadUrl = info.uploadUrl, + bytes = bytes, + contentType = contentType, + ) + if (uploadResult is AppResult.Error) return uploadResult + + // fileName을 key로 사용함. 인증샷 등록 API에서 사용 + return AppResult.Success(info.fileName) + } +} diff --git a/domain/src/main/java/com/twix/domain/model/photo/PhotoLogUploadInfo.kt b/domain/src/main/java/com/twix/domain/model/photo/PhotoLogUploadInfo.kt new file mode 100644 index 00000000..f0b90251 --- /dev/null +++ b/domain/src/main/java/com/twix/domain/model/photo/PhotoLogUploadInfo.kt @@ -0,0 +1,6 @@ +package com.twix.domain.model.photo + +data class PhotoLogUploadInfo( + val uploadUrl: String = "", + val fileName: String = "", +) diff --git a/domain/src/main/java/com/twix/domain/repository/PhotoLogRepository.kt b/domain/src/main/java/com/twix/domain/repository/PhotoLogRepository.kt new file mode 100644 index 00000000..fc598301 --- /dev/null +++ b/domain/src/main/java/com/twix/domain/repository/PhotoLogRepository.kt @@ -0,0 +1,14 @@ +package com.twix.domain.repository + +import com.twix.domain.model.photo.PhotoLogUploadInfo +import com.twix.result.AppResult + +interface PhotoLogRepository { + suspend fun getUploadUrl(goalId: Long): AppResult + + suspend fun uploadPhotoLogImage( + goalId: Long, + bytes: ByteArray, + contentType: String, + ): AppResult +}