-
Notifications
You must be signed in to change notification settings - Fork 0
feat(configuration): 파일 API 공통 응답 규약 및 예외 처리 #26 #58
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
The head ref may contain hidden characters: "feat(document)-\uD30C\uC77C-\uC751\uB2F5-\uADDC\uC57D-#26"
Changes from all commits
787549e
751de7d
d7488f8
a06cb19
d1aefba
7c33444
a73f2a1
239c822
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| package hs.kr.entrydsm.configuration.adapterin.common | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonInclude | ||
| import java.time.Instant | ||
|
|
||
| data class ApiResponse<T>( | ||
| val success: Boolean, | ||
| val data: T? = null, | ||
| val error: ErrorResponse? = null, | ||
| @get:JsonInclude(JsonInclude.Include.NON_NULL) | ||
| val timestamp: Instant? = null, | ||
| ) { | ||
| companion object { | ||
| fun <T> success(data: T) = ApiResponse(success = true, data = data) | ||
|
|
||
| fun failure(errorCode: ErrorCode, message: String? = null) = ApiResponse<Nothing>( | ||
| success = false, | ||
| error = ErrorResponse( | ||
| code = errorCode.name, | ||
| message = message ?: errorCode.message, | ||
| status = errorCode.status.value(), | ||
| ), | ||
| timestamp = Instant.now(), | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| data class ErrorResponse( | ||
| val code: String, | ||
| val message: String, | ||
| val status: Int, | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| package hs.kr.entrydsm.configuration.adapterin.common | ||
|
|
||
| import hs.kr.entrydsm.configuration.adapterin.document.InvalidFileReferenceIdException | ||
| import hs.kr.entrydsm.configuration.domain.document.exception.FileDocumentNotFoundException | ||
| import hs.kr.entrydsm.configuration.domain.document.exception.FileTooLargeException | ||
| import hs.kr.entrydsm.configuration.domain.document.exception.InvalidFileFormatException | ||
| import hs.kr.entrydsm.configuration.domain.document.exception.InvalidFileNameException | ||
| import hs.kr.entrydsm.configuration.domain.document.exception.PresignFailedException | ||
| import hs.kr.entrydsm.configuration.domain.document.exception.StorageUnavailableException | ||
| import hs.kr.entrydsm.configuration.domain.document.exception.StorageUploadFailedException | ||
| import org.slf4j.LoggerFactory | ||
| import org.springframework.http.ResponseEntity | ||
| import org.springframework.http.converter.HttpMessageNotReadableException | ||
| import org.springframework.web.bind.MethodArgumentNotValidException | ||
| import org.springframework.web.bind.MissingServletRequestParameterException | ||
| import org.springframework.web.bind.annotation.ExceptionHandler | ||
| import org.springframework.web.bind.annotation.RestControllerAdvice | ||
| import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException | ||
| import org.springframework.web.multipart.MaxUploadSizeExceededException | ||
|
|
||
| @RestControllerAdvice | ||
| class DocumentExceptionHandler { | ||
|
|
||
| private val log = LoggerFactory.getLogger(javaClass) | ||
|
|
||
| @ExceptionHandler(InvalidFileFormatException::class) | ||
| fun handleInvalidFileFormat(e: InvalidFileFormatException) = | ||
| respond(ErrorCode.INVALID_FILE_FORMAT, e) | ||
|
|
||
| @ExceptionHandler(FileTooLargeException::class, MaxUploadSizeExceededException::class) | ||
| fun handleFileTooLarge(e: Exception) = | ||
| respond(ErrorCode.FILE_TOO_LARGE, e) | ||
|
|
||
| @ExceptionHandler(FileDocumentNotFoundException::class) | ||
| fun handleFileNotFound(e: FileDocumentNotFoundException) = | ||
| respond(ErrorCode.FILE_NOT_FOUND, e) | ||
|
|
||
| @ExceptionHandler( | ||
| InvalidFileNameException::class, | ||
| InvalidFileReferenceIdException::class, | ||
| MissingServletRequestParameterException::class, | ||
| MethodArgumentNotValidException::class, | ||
| MethodArgumentTypeMismatchException::class, | ||
| HttpMessageNotReadableException::class, | ||
| ) | ||
| fun handleInvalidRequestParam(e: Exception) = | ||
| respond(ErrorCode.INVALID_REQUEST_PARAM, e) | ||
|
|
||
| @ExceptionHandler(StorageUploadFailedException::class) | ||
| fun handleStorageUploadFailed(e: StorageUploadFailedException) = | ||
| respond(ErrorCode.STORAGE_UPLOAD_FAILED, e) | ||
|
|
||
| @ExceptionHandler(PresignFailedException::class) | ||
| fun handlePresignFailed(e: PresignFailedException) = | ||
| respond(ErrorCode.PRESIGN_FAILED, e) | ||
|
|
||
| @ExceptionHandler(StorageUnavailableException::class) | ||
| fun handleStorageUnavailable(e: StorageUnavailableException) = | ||
| respond(ErrorCode.STORAGE_UNAVAILABLE, e) | ||
|
|
||
| @ExceptionHandler(Exception::class) | ||
| fun handleUnexpected(e: Exception): ResponseEntity<ApiResponse<Nothing>> { | ||
| log.error("Unhandled exception", e) | ||
| return ResponseEntity | ||
| .status(ErrorCode.INTERNAL_SERVER_ERROR.status) | ||
| .body(ApiResponse.failure(ErrorCode.INTERNAL_SERVER_ERROR)) | ||
| } | ||
|
|
||
| private fun respond(errorCode: ErrorCode, e: Exception): ResponseEntity<ApiResponse<Nothing>> { | ||
| log.warn("{}: {}", errorCode.name, e.message) | ||
| return ResponseEntity.status(errorCode.status).body(ApiResponse.failure(errorCode)) | ||
|
Comment on lines
+26
to
+71
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift 공통 응답 및 예외 변환 계약에 대한 테스트를 추가하세요. PR 목표에 테스트가 아직 완료되지 않았다고 명시되어 있습니다. 이 변경은 HTTP 상태, 오류 코드, JSON 응답 구조를 외부 계약으로 추가합니다. 병합 전에 결정적 테스트로 계약을 고정하세요.
As per coding guidelines, "If production logic is changed in Kotlin or Go files, require corresponding test updates in the same subsystem unless the PR description explicitly justifies why tests are unnecessary" 규칙을 적용했습니다. 📍 Affects 3 files
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package hs.kr.entrydsm.configuration.adapterin.common | ||
|
|
||
| import org.springframework.http.HttpStatus | ||
|
|
||
| enum class ErrorCode(val status: HttpStatus, val message: String) { | ||
| INVALID_REQUEST_PARAM(HttpStatus.BAD_REQUEST, "요청 파라미터가 올바르지 않습니다."), | ||
| INVALID_FILE_FORMAT(HttpStatus.BAD_REQUEST, "지원하지 않는 파일 형식입니다."), | ||
| FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "파일을 찾을 수 없습니다."), | ||
| FILE_TOO_LARGE(HttpStatus.CONTENT_TOO_LARGE, "허용 용량을 초과했습니다."), | ||
| STORAGE_UPLOAD_FAILED(HttpStatus.BAD_GATEWAY, "파일 저장에 실패했습니다."), | ||
| PRESIGN_FAILED(HttpStatus.BAD_GATEWAY, "다운로드 URL 발급에 실패했습니다."), | ||
| STORAGE_UNAVAILABLE(HttpStatus.BAD_GATEWAY, "파일 저장소에 접근할 수 없습니다."), | ||
| INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."), | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| package hs.kr.entrydsm.configuration.adapterin.document | ||
|
|
||
| import hs.kr.entrydsm.configuration.domain.document.FileCategory | ||
|
|
||
| class InvalidFileReferenceIdException(category: FileCategory, value: String) : | ||
| RuntimeException("Invalid ${category.name.lowercase()} id: $value") | ||
|
|
||
| object FileReferenceId { | ||
|
|
||
| fun of(category: FileCategory, id: Long): String = "${prefixOf(category)}$id" | ||
|
|
||
| fun parse(category: FileCategory, value: String): Long { | ||
| val prefix = prefixOf(category) | ||
| return value.takeIf { it.startsWith(prefix) } | ||
| ?.removePrefix(prefix) | ||
| ?.toLongOrNull() | ||
| ?: throw InvalidFileReferenceIdException(category, value) | ||
| } | ||
|
|
||
| private fun prefixOf(category: FileCategory) = "${category.name.lowercase()}_" | ||
| } | ||
|
Comment on lines
+8
to
+21
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift 동일 서브시스템에 대응 테스트를 추가하십시오. 새 Kotlin 프로덕션 로직에 대한 테스트 갱신이 없습니다.
코딩 가이드라인에 따라 Kotlin 프로덕션 로직 변경 시 동일 서브시스템의 테스트 갱신이 필요합니다. 📍 Affects 3 files
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| package hs.kr.entrydsm.configuration.adapterin.document | ||
|
|
||
| import hs.kr.entrydsm.configuration.domain.document.FileCategory | ||
| import hs.kr.entrydsm.configuration.domain.document.FileExtension | ||
| import hs.kr.entrydsm.configuration.domain.document.command.UploadFileCommand | ||
| import hs.kr.entrydsm.configuration.domain.document.exception.InvalidFileFormatException | ||
| import org.springframework.web.multipart.MultipartFile | ||
|
|
||
| fun MultipartFile.requireExtension(category: FileCategory): FileExtension = | ||
| FileExtension.fromFileName(originalName()) | ||
| ?: throw InvalidFileFormatException(originalName(), category) | ||
|
|
||
| fun MultipartFile.toUploadCommand(category: FileCategory, fileName: String) = UploadFileCommand( | ||
| category = category, | ||
| originalName = originalName(), | ||
| fileName = fileName, | ||
| sizeBytes = size, | ||
| ) | ||
|
|
||
| private fun MultipartFile.originalName(): String = originalFilename.orEmpty() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| package hs.kr.entrydsm.configuration.adapterin.document.dto | ||
|
|
||
| import hs.kr.entrydsm.configuration.domain.document.DownloadUrl | ||
| import hs.kr.entrydsm.configuration.domain.document.FileDocument | ||
|
|
||
| data class UploadFileResponse( | ||
| val key: String, | ||
| val fileName: String, | ||
| ) { | ||
| companion object { | ||
| fun from(fileDocument: FileDocument) = UploadFileResponse( | ||
| key = fileDocument.objectKey, | ||
| fileName = fileDocument.fileName, | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| data class UploadPhotoResponse( | ||
| val key: String, | ||
| val fileName: String, | ||
| val url: String, | ||
| ) | ||
|
|
||
| data class UploadAttachmentResponse( | ||
| val attachmentId: String, | ||
| val key: String, | ||
| val fileName: String, | ||
| val size: Long, | ||
| ) | ||
|
|
||
| data class DownloadUrlResponse( | ||
| val fileName: String, | ||
| val downloadUrl: String, | ||
| val expiresIn: Long, | ||
| ) { | ||
| companion object { | ||
| fun from(downloadUrl: DownloadUrl) = DownloadUrlResponse( | ||
| fileName = downloadUrl.fileName, | ||
| downloadUrl = downloadUrl.downloadUrl, | ||
| expiresIn = downloadUrl.expiresIn, | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| data class FileMetadataResponse( | ||
| val key: String, | ||
| val fileName: String, | ||
| val exists: Boolean, | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: EntryDSM/entrydsm-platform
Length of output: 1400
🏁 Script executed:
Repository: EntryDSM/entrydsm-platform
Length of output: 11160
🏁 Script executed:
Repository: EntryDSM/entrydsm-platform
Length of output: 11160
🏁 Script executed:
Repository: EntryDSM/entrydsm-platform
Length of output: 192
🏁 Script executed:
Repository: EntryDSM/entrydsm-platform
Length of output: 7360
🏁 Script executed:
Repository: EntryDSM/entrydsm-platform
Length of output: 38641
🏁 Script executed:
Repository: EntryDSM/entrydsm-platform
Length of output: 612
🏁 Script executed:
Repository: EntryDSM/entrydsm-platform
Length of output: 611
IllegalArgumentException매핑 범위를 요청 오류로 제한하세요.현재 configuration 소스에는 HTTP controller와 도메인·인프라의 직접적인
IllegalArgumentException발생 지점이 없습니다. 다만 이 매핑은 향후 모든 MVC controller의IllegalArgumentException을INVALID_REQUEST_PARAM으로 분류합니다. 요청 바인딩 예외만 매핑하고, 해당 동작을 검증하는 adapter-in 테스트를 추가하세요.🤖 Prompt for AI Agents